import { PureComponent } from 'react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Checkbox from '@mui/material/Checkbox'; import Chip from '@mui/material/Chip'; import CircularProgress from '@mui/material/CircularProgress'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; import IconButton from '@mui/material/IconButton'; import Stack from '@mui/material/Stack'; 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 TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; import Alert from '@mui/material/Alert'; import CloseIcon from '@mui/icons-material/Close'; import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'; import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; import { enqueueSnackbar } from 'notistack'; import AppDialog from '../AppDialog'; import { order as orderApi } from '../../api'; import { notifyError } from '../../utils/notify'; import { formatStock } from '../article/searchFormat'; import artikelAutomat, { getArticle } from '../../automats/artikelAutomat'; import { todayInputValue } from './manifestConfig'; // MARK: helper to sum already shipped quantities per SKU, orderLineId, and line name across shipments export function computeShippedSummary(shipments) { const bySku = {}; const byOrderLineId = {}; const byName = {}; for (const sh of shipments || []) { for (const sl of sh.lines || []) { const amt = Number(sl.amount) || 0; if (sl.sku) { const k = String(sl.sku).trim(); bySku[k] = (bySku[k] || 0) + amt; } if (sl.orderLineId != null) { byOrderLineId[sl.orderLineId] = (byOrderLineId[sl.orderLineId] || 0) + amt; } if (sl.name) { const n = String(sl.name).trim().toLowerCase(); byName[n] = (byName[n] || 0) + amt; } } } return { bySku, byOrderLineId, byName }; } function getItemKey(line, index) { if (line.sku && String(line.sku).trim()) { return `sku:${String(line.sku).trim()}`; } if (line.id != null) { return `id:${line.id}`; } return `idx:${index}`; } // MARK: helper to determine next shipment number preview export function getNextShipmentNumber(orderNumber, shipments = []) { if (!orderNumber) return '–'; let maxIdx = 0; for (const s of shipments || []) { const num = String(s.number ?? '').trim(); const match = num.match(/-L-(\d+)$/i) || num.match(/-(\d+)$/); if (match) { const parsed = parseInt(match[1], 10); if (!Number.isNaN(parsed) && parsed > maxIdx) { maxIdx = parsed; } } } const nextIdx = maxIdx > 0 ? maxIdx + 1 : ((shipments?.length || 0) + 1); return `${orderNumber}-L-${nextIdx}`; } /** * Dialog for selecting items and quantities to create a shipment from a sales order. * Calls POST /api/order/:id/shipment. */ export default class OrderShipmentDialog extends PureComponent { constructor(props) { super(props); const { initialAmounts, initialSelected } = this.calculateInitialState(props); this.state = { date: todayInputValue(), externalNumber: '', lineAmounts: initialAmounts, selectedSkus: initialSelected, saving: false, error: null, }; this.handleAmountChange = this.handleAmountChange.bind(this); this.handleToggleItem = this.handleToggleItem.bind(this); this.handleToggleSku = this.handleToggleItem; // Alias for backward compatibility this.handleToggleAll = this.handleToggleAll.bind(this); this.handleSubmit = this.handleSubmit.bind(this); this.handleShipAll = this.handleShipAll.bind(this); if (props.open) { this.ensureArticlesLoaded(props.lines); } } componentDidMount() { this.unsubArticle = artikelAutomat.subscribe(() => { this.forceUpdate(); }); if (this.props.open) { this.ensureArticlesLoaded(this.props.lines); } } componentWillUnmount() { this.unsubArticle?.(); } componentDidUpdate(prevProps) { if (!prevProps.open && this.props.open) { const { initialAmounts, initialSelected } = this.calculateInitialState(this.props); this.setState({ date: todayInputValue(), externalNumber: '', lineAmounts: initialAmounts, selectedSkus: initialSelected, saving: false, error: null, }); this.ensureArticlesLoaded(this.props.lines); } else if (this.props.open && prevProps.lines !== this.props.lines) { this.ensureArticlesLoaded(this.props.lines); } } ensureArticlesLoaded(lines) { if (!Array.isArray(lines)) return; for (const l of lines) { if (l?.sku && !getArticle(l.sku)) { artikelAutomat.actions.loadArticle(l.sku).catch(() => {}); } } } calculateInitialState(props) { const { bySku, byOrderLineId, byName } = computeShippedSummary(props.shipments); const remainingShippedByName = { ...byName }; const initialAmounts = {}; const initialSelected = new Set(); (props.lines || []).forEach((l, idx) => { const isShippable = l.shippable !== false; const key = getItemKey(l, idx); const ordered = Number(l.amount) || 0; const sku = l.sku ? String(l.sku).trim() : null; const orderLineId = l.id != null ? l.id : null; let shipped = 0; if (sku) { shipped = bySku[sku] || 0; } else if (orderLineId != null && byOrderLineId[orderLineId] != null) { shipped = byOrderLineId[orderLineId]; } else if (l.name) { const n = String(l.name).trim().toLowerCase(); const pool = remainingShippedByName[n] || 0; const consumed = Math.min(ordered, pool); shipped = consumed; remainingShippedByName[n] = pool - consumed; } const rem = isShippable ? Math.max(0, ordered - shipped) : 0; if (isShippable && rem > 0) { initialAmounts[key] = String(rem); initialSelected.add(key); } else { initialAmounts[key] = '0'; } }); return { initialAmounts, initialSelected }; } getItems() { const { lines = [], shipments = [], orderId } = this.props; const { lineAmounts, selectedSkus } = this.state; const currentOrderId = orderId != null ? Number(orderId) : null; const { bySku, byOrderLineId, byName } = computeShippedSummary(shipments); const remainingShippedByName = { ...byName }; return (lines || []).map((line, idx) => { const itemKey = getItemKey(line, idx); const isShippable = line.shippable !== false; const ordered = Number(line.amount) || 0; const sku = line.sku ? String(line.sku).trim() : null; const orderLineId = line.id != null ? line.id : null; let shipped = 0; if (sku) { shipped = bySku[sku] || 0; } else if (orderLineId != null && byOrderLineId[orderLineId] != null) { shipped = byOrderLineId[orderLineId]; } else if (line.name) { const n = String(line.name).trim().toLowerCase(); const pool = remainingShippedByName[n] || 0; const consumed = Math.min(ordered, pool); shipped = consumed; remainingShippedByName[n] = pool - consumed; } const remaining = isShippable ? Math.max(0, ordered - shipped) : 0; const isSelected = selectedSkus.has(itemKey); const shipAmount = lineAmounts[itemKey] ?? (remaining > 0 ? String(remaining) : '0'); let cached = null; let isTracked = null; let stockVal = null; let availableVal = null; let reservedTitle = undefined; let stockTitle = undefined; if (sku) { cached = getArticle(sku); const rawWarehouses = Array.isArray(cached?.warehouses) ? cached.warehouses : []; isTracked = cached ? (cached.track_stock ?? cached.trackStock ?? (rawWarehouses.length > 0)) : null; stockVal = cached == null || isTracked === false ? null : (cached.stock != null && !Number.isNaN(Number(cached.stock)) ? Number(cached.stock) : (rawWarehouses.length > 0 || isTracked ? rawWarehouses.reduce((sum, wh) => sum + (Number(wh.stock) || 0), 0) : null)); const undeliveredOrders = Array.isArray(cached?.undeliveredOrders) ? cached.undeliveredOrders : []; const otherOrders = currentOrderId != null ? undeliveredOrders.filter((ord) => Number(ord.orderId) !== currentOrderId) : undeliveredOrders; const reservedInOtherOrders = otherOrders.reduce((sum, ord) => sum + (Number(ord.amount) || 0), 0); availableVal = cached == null || isTracked === false ? null : (stockVal != null ? stockVal - reservedInOtherOrders : null); reservedTitle = isTracked !== false && reservedInOtherOrders > 0 ? `Bereits in anderen Aufträgen reserviert:\n${otherOrders .map((ord) => `• ${ord.orderNumber || (ord.orderId ? `Auftrag #${ord.orderId}` : 'Auftrag')}: ${formatStock(ord.amount)}${ord.customerName ? ` (${ord.customerName})` : ''}`) .join('\n')}` : undefined; stockTitle = isTracked !== false && rawWarehouses.length > 1 ? `Lagerbestände:\n${rawWarehouses .map((wh) => `• ${wh.name || 'Lager'}: ${formatStock(wh.stock)}`) .join('\n')}` : undefined; } return { ...line, itemKey, sku, orderLineId, isShippable, ordered, shipped, remaining, isSelected, shipAmount, cached, isTracked, stockVal, availableVal, reservedTitle, stockTitle, }; }); } handleToggleItem(itemKey, remaining) { this.setState((prev) => { const nextSelected = new Set(prev.selectedSkus); const nextAmounts = { ...prev.lineAmounts }; if (nextSelected.has(itemKey)) { nextSelected.delete(itemKey); } else { nextSelected.add(itemKey); if (!Number(nextAmounts[itemKey]) || Number(nextAmounts[itemKey]) <= 0) { nextAmounts[itemKey] = String(remaining); } } return { selectedSkus: nextSelected, lineAmounts: nextAmounts }; }); } handleToggleAll(openItems) { this.setState((prev) => { const allSelected = openItems.length > 0 && openItems.every((it) => prev.selectedSkus.has(it.itemKey)); const nextSelected = new Set(); const nextAmounts = { ...prev.lineAmounts }; if (!allSelected) { for (const it of openItems) { nextSelected.add(it.itemKey); if (!Number(nextAmounts[it.itemKey]) || Number(nextAmounts[it.itemKey]) <= 0) { nextAmounts[it.itemKey] = String(it.remaining); } } } return { selectedSkus: nextSelected, lineAmounts: nextAmounts }; }); } handleAmountChange(itemKey, val) { this.setState((prev) => { const nextAmounts = { ...prev.lineAmounts, [itemKey]: val }; const nextSelected = new Set(prev.selectedSkus); const num = Number(val); if (num > 0) { nextSelected.add(itemKey); } else { nextSelected.delete(itemKey); } return { lineAmounts: nextAmounts, selectedSkus: nextSelected }; }); } // MARK: calls orderApi.createShipment from ./api/index.js // #region POST /api/order/:id/shipment — submit selected lines async handleSubmit(e) { e?.preventDefault?.(); const { orderId, onClose, onSuccess } = this.props; const { date, externalNumber, lineAmounts, selectedSkus, saving } = this.state; if (saving || !orderId) return; const items = this.getItems(); const linesToShip = []; for (const it of items) { if (!it.isShippable || !selectedSkus.has(it.itemKey)) continue; const amount = Number(lineAmounts[it.itemKey]); if (amount > 0) { if (it.sku) { linesToShip.push({ sku: it.sku, amount }); } else if (it.orderLineId != null) { linesToShip.push({ orderLineId: it.orderLineId, amount }); } } } if (linesToShip.length === 0) { enqueueSnackbar('Bitte wählen Sie mindestens einen Artikel mit Liefermenge > 0 aus.', { variant: 'warning' }); return; } this.setState({ saving: true, error: null }); try { const payload = { date: date || undefined, externalNumber: externalNumber.trim() || undefined, lines: linesToShip, }; const res = await orderApi.createShipment(orderId, payload); const shipment = res?.shipment; const shNum = shipment?.number || ''; enqueueSnackbar( shNum ? `Lieferschein ${shNum} erfolgreich angelegt!` : 'Lieferschein erfolgreich angelegt!', { variant: 'success' }, ); onSuccess?.(shipment); onClose(); } catch (err) { notifyError(err); this.setState({ saving: false, error: err?.message || String(err) }); } } // // MARK: calls orderApi.createShipment to ship all remaining lines // #region POST /api/order/:id/shipment — ship all remaining async handleShipAll() { const { orderId, onClose, onSuccess } = this.props; const { date, externalNumber, saving } = this.state; if (saving || !orderId) return; this.setState({ saving: true, error: null }); try { const payload = { date: date || undefined, externalNumber: externalNumber.trim() || undefined, }; const res = await orderApi.createShipment(orderId, payload); const shipment = res?.shipment; const shNum = shipment?.number || ''; enqueueSnackbar( shNum ? `Lieferschein ${shNum} erfolgreich angelegt!` : 'Lieferschein erfolgreich angelegt!', { variant: 'success' }, ); onSuccess?.(shipment); onClose(); } catch (err) { notifyError(err); this.setState({ saving: false, error: err?.message || String(err) }); } } // #endregion render() { const { open, onClose, orderNumber } = this.props; const { date, externalNumber, saving, error } = this.state; const nextShipmentNumber = getNextShipmentNumber(orderNumber, this.props.shipments); const items = this.getItems(); const shippableItems = items.filter((it) => it.isShippable); const openItems = shippableItems.filter((it) => it.remaining > 0); const allShipped = shippableItems.length > 0 && openItems.length === 0; const isAllSelected = openItems.length > 0 && openItems.every((it) => it.isSelected); const selectedCount = openItems.filter((it) => it.isSelected).length; return ( Lieferschein erstellen {orderNumber ? ( Zu: {orderNumber} ) : null} theme.palette.grey[500] }} > {error && ( this.setState({ error: null })}> {error} )} {allShipped && ( } sx={{ mb: 2 }}> Alle Artikel dieses Auftrags wurden bereits vollständig geliefert. )} {/* Header controls: date, preview shipment number, and external reference */} this.setState({ date: e.target.value })} slotProps={{ inputLabel: { shrink: true } }} sx={{ minWidth: 160 }} /> this.setState({ externalNumber: e.target.value })} fullWidth slotProps={{ input: { sx: { fontFamily: 'monospace' } } }} /> 0 && !isAllSelected} disabled={saving || openItems.length === 0} onChange={() => this.handleToggleAll(openItems)} /> ArtNr Artikel Bestellt Geliefert Offen Bestand Verfügbar Liefermenge {items.length === 0 ? ( Keine Artikel im Auftrag vorhanden. ) : ( items.map((item) => { const isNonShippable = !item.isShippable; const isClosed = isNonShippable || item.remaining <= 0; return ( this.handleToggleItem(item.itemKey, item.remaining)} /> {item.sku || '–'} {item.name || '–'} {item.ordered} {isNonShippable ? '–' : item.shipped} 0 ? 'primary.main' : 'text.disabled', }} > {isNonShippable ? '–' : item.remaining} {!item.sku ? '–' : !item.cached ? '…' : item.isTracked === false ? '∞' : formatStock(item.stockVal)} item.availableVal ? 'warning.main' : 'text.secondary', }} > {!item.sku ? '–' : !item.cached ? '…' : item.isTracked === false ? '∞' : formatStock(item.availableVal)} {isNonShippable ? ( ) : isClosed ? ( ) : ( this.handleAmountChange(item.itemKey, e.target.value)} slotProps={{ htmlInput: { min: 0, max: item.remaining, step: 'any', style: { textAlign: 'right', fontWeight: 600 }, }, }} sx={{ width: 100 }} /> )} ); }) )}
); } }