import { PureComponent, createRef } from 'react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Chip from '@mui/material/Chip'; import CircularProgress from '@mui/material/CircularProgress'; import Divider from '@mui/material/Divider'; import FormControlLabel from '@mui/material/FormControlLabel'; import IconButton from '@mui/material/IconButton'; import ListItemButton from '@mui/material/ListItemButton'; import Typography from '@mui/material/Typography'; import Checkbox from '@mui/material/Checkbox'; import Switch from '@mui/material/Switch'; import Skeleton from '@mui/material/Skeleton'; import Tooltip from '@mui/material/Tooltip'; import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import ViewModuleIcon from '@mui/icons-material/ViewModule'; import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined'; import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'; import { enqueueSnackbar } from 'notistack'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import FillSpace from 'dashgui/FillSpace'; import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import BackendError from '../BackendError'; import orderAutomat, { CHUNK_SIZE, isOrderPaid, isOrderDelivered, } from '../../automats/orderAutomat'; import companyAutomat from '../../automats/companyAutomat'; import { order as orderApi } from '../../api'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; import { formatPrice } from '../../utils/price'; import { getMaxDocumentNumber } from '../../utils/documentNumber'; import PartyFilterPanel, { PartyFilterButton, PartyFilterChip } from './PartyFilterPanel'; import withNavigate from '../withNavigate'; import { formatDate, loadParties, manifestConfig, numberDisplay, partyDisplayName, } from './manifestConfig'; // MARK: pure helpers function orderGrossTotal(lines) { if (!Array.isArray(lines)) return 0; return lines.reduce((acc, line) => { const net = (Number(line.amount) || 0) * (Number(line.net) || 0) * (1 - (Number(line.rebate) || 0) / 100); return acc + net * (1 + (Number(line.taxrate) || 0) / 100); }, 0); } const cfg = manifestConfig('order'); // Dummy order generator using the active sequence scheme with max digits for layout testing export function createDummyTestOrder() { const maxNumber = getMaxDocumentNumber('order'); return { id: 999999, number: maxNumber, externalNumber: 'REF-999999', date: '2030-10-30T12:00:00Z', customerName: 'Kunde', positions: 999, grossTotal: 30000.90, }; } const rowGridSx = { display: 'grid', gridTemplateColumns: 'max-content max-content 1fr max-content max-content max-content max-content auto', gridTemplateRows: '0px 1fr', rowGap: 0, columnGap: 1.5, alignItems: 'center', width: '100%', boxSizing: 'border-box', }; const ellipsisSx = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }; const measuringCellSx = { visibility: 'hidden', height: 0, boxSizing: 'border-box', pointerEvents: 'none', textTransform: 'none', letterSpacing: 'normal', }; const headerCellSx = { color: 'text.secondary', typography: 'caption', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', userSelect: 'none', }; function renderInvisibleDummyRow(dummy) { const d = dummy || createDummyTestOrder(); return ( <> {d.number} {d.externalNumber || 'Nr. / Ref.'} {d.date ? formatDate(d.date) : '–'} {d.customerName} {formatPrice(d.grossTotal)} ); } /** * Ambient context for `OrderRow`: FillSpace renders rows as bare elements * (``), so the list publishes callbacks and the party map * here on every render. */ const rowContext = { partiesById: {}, busyId: null, onOpen: null, onDelete: null, onTogglePaid: null, onToggleDelivered: null, headerItem: null, }; // MARK: class OrderRow extends PureComponent { partyName(partyId) { if (partyId == null) return '–'; const party = rowContext.partiesById[partyId]; return partyDisplayName('order', party) || `Kunde #${partyId}`; } render() { const { item: order, disabled } = this.props; const dummy = this.props.headerItem || rowContext.headerItem; // Data-free probe (FillSpace measures the row height with `item` undefined). if (!order) { if (disabled) { return (   ); } return ( {renderInvisibleDummyRow(dummy)} ); } const { busyId, onOpen, onDelete, onTogglePaid, onToggleDelivered } = rowContext; const lines = order.lines ?? []; const gross = orderGrossTotal(lines); const paid = isOrderPaid(order); const delivered = isOrderDelivered(order); return ( onOpen?.(order)} disabled={disabled} > {renderInvisibleDummyRow(dummy)} {order.number} {numberDisplay(order.externalNumber)} {order.date ? formatDate(order.date) : '–'} {this.partyName(order.entityId)} e.stopPropagation()} onChange={(e) => onTogglePaid?.(order, e.target.checked)} slotProps={{ input: { 'aria-label': `Auftrag ${order.number} bezahlt` } }} sx={{ p: 0.5 }} /> e.stopPropagation()} onChange={(e) => onToggleDelivered?.(order, e.target.checked)} slotProps={{ input: { 'aria-label': `Auftrag ${order.number} geliefert` } }} sx={{ p: 0.5 }} /> 0 ? 'text.primary' : 'text.disabled', '& .MuiChip-label': { px: 0.5 }, }} /> {gross > 0 ? formatPrice(gross) : '—'} { e.stopPropagation(); onDelete?.(order); }} onMouseDown={(e) => e.stopPropagation()} sx={{ width: 40, height: 40 }} > ); } } // MARK: class OrderSearchField extends PureComponent { constructor(props) { super(props); const s = orderAutomat.state; const stats = orderAutomat.getChunkStats?.(); this.state = { input: s.filter ?? '', onlyUnpaid: s.onlyUnpaid ?? false, onlyUndelivered: s.onlyUndelivered ?? false, seekMonths: s.seekMonths ?? 0, seekYears: s.seekYears ?? 0, negativeSeekMonths: s.negativeSeekMonths ?? 0, lruChunksCount: stats?.lruChunksCount || 0, maxLruChunks: stats?.maxLruChunks || 50, filterCachesCount: stats?.filterCaches?.length || 1, }; } componentDidMount() { this.unsub = orderAutomat.subscribe(this, (state) => ({ input: state.filter ?? '', onlyUnpaid: state.onlyUnpaid ?? false, onlyUndelivered: state.onlyUndelivered ?? false, seekMonths: state.seekMonths ?? 0, seekYears: state.seekYears ?? 0, negativeSeekMonths: state.negativeSeekMonths ?? 0, })); if (import.meta.env.DEV) { this.unsubStats = orderAutomat.subscribeChunkStats?.((stats) => { this.setState({ lruChunksCount: stats.lruChunksCount, maxLruChunks: stats.maxLruChunks, filterCachesCount: stats.filterCaches?.length || 1, }); }); } } componentWillUnmount() { this.unsub?.(); this.unsubStats?.(); } render() { const { inputRef, onCreate, customerFilterOpen, onToggleCustomerFilter, selectedCustomer, onClearCustomerFilter, } = this.props; const { input, onlyUnpaid, onlyUndelivered, seekMonths, seekYears } = this.state; const monthActive = seekMonths > 0; const monthLabel = !monthActive ? '-1 Month' : `-${seekMonths} Month${seekMonths > 1 ? 's' : ''}`; const yearActive = seekYears > 0; const yearLabel = !yearActive ? '-1 Year' : `-${seekYears} Year${seekYears > 1 ? 's' : ''}`; return ( {/* Top row: search field, customer filter button, active chip & create button */} orderAutomat.actions.filter(next)} /> {selectedCustomer && ( )} {/* Filter Bar: "Nur ungezahlte", "Nur ungelieferte", Negativeseek (-1 Month, -1 Year, Reset) */} {/* Left: Unpaid & Undelivered toggles */} orderAutomat.actions.setOnlyUnpaid(e.target.checked)} /> } label={ Nur ungezahlte } sx={{ m: 0 }} /> orderAutomat.actions.setOnlyUndelivered(e.target.checked)} /> } label={ Nur ungelieferte } sx={{ m: 0 }} /> {/* Right: Negativeseek controls (-1 Month, -1 Year, Reset) */} {import.meta.env.DEV && ( } label={this.state.lruChunksCount > 0 ? `Chunk-Cache (${this.state.lruChunksCount}/${this.state.maxLruChunks || 50})` : 'Chunk-Cache'} onClick={() => window.dispatchEvent(new CustomEvent('dash:toggle-chunk-monitor'))} sx={{ cursor: 'pointer', height: 28, fontWeight: 600, fontSize: '0.75rem', bgcolor: 'background.paper', boxShadow: 1, '&:hover': { bgcolor: 'action.hover' }, }} /> )} ); } } // MARK: export class OrderList extends PureComponent { constructor(props) { super(props); this.headerItem = createDummyTestOrder(); // getItems() returns current items or null and triggers loading of the first chunk this.state = { items: orderAutomat.getItems(), unfilteredTotal: orderAutomat.state.unfilteredTotal ?? 0, filterKey: orderAutomat.getFilterKey?.() ?? '', entityId: orderAutomat.state.entityId ?? null, customerFilterOpen: false, partiesById: {}, partiesList: [], busyId: null, }; this.searchRef = createRef(); this._cancelled = false; this.handleRetry = this.handleRetry.bind(this); this.handleCreate = this.handleCreate.bind(this); this.handleOpen = this.handleOpen.bind(this); this.handleDelete = this.handleDelete.bind(this); this.handleTogglePaid = this.handleTogglePaid.bind(this); this.handleToggleDelivered = this.handleToggleDelivered.bind(this); this.handlePageChange = this.handlePageChange.bind(this); this.handleToggleCustomerFilter = this.handleToggleCustomerFilter.bind(this); this.handleSelectCustomer = this.handleSelectCustomer.bind(this); } componentDidMount() { this._cancelled = false; this.unsub = orderAutomat.subscribe(this, (state) => ({ items: state.items, unfilteredTotal: state.unfilteredTotal ?? 0, filterKey: orderAutomat.getFilterKey(state), entityId: state.entityId ?? null, })); this.unsubNumber = companyAutomat.subscribe( this, () => { this.headerItem = createDummyTestOrder(); this.forceUpdate(); }, ['numbers'], ); void companyAutomat.actions.ensureNumbersLoaded().catch(() => {}); void this.loadParties(); } componentWillUnmount() { this._cancelled = true; this.unsub?.(); this.unsubNumber?.(); } handleToggleCustomerFilter() { this.setState((prev) => ({ customerFilterOpen: !prev.customerFilterOpen })); } handleSelectCustomer(entityId) { orderAutomat.actions.setEntityId(entityId); } async loadParties() { try { const parties = await loadParties('order'); if (!this._cancelled) { this.setState({ partiesById: cfg.partiesByIdFromList(parties), partiesList: parties || [], }); } } catch { // Non-fatal: names fall back to ids. } } handlePageChange(page, meta) { const pageSize = meta?.pageSize ?? 10; const startIndex = (page - 1) * pageSize; const items = this.state.items; const maxIndex = items ? items.length - 1 : startIndex; const endIndex = Math.min(maxIndex, startIndex + pageSize - 1); const startChunk = Math.floor(startIndex / CHUNK_SIZE); const endChunk = Math.floor(endIndex / CHUNK_SIZE); orderAutomat.requireChunk(startChunk, endChunk); } handleRetry() { void orderAutomat.actions.requireChunk(0, 0, { immediate: true }).catch(() => {}); } handleCreate() { this.props.navigate(cfg.createPath); } handleOpen(order) { this.props.navigate(cfg.editPath(order.id)); } async handleDelete(order) { if (this.state.busyId != null) return; if (!window.confirm(cfg.confirmDelete(order.number))) return; this.setState({ busyId: order.id }); try { await orderApi.delete(order.id); await orderAutomat.actions.reload(); enqueueSnackbar(cfg.deletedMsg, { variant: 'success' }); } catch (err) { notifyError(err); } finally { if (!this._cancelled) this.setState({ busyId: null }); } } async handleTogglePaid(order, checked) { if (this.state.busyId != null) return; const patch = { paid: checked, payed: checked, isPaid: checked }; orderAutomat.actions.patchOrder(order.id, patch); try { await orderApi.update(order.id, { paid: checked }); enqueueSnackbar(checked ? 'Auftrag als bezahlt markiert' : 'Auftrag als unbezahlt markiert', { variant: 'info', }); } catch (err) { console.warn('[OrderList] update paid on backend failed:', err); } } async handleToggleDelivered(order, checked) { if (this.state.busyId != null) return; const patch = { delivered: checked, isDelivered: checked }; orderAutomat.actions.patchOrder(order.id, patch); try { await orderApi.update(order.id, { delivered: checked }); enqueueSnackbar(checked ? 'Auftrag als geliefert markiert' : 'Auftrag als ungeliefert markiert', { variant: 'info', }); } catch (err) { console.warn('[OrderList] update delivered on backend failed:', err); } } render() { const { items, unfilteredTotal, filterKey, partiesById, partiesList, entityId, customerFilterOpen, busyId, } = this.state; rowContext.partiesById = partiesById; rowContext.busyId = busyId; rowContext.onOpen = this.handleOpen; rowContext.onDelete = this.handleDelete; rowContext.onTogglePaid = this.handleTogglePaid; rowContext.onToggleDelivered = this.handleToggleDelivered; rowContext.headerItem = this.headerItem; const customerCounts = {}; if (Array.isArray(items)) { for (const ord of items) { if (ord?.entityId) { customerCounts[ord.entityId] = (customerCounts[ord.entityId] || 0) + 1; } } } const selectedCustomer = entityId ? (partiesById[entityId] || { id: entityId, name: `Kunde #${entityId}` }) : null; const searchField = ( this.handleSelectCustomer(null)} /> ); if (items === null && orderAutomat.state.status === 'failed') { return ( <> {searchField} ); } return ( <> {searchField} {customerFilterOpen && ( )} {renderInvisibleDummyRow(this.headerItem)} Nr. / Ref. Datum Kunde Pos. Gesamt } onPageChange={this.handlePageChange} /> {items === null && ( )} ); } } export default withNavigate(OrderList);