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 IconButton from '@mui/material/IconButton'; import ListItemButton from '@mui/material/ListItemButton'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; import MenuItem from '@mui/material/MenuItem'; import Stack from '@mui/material/Stack'; import Skeleton from '@mui/material/Skeleton'; import LocalShippingIcon from '@mui/icons-material/LocalShipping'; import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; import CloseIcon from '@mui/icons-material/Close'; import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; import Menu from '@mui/material/Menu'; 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 AppDialog from '../AppDialog'; import shipmentAutomat, { CHUNK_SIZE } from '../../automats/shipmentAutomat'; import orderAutomat from '../../automats/orderAutomat'; import numberAutomat from '../../automats/numberAutomat'; import { shipment as shipmentApi } from '../../api'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; import { getMaxDocumentNumber } from '../../utils/documentNumber'; import withNavigate from '../withNavigate'; import ShipmentDetailDialog from './ShipmentDetailDialog'; import { getNextShipmentNumber } from './OrderShipmentDialog'; import { formatDate, loadParties, manifestConfig, numberDisplay, partyDisplayName, } from './manifestConfig'; import { GREEN_PANEL, SURFACE_RAISED, } from '../../colors'; const cfg = manifestConfig('order'); const ellipsisSx = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }; const STATE_CONFIG = { new: { label: 'Neu', color: 'info', icon: LocalShippingIcon }, delivered: { label: 'Geliefert', color: 'success', icon: CheckCircleOutlineOutlinedIcon }, returned: { label: 'Retoure', color: 'error', icon: CloseIcon }, }; // MARK: class NewShipmentModal extends PureComponent { constructor(props) { super(props); this.state = { orderId: '', date: new Date().toISOString().slice(0, 10), externalNumber: '', lineAmounts: {}, saving: false, }; this.handleOrderChange = this.handleOrderChange.bind(this); this.handleSubmit = this.handleSubmit.bind(this); } componentDidUpdate(prevProps) { if (!prevProps.open && this.props.open) { const openOrders = (this.props.orders || []).filter((o) => { const ordQty = Number(o.orderedQuantity) || 0; const shpQty = Number(o.shippedQuantity) || 0; return ordQty === 0 || shpQty < ordQty; }); const first = openOrders[0]; if (first) { this.handleOrderChange(first.id); } } } handleOrderChange(orderId) { const { orders = [] } = this.props; const order = orders.find((o) => o.id === Number(orderId)); const lines = Array.isArray(order?.lines) ? order.lines : []; const shippedBySku = {}; for (const sh of order?.shipments || []) { for (const sl of sh.lines || []) { if (sl.sku) shippedBySku[sl.sku] = (shippedBySku[sl.sku] || 0) + (Number(sl.amount) || 0); } } const lineAmounts = {}; for (const l of lines) { if (!l.sku) continue; const ordered = Number(l.amount) || 0; const shipped = shippedBySku[l.sku] || 0; const remaining = Math.max(0, ordered - shipped); if (remaining > 0) { lineAmounts[l.sku] = String(remaining); } } this.setState({ orderId, externalNumber: order?.externalNumber || '', lineAmounts, }); } handleAmountChange(sku, val) { this.setState((prev) => ({ lineAmounts: { ...prev.lineAmounts, [sku]: val }, })); } async handleSubmit() { const { orderId, date, externalNumber, lineAmounts } = this.state; if (!orderId) { enqueueSnackbar('Bitte wählen Sie einen Auftrag aus.', { variant: 'warning' }); return; } const lines = Object.entries(lineAmounts) .filter(([, amt]) => Number(amt) > 0) .map(([sku, amt]) => ({ sku, amount: Number(amt) })); if (lines.length === 0) { enqueueSnackbar('Mindestens eine Position muss mit Menge > 0 geliefert werden.', { variant: 'warning' }); return; } this.setState({ saving: true }); try { const res = await shipmentApi.create(orderId, { date, externalNumber: externalNumber.trim() || undefined, lines, }); enqueueSnackbar(res.message || 'Lieferschein erfolgreich erstellt.', { variant: 'success' }); void orderAutomat.actions.reload().catch(() => {}); void shipmentAutomat.actions.reload().catch(() => {}); this.props.onSuccess?.(res.shipment); this.props.onClose?.(); } catch (err) { notifyError(err); } finally { this.setState({ saving: false }); } } render() { const { open, onClose, orders = [], partiesById = {} } = this.props; const { orderId, date, externalNumber, lineAmounts, saving } = this.state; const currentOrder = orders.find((o) => o.id === Number(orderId)); const lines = Array.isArray(currentOrder?.lines) ? currentOrder.lines : []; const shippedBySku = {}; for (const sh of currentOrder?.shipments || []) { for (const sl of sh.lines || []) { if (sl.sku) shippedBySku[sl.sku] = (shippedBySku[sl.sku] || 0) + (Number(sl.amount) || 0); } } return ( this.handleOrderChange(e.target.value)} fullWidth > {orders.map((o) => { const customer = partiesById[o.entityId]; const custName = partyDisplayName('order', customer) || (o.entityId ? `Kunde #${o.entityId}` : ''); return ( {o.number} {custName ? `— ${custName}` : ''} {o.date ? `(${formatDate(o.date)})` : ''} ); })} this.setState({ date: e.target.value })} InputLabelProps={{ shrink: true }} sx={{ width: 170 }} /> {currentOrder ? ( ) : null} this.setState({ externalNumber: e.target.value })} sx={{ flex: 1 }} /> {currentOrder && ( Lieferbare Positionen für Auftrag {currentOrder.number}: {lines.map((line) => { const orderedAmt = Number(line.amount) || 0; const shippedAmt = shippedBySku[line.sku] || 0; const remaining = Math.max(0, orderedAmt - shippedAmt); const currentVal = lineAmounts[line.sku] ?? ''; return ( {line.sku} — {line.name || '–'} Bestellt: {orderedAmt} • Bisher geliefert: {shippedAmt} • Noch offen: {remaining} this.handleAmountChange(line.sku, e.target.value)} disabled={remaining <= 0} slotProps={{ htmlInput: { min: 0, max: remaining, step: 'any', style: { textAlign: 'right' } } }} sx={{ width: 110 }} /> ); })} )} ); } } // MARK: class DeliveryStateMenu extends PureComponent { constructor(props) { super(props); this.state = { anchorEl: null, busy: false, }; this.handleOpen = this.handleOpen.bind(this); this.handleClose = this.handleClose.bind(this); this.handleSelectState = this.handleSelectState.bind(this); } handleOpen(e) { e.stopPropagation(); this.setState({ anchorEl: e.currentTarget }); } handleClose(e) { if (e) e.stopPropagation(); this.setState({ anchorEl: null }); } async handleSelectState(newState) { this.setState({ anchorEl: null, busy: true }); try { await shipmentAutomat.actions.updateState(this.props.shipment.id, newState); const label = STATE_CONFIG[newState]?.label || newState; enqueueSnackbar(`Status geändert: ${label}`, { variant: 'success' }); } catch (err) { notifyError(err); } finally { this.setState({ busy: false }); } } render() { const { shipment } = this.props; const { anchorEl, busy } = this.state; const currentState = shipment.state || 'new'; const conf = STATE_CONFIG[currentState] || STATE_CONFIG.new; return ( e.stopPropagation()}> {conf.label} } disabled={busy} onClick={this.handleOpen} sx={{ fontWeight: 600, height: 24, cursor: 'pointer' }} /> e.stopPropagation()} > this.handleSelectState('new')} > Neu (In Vorbereitung) this.handleSelectState('delivered')} > Geliefert (Zugestellt) this.handleSelectState('returned')} > Retoure (Retourniert) ); } } // Dummy shipment generator with max digits for layout testing and probe measurement export function createDummyTestShipment() { const maxOrderNumber = getMaxDocumentNumber('order'); return { id: 999999, number: `${maxOrderNumber}-L-99`, externalNumber: 'REF-999999', date: '2030-10-30T12:00:00Z', orderNumber: maxOrderNumber, customerName: 'Kunde', positions: 999, totalQuantity: 999, packageCount: 9, carrier: 'DHL', trackingCode: '00340434161094042578', state: 'delivered', }; } const rowGridSx = { display: 'grid', gridTemplateColumns: 'max-content max-content max-content 1fr max-content max-content max-content auto', gridTemplateRows: '0px 1fr', rowGap: 0, columnGap: 1.5, alignItems: 'center', width: '100%', boxSizing: 'border-box', }; 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 || createDummyTestShipment(); return ( <> {d.number} {d.externalNumber || 'Nr. / Ref.'} {d.date ? formatDate(d.date) : '–'} {d.orderNumber} {d.customerName} {d.trackingCode} Geliefert } sx={{ fontWeight: 600, height: 24 }} /> ); } /** * Ambient context for `ShipmentRow`: FillSpace renders rows as bare elements * (``), so the list publishes callbacks and party lookup here. */ const rowContext = { partiesById: {}, busyId: null, onOpen: null, onDelete: null, headerItem: null, }; // MARK: class ShipmentRow extends PureComponent { partyName(partyId) { if (partyId == null) return '–'; const party = rowContext.partiesById[partyId]; return partyDisplayName('order', party) || `Kunde #${partyId}`; } render() { const { item: shipment, disabled } = this.props; const dummy = this.props.headerItem || rowContext.headerItem; // Data-free probe (FillSpace measures the row height with `item` undefined). if (!shipment) { if (disabled) { return (   ); } return ( {renderInvisibleDummyRow(dummy)} ); } const { busyId, onOpen, onDelete } = rowContext; const isBusy = busyId === shipment.id; const canDelete = shipment.state === 'new'; return ( onOpen?.(shipment)} disabled={disabled} > {renderInvisibleDummyRow(dummy)} {/* 1. Lieferschein-Nr & Ref */} {shipment.number} {numberDisplay(shipment.externalNumber)} {/* 2. Datum */} {shipment.date ? formatDate(shipment.date) : '–'} {/* 3. Bezugsauftrag */} {shipment.orderNumber ? ( {shipment.orderNumber} ) : ( – )} {/* 4. Kunde */} {shipment.customerName || this.partyName(shipment.entityId)} {/* 5. Positionen */} {/* 6. Paket / Tracking */} {shipment.trackingCode ? ( {shipment.carrier && ( )} {shipment.trackingCode} ) : shipment.packageCount > 0 ? ( {shipment.packageCount} {shipment.packageCount === 1 ? 'Paket' : 'Pakete'} ) : ( – )} {/* 7. Status */} {/* 8. Aktionen (Löschen) */} {canDelete ? ( { e.stopPropagation(); onDelete?.(shipment); }} onMouseDown={(e) => e.stopPropagation()} sx={{ width: 40, height: 40 }} > ) : ( )} ); } } // MARK: class ShipmentSearchField extends PureComponent { constructor(props) { super(props); const s = shipmentAutomat.state; this.state = { input: s.filter ?? '', stateFilter: s.stateFilter ?? '', seekMonths: s.seekMonths ?? 0, seekYears: s.seekYears ?? 0, }; } componentDidMount() { this.unsub = shipmentAutomat.subscribe(this, (state) => ({ input: state.filter ?? '', stateFilter: state.stateFilter ?? '', seekMonths: state.seekMonths ?? 0, seekYears: state.seekYears ?? 0, })); } componentWillUnmount() { this.unsub?.(); } render() { const { inputRef, onCreate } = this.props; const { input, stateFilter, seekMonths, seekYears } = this.state; const monthActive = seekMonths > 0; const monthLabel = !monthActive ? '-1 Monat' : `-${seekMonths} Monat${seekMonths > 1 ? 'e' : ''}`; const yearActive = seekYears > 0; const yearLabel = !yearActive ? '-1 Jahr' : `-${seekYears} Jahr${seekYears > 1 ? 'e' : ''}`; return ( {/* Top row: search field & create button */} shipmentAutomat.actions.setFilter(next)} /> {/* Filter Bar: Status chips & Negativeseek (-1 Month, -1 Year, Reset) */} {/* Left: Status chips */} shipmentAutomat.actions.setStateFilter('')} sx={{ fontWeight: 600, cursor: 'pointer', height: 26 }} /> shipmentAutomat.actions.setStateFilter('new')} sx={{ fontWeight: 600, cursor: 'pointer', height: 26 }} /> shipmentAutomat.actions.setStateFilter('delivered')} sx={{ fontWeight: 600, cursor: 'pointer', height: 26 }} /> shipmentAutomat.actions.setStateFilter('returned')} sx={{ fontWeight: 600, cursor: 'pointer', height: 26 }} /> {/* Right: Negativeseek controls (-1 Month, -1 Year, Reset) */} ); } } // MARK: export class ShipmentList extends PureComponent { constructor(props) { super(props); this.headerItem = createDummyTestShipment(); this.state = { items: shipmentAutomat.getItems(), total: shipmentAutomat.state.total ?? 0, unfilteredTotal: shipmentAutomat.state.unfilteredTotal ?? 0, filterKey: shipmentAutomat.getFilterKey?.() ?? '', orders: orderAutomat.state?.items || [], partiesById: {}, selectedShipment: null, newShipmentModalOpen: false, busyId: null, }; this.searchRef = createRef(); this._cancelled = false; this.handleRetry = this.handleRetry.bind(this); this.handleOpenShipment = this.handleOpenShipment.bind(this); this.handleCloseShipment = this.handleCloseShipment.bind(this); this.handleDeleteShipment = this.handleDeleteShipment.bind(this); this.handlePageChange = this.handlePageChange.bind(this); } componentDidMount() { this._cancelled = false; this.unsub = shipmentAutomat.subscribe(this, (state) => ({ items: state.items, total: state.total ?? 0, unfilteredTotal: state.unfilteredTotal ?? 0, filterKey: shipmentAutomat.getFilterKey(state), })); this.unsubOrder = orderAutomat.subscribe(this, (state) => ({ orders: state.items || [], })); this.unsubNumber = numberAutomat.subscribe(this, () => { this.headerItem = createDummyTestShipment(); this.forceUpdate(); }); void numberAutomat.actions.ensureLoaded().catch(() => {}); void shipmentAutomat.actions.load().catch(() => {}); void orderAutomat.actions.load().catch(() => {}); void this.loadParties(); // Check query params for targeted shipmentId const searchParams = new URLSearchParams(this.props.location?.search || (typeof window !== 'undefined' ? window.location.search : '')); const targetId = Number(searchParams.get('shipmentId') || searchParams.get('id')) || null; if (targetId) { this.setState({ selectedShipment: { id: targetId } }); } } componentDidUpdate(prevProps) { const prevSearch = prevProps.location?.search; const currentSearch = this.props.location?.search; if (prevSearch !== currentSearch) { const searchParams = new URLSearchParams(currentSearch || (typeof window !== 'undefined' ? window.location.search : '')); const targetId = Number(searchParams.get('shipmentId') || searchParams.get('id')) || null; if (targetId && targetId !== this.state.selectedShipment?.id) { this.setState({ selectedShipment: { id: targetId } }); } } } componentWillUnmount() { this._cancelled = true; this.unsub?.(); this.unsubOrder?.(); this.unsubNumber?.(); } async loadParties() { try { const parties = await loadParties('order'); if (!this._cancelled) { this.setState({ partiesById: cfg.partiesByIdFromList(parties) }); } } catch { // Non-fatal } } 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); shipmentAutomat.requireChunk(startChunk, endChunk); } handleRetry() { void shipmentAutomat.actions.requireChunk(0, 0, { immediate: true }).catch(() => {}); } handleOpenShipment(shipment) { this.setState({ selectedShipment: shipment }); } handleCloseShipment() { this.setState({ selectedShipment: null }); } async handleDeleteShipment(sh) { if (this.state.busyId != null) return; if (!window.confirm(`Lieferschein „${sh.number}“ wirklich löschen?`)) return; this.setState({ busyId: sh.id }); try { await shipmentAutomat.actions.deleteShipment(sh.id); enqueueSnackbar(`Lieferschein ${sh.number} gelöscht.`, { variant: 'success' }); } catch (err) { notifyError(err); } finally { if (!this._cancelled) this.setState({ busyId: null }); } } render() { const { items, total, unfilteredTotal, filterKey, orders, partiesById, selectedShipment, newShipmentModalOpen, busyId, } = this.state; rowContext.partiesById = partiesById; rowContext.busyId = busyId; rowContext.onOpen = this.handleOpenShipment; rowContext.onDelete = this.handleDeleteShipment; rowContext.headerItem = this.headerItem; if (items === null && shipmentAutomat.state.status === 'failed') { return ( <> this.setState({ newShipmentModalOpen: true })} /> ); } return ( <> this.setState({ newShipmentModalOpen: true })} /> {renderInvisibleDummyRow(this.headerItem)} Lieferschein / Ref Datum Auftrag Kunde Pos. Paket / Tracking Status } onPageChange={this.handlePageChange} /> {items === null && ( )} {/* Modal: View / Manage Shipment Details */} {selectedShipment && ( { void shipmentAutomat.actions.reload(); void orderAutomat.actions.reload(); }} /> )} {/* Modal: Create new shipment from open order */} {newShipmentModalOpen && ( this.setState({ newShipmentModalOpen: false })} onSuccess={(created) => { if (created) { this.setState({ selectedShipment: created }); } }} /> )} ); } } export default withNavigate(ShipmentList);