import { PureComponent } 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 IconButton from '@mui/material/IconButton'; import ListItemButton from '@mui/material/ListItemButton'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; import TableCell from '@mui/material/TableCell'; import TableContainer from '@mui/material/TableContainer'; import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import MenuItem from '@mui/material/MenuItem'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import LocalShippingIcon from '@mui/icons-material/LocalShipping'; import AddIcon from '@mui/icons-material/Add'; import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; import CloseIcon from '@mui/icons-material/Close'; import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; import Inventory2Icon from '@mui/icons-material/Inventory2'; import { enqueueSnackbar } from 'notistack'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import BackendError from '../BackendError'; import AppDialog from '../AppDialog'; import orderAutomat from '../../automats/orderAutomat'; import { shipment as shipmentApi, shipping as shippingApi } from '../../api'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; import withNavigate from '../withNavigate'; import { formatDate, loadParties, manifestConfig, numberDisplay, partyDisplayName, } from './manifestConfig'; import { GREEN_PANEL, SURFACE_BASE, SURFACE_RAISED, WHITE, } from '../../colors'; const cfg = manifestConfig('order'); const CARRIERS = ['DHL', 'UPS', 'DPD', 'GLS', 'Hermes', 'Spedition', 'Selbstabholung']; const ellipsisSx = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }; // MARK: class AddPackageDialog extends PureComponent { constructor(props) { super(props); this.state = { carrier: 'DHL', trackingCode: '', type: 'parcel', state: 'draft', saving: false, }; this.handleSubmit = this.handleSubmit.bind(this); } async handleSubmit() { const { shipment, onClose, onSuccess } = this.props; const { carrier, trackingCode, type, state } = this.state; this.setState({ saving: true }); try { await shippingApi.create(shipment.id, { carrier: carrier.trim() || undefined, trackingCode: trackingCode.trim() || undefined, type, state, }); enqueueSnackbar('Paket angelegt.', { variant: 'success' }); await orderAutomat.actions.reload(); onSuccess?.(); onClose(); } catch (err) { notifyError(err); this.setState({ saving: false }); } } render() { const { open, onClose, shipment } = this.props; const { carrier, trackingCode, type, state, saving } = this.state; return ( Paket zu Lieferschein hinzufügen Lieferschein: {shipment?.number} this.setState({ carrier: e.target.value })} > {CARRIERS.map((c) => ( {c} ))} this.setState({ trackingCode: e.target.value })} placeholder="z.B. 00340434234234234" /> this.setState({ type: e.target.value })} > Paket (Parcel) Palette (Pallet) Brief / Dokument this.setState({ state: e.target.value })} helperText={state === 'shipped' ? 'Bucht bei Auswahl direkt das Lager ab.' : undefined} > Entwurf (draft) Gepackt (packed) Versendet (shipped — Lagerabbuchung) ); } } // MARK: class NewShipmentModal extends PureComponent { constructor(props) { super(props); const orders = props.orders.filter((o) => { const ordQty = Number(o.orderedQuantity) || 0; const shpQty = Number(o.shippedQuantity) || 0; return ordQty === 0 || shpQty < ordQty; }); const selectedOrder = orders[0] || null; const initialAmounts = {}; if (selectedOrder) { this.populateAmounts(selectedOrder, initialAmounts); } this.state = { orderId: selectedOrder?.id || '', date: new Date().toISOString().slice(0, 10), externalNumber: '', lineAmounts: initialAmounts, saving: false, }; this.handleOrderChange = this.handleOrderChange.bind(this); this.handleAmountChange = this.handleAmountChange.bind(this); this.handleSubmit = this.handleSubmit.bind(this); } populateAmounts(order, targetMap) { 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); } } for (const l of order.lines || []) { if (!l.sku) continue; const orderedAmt = Number(l.amount) || 0; const shippedAmt = shippedBySku[l.sku] || 0; const rem = Math.max(0, orderedAmt - shippedAmt); targetMap[l.sku] = rem > 0 ? String(rem) : '0'; } } handleOrderChange(newOrderId) { const order = this.props.orders.find((o) => o.id === newOrderId); const newAmounts = {}; if (order) { this.populateAmounts(order, newAmounts); } this.setState({ orderId: newOrderId, lineAmounts: newAmounts }); } handleAmountChange(sku, val) { this.setState((prev) => ({ lineAmounts: { ...prev.lineAmounts, [sku]: val }, })); } async handleSubmit() { const { orders, onClose, onSuccess } = this.props; const { orderId, date, externalNumber, lineAmounts } = this.state; const order = orders.find((o) => o.id === Number(orderId)); if (!order) return; const linesToShip = []; for (const [sku, amtStr] of Object.entries(lineAmounts)) { const amt = Number(amtStr); if (amt > 0) { const orderLine = order.lines?.find((l) => l.sku === sku); linesToShip.push({ sku, amount: amt, name: orderLine?.name ?? '', pu: orderLine?.pu ?? 1, net: orderLine?.net ?? 0, rebate: orderLine?.rebate ?? 0, taxrate: orderLine?.taxrate ?? 0, }); } } if (linesToShip.length === 0) { enqueueSnackbar('Mindestens ein Artikel muss eine Liefermenge > 0 haben.', { variant: 'warning' }); return; } this.setState({ saving: true }); try { const res = await shipmentApi.create(order.id, { date, externalNumber: externalNumber.trim() || undefined, lines: linesToShip, }); enqueueSnackbar(`Lieferschein ${res.shipment?.number || ''} erfolgreich angelegt!`, { variant: 'success' }); await orderAutomat.actions.reload(); onSuccess?.(res.shipment?.id); onClose(); } catch (err) { notifyError(err); this.setState({ saving: false }); } } render() { const { open, onClose, orders, partiesById } = this.props; const { orderId, date, externalNumber, lineAmounts, saving } = this.state; const openOrders = orders.filter((o) => { const ordQty = Number(o.orderedQuantity) || 0; const shpQty = Number(o.shippedQuantity) || 0; return ordQty === 0 || shpQty < ordQty; }); 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 ( Neuen Lieferschein erstellen (Shipment Manifest) this.handleOrderChange(e.target.value)} fullWidth > {openOrders.map((o) => { const party = partiesById[o.entityId]; const partyName = partyDisplayName('order', party) || `Kunde #${o.entityId}`; return ( {o.number} — {partyName} ({o.shippedQuantity || 0}/{o.orderedQuantity || 0} geliefert) ); })} this.setState({ date: e.target.value })} InputLabelProps={{ shrink: true }} sx={{ width: 180 }} /> this.setState({ externalNumber: e.target.value })} sx={{ flex: 1 }} /> {currentOrder && ( <> Zu liefernde Positionen (Maximal restante Bestellmenge) SKU Artikel Bestellt Geliefert Offen Liefermenge {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] ?? '0'; return ( {line.sku} {line.name} {orderedAmt} {shippedAmt} 0 ? 'primary.main' : 'text.disabled' }}> {remaining} this.handleAmountChange(line.sku, e.target.value)} disabled={remaining <= 0} inputProps={{ min: 0, max: remaining, step: 'any', style: { textAlign: 'right' } }} sx={{ width: 100 }} /> ); })}
)}
); } } // MARK: export class ShipmentList extends PureComponent { constructor(props) { super(props); const s = orderAutomat.state; this.state = { status: s.status === 'ready' ? 'ready' : 'loading', error: s.error ? backendErrorFrom(s.error) : null, items: s.items ?? [], filter: '', partiesById: {}, selectedShipmentId: null, addPackageDialogOpen: false, newShipmentModalOpen: false, busyAction: false, }; this._cancelled = false; this.handleRetry = this.handleRetry.bind(this); this.handleSelectShipment = this.handleSelectShipment.bind(this); this.handleDeleteShipment = this.handleDeleteShipment.bind(this); this.handleDeletePackage = this.handleDeletePackage.bind(this); this.handleMarkPackageShipped = this.handleMarkPackageShipped.bind(this); } componentDidMount() { this._cancelled = false; this.unsub = orderAutomat.subscribe(this, (state) => { const items = state.items ?? []; this.setState((prev) => { // If current selectedShipmentId is no longer found, pick first const allShipments = this.extractAllShipments(items); let selectedShipmentId = prev.selectedShipmentId; if (!selectedShipmentId && allShipments.length > 0) { selectedShipmentId = allShipments[0].id; } else if (selectedShipmentId && !allShipments.some((s) => s.id === selectedShipmentId)) { selectedShipmentId = allShipments[0]?.id || null; } return { items, status: state.status, error: state.error ? backendErrorFrom(state.error) : null, selectedShipmentId, }; }); }); if (orderAutomat.state.status === 'idle') { void orderAutomat.actions.load().catch(() => {}); } void this.loadPartiesData(); } componentWillUnmount() { this._cancelled = true; this.unsub?.(); } async loadPartiesData() { try { const parties = await loadParties('order'); if (!this._cancelled) { this.setState({ partiesById: cfg.partiesByIdFromList(parties) }); } } catch { // Non-fatal } } extractAllShipments(orders = this.state.items) { const list = []; for (const order of orders) { if (!order || !Array.isArray(order.shipments)) continue; for (const sh of order.shipments) { list.push({ ...sh, parentOrderId: order.id, parentOrderNumber: order.number, parentOrderDate: order.date, entityId: order.entityId, parentOrder: order, }); } } // Sort descending by date / id return list.sort((a, b) => { const cmp = String(b.date || '').localeCompare(String(a.date || '')); if (cmp !== 0) return cmp; return Number(b.id || 0) - Number(a.id || 0); }); } handleRetry() { void orderAutomat.actions.load().catch(() => {}); } handleSelectShipment(shipment) { this.setState({ selectedShipmentId: shipment.id }); } async handleDeleteShipment(sh) { if (!window.confirm(`Lieferschein „${sh.number}“ wirklich löschen?`)) return; this.setState({ busyAction: true }); try { await shipmentApi.delete(sh.id); enqueueSnackbar(`Lieferschein ${sh.number} gelöscht.`, { variant: 'success' }); await orderAutomat.actions.reload(); } catch (err) { notifyError(err); } finally { this.setState({ busyAction: false }); } } async handleDeletePackage(pkg) { if (!window.confirm(`Paket „#${pkg.number || pkg.id}“ wirklich löschen?`)) return; this.setState({ busyAction: true }); try { await shippingApi.delete(pkg.id); enqueueSnackbar('Paket gelöscht.', { variant: 'success' }); await orderAutomat.actions.reload(); } catch (err) { notifyError(err); } finally { this.setState({ busyAction: false }); } } async handleMarkPackageShipped(pkg) { this.setState({ busyAction: true }); try { await shippingApi.update(pkg.id, { state: 'shipped' }); enqueueSnackbar('Paket als versendet markiert (Lagerbestand abgebucht).', { variant: 'success' }); await orderAutomat.actions.reload(); } catch (err) { notifyError(err); } finally { this.setState({ busyAction: false }); } } getFilteredShipments() { const all = this.extractAllShipments(); const q = this.state.filter.trim().toLowerCase(); if (!q) return all; const { partiesById } = this.state; return all.filter((sh) => { const party = partiesById[sh.entityId]; const partyName = partyDisplayName('order', party); const trackingCodes = (sh.shippings || []).map((p) => p.trackingCode).filter(Boolean); const carriers = (sh.shippings || []).map((p) => p.carrier).filter(Boolean); return [sh.number, sh.parentOrderNumber, sh.externalNumber, partyName, ...trackingCodes, ...carriers] .some((val) => String(val || '').toLowerCase().includes(q)); }); } render() { const { status, error, filter, partiesById, selectedShipmentId, addPackageDialogOpen, newShipmentModalOpen, busyAction, items } = this.state; if (status === 'loading' || status === 'idle') { return ( ); } if (status === 'failed') { return ; } const filteredShipments = this.getFilteredShipments(); const selectedShipment = filteredShipments.find((s) => s.id === selectedShipmentId) || this.extractAllShipments().find((s) => s.id === selectedShipmentId) || filteredShipments[0] || null; const shPackages = Array.isArray(selectedShipment?.shippings) ? selectedShipment.shippings : []; const shLines = Array.isArray(selectedShipment?.lines) ? selectedShipment.lines : []; const hasShippedPackages = shPackages.some((p) => p.state === 'shipped'); return ( <> {/* Top Filter and Action Bar */} this.setState({ filter: next })} /> {/* Master-Detail Split Grid */} {/* Left: Shipment Manifests List */} Lieferscheine (Shipments) {filteredShipments.length === 0 ? ( Keine Lieferscheine gefunden. ) : ( filteredShipments.map((sh) => { const isSelected = selectedShipment?.id === sh.id; const party = partiesById[sh.entityId]; const partyName = partyDisplayName('order', party) || `Kunde #${sh.entityId}`; const pkgCount = Array.isArray(sh.shippings) ? sh.shippings.length : 0; const lineCount = Array.isArray(sh.lines) ? sh.lines.length : 0; const allShipped = pkgCount > 0 && sh.shippings.every((p) => p.state === 'shipped'); return ( this.handleSelectShipment(sh)} sx={{ py: 1.25, px: 1.75, display: 'flex', flexDirection: 'column', alignItems: 'stretch', gap: 0.5, bgcolor: isSelected ? 'action.selected' : 'inherit', }} > {sh.number} {sh.date ? formatDate(sh.date) : '–'} Auftrag: {sh.parentOrderNumber} • {partyName} 0 ? 'primary' : 'default'} label={`${pkgCount} Paket${pkgCount === 1 ? '' : 'e'}`} sx={{ height: 18, fontSize: '0.65rem', fontWeight: 600 }} /> ); }) )} {/* Right: Selected Shipment Detail & Shipping Packages */} {selectedShipment ? ( {/* Header */} {selectedShipment.number} Zugehöriger Auftrag: {selectedShipment.parentOrderNumber} • Datum: {selectedShipment.date ? formatDate(selectedShipment.date) : '–'} {selectedShipment.externalNumber ? ` • Ref: ${selectedShipment.externalNumber}` : ''} {!hasShippedPackages && ( this.handleDeleteShipment(selectedShipment)} aria-label={`Lieferschein ${selectedShipment.number} löschen`} > )} {/* Shipped Article Positions */} Positionen dieses Lieferscheins ({shLines.length}) SKU Bezeichnung Menge {shLines.map((line, idx) => ( {line.sku} {line.name} {line.amount} ))}
{/* Shipping Packages from 'shipping' Table */} Pakete aus der Versandtabelle (shipping) ({shPackages.length}) {shPackages.length === 0 ? ( Keine Pakete (Shippings) erfasst. ) : ( {shPackages.map((pkg) => { const isShipped = pkg.state === 'shipped'; return ( {pkg.carrier || 'DHL'} ({pkg.type === 'pallet' ? 'Palette' : 'Paket'}) {pkg.trackingCode ? ( {pkg.trackingCode} { navigator.clipboard.writeText(pkg.trackingCode); enqueueSnackbar('Tracking-Code kopiert!', { variant: 'info' }); }} sx={{ p: 0.25 }} > ) : ( Ohne Tracking )} {isShipped ? ( } label="Versendet (Lager abgebucht)" sx={{ fontWeight: 600 }} /> ) : ( <> this.handleDeletePackage(pkg)} > )} ); })} )}
) : ( Wählen Sie einen Lieferschein aus der linken Liste aus. )}
{addPackageDialogOpen && selectedShipment && ( this.setState({ addPackageDialogOpen: false })} /> )} {newShipmentModalOpen && ( this.setState({ newShipmentModalOpen: false })} onSuccess={(newId) => { if (newId) this.setState({ selectedShipmentId: newId }); }} /> )} ); } } export default withNavigate(ShipmentList);