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 DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; 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 { order as orderApi } from '../../api'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; import { formatPrice } from '../../utils/price'; 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'); const rowSx = { alignItems: 'center', height: 64, minHeight: 64, maxHeight: 64, boxSizing: 'border-box', py: 0, gap: 1.5, }; const ellipsisSx = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }; /** * 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, }; // 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; // Data-free probe (FillSpace measures the row height with `item` undefined). if (!order) { if (disabled) { return (   ); } return ( ); } 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)}> {order.number} {numberDisplay(order.externalNumber)} {order.date ? formatDate(order.date) : '–'} {this.partyName(order.entityId)} e.stopPropagation()} onChange={(e) => onTogglePaid?.(order, e.target.checked)} inputProps={{ 'aria-label': `Auftrag ${order.number} bezahlt` }} sx={{ p: 0.5 }} /> e.stopPropagation()} onChange={(e) => onToggleDelivered?.(order, e.target.checked)} inputProps={{ '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, flexShrink: 0 }} > ); } } // MARK: class OrderSearchField extends PureComponent { constructor(props) { super(props); const s = orderAutomat.state; 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, }; } 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, })); } componentWillUnmount() { this.unsub?.(); } render() { const { inputRef, onCreate } = 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 & create button */} orderAutomat.actions.filter(next)} /> {/* 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) */} ); } } // MARK: export class OrderList extends PureComponent { constructor(props) { super(props); // getItems() returns current items or null and triggers loading of the first chunk this.state = { items: orderAutomat.getItems(), partiesById: {}, 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); } componentDidMount() { this._cancelled = false; // Subscribe to items only this.unsub = orderAutomat.subscribe(this, (state) => ({ items: state.items, })); void this.loadParties(); } componentWillUnmount() { this._cancelled = true; this.unsub?.(); } async loadParties() { try { const parties = await loadParties('order'); if (!this._cancelled) { this.setState({ partiesById: cfg.partiesByIdFromList(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, partiesById, busyId } = this.state; if (items === null) { if (orderAutomat.state.status === 'failed') { return ; } return ( ); } rowContext.partiesById = partiesById; rowContext.busyId = busyId; rowContext.onOpen = this.handleOpen; rowContext.onDelete = this.handleDelete; rowContext.onTogglePaid = this.handleTogglePaid; rowContext.onToggleDelivered = this.handleToggleDelivered; return ( <> Nr. / Ref. Datum Kunde Bezahlt Geliefert Pos. Gesamt } onPageChange={this.handlePageChange} /> ); } } export default withNavigate(OrderList);