diff --git a/src/api/order.js b/src/api/order.js index 299ed10..aa81e34 100644 --- a/src/api/order.js +++ b/src/api/order.js @@ -93,7 +93,7 @@ export const order = { * POST /api/order/:id/shipment * Creates shipment from order. * @param {number|string} id Order ID - * @param {object} [body] Optional payload: { date?, externalNumber?, lines?: Array<{ sku: string, amount?: number }> } + * @param {object} [body] Optional payload: { date?, externalNumber?, lines?: Array<{ sku?: string, orderLineId?: number, amount?: number }> } * → 201 { shipment: Shipment } */ // #region POST /api/order/:id/shipment — createShipment diff --git a/src/automats/manifestDraftAutomat.js b/src/automats/manifestDraftAutomat.js index 60cde05..573028c 100644 --- a/src/automats/manifestDraftAutomat.js +++ b/src/automats/manifestDraftAutomat.js @@ -343,6 +343,9 @@ export function getManifestDirtyDetails(current, baseline) { if (String(cl.unit ?? '').trim() !== String(bl.unit ?? '').trim()) { lineFieldDiffs.unit = true; } + if (Boolean(cl.shippable ?? true) !== Boolean(bl.shippable ?? true)) { + lineFieldDiffs.shippable = true; + } if (Object.keys(lineFieldDiffs).length > 0) { dirtyLineKeys.push(key); diff --git a/src/automats/orderAutomat.js b/src/automats/orderAutomat.js index 0b9947b..f41a9e7 100644 --- a/src/automats/orderAutomat.js +++ b/src/automats/orderAutomat.js @@ -453,7 +453,7 @@ export function isOrderPaid(order) { export function isOrderDelivered(order) { if (!order) return false; - return Boolean(order.shipped ?? order.delivered ?? order.isDelivered ?? order.is_delivered ?? order.deliveredAt); + return Boolean(order.isShipped ?? order.shipped ?? order.delivered ?? order.isDelivered ?? order.is_delivered ?? order.deliveredAt); } export function getOrderDate(order) { diff --git a/src/components/manifest/ManifestForm.jsx b/src/components/manifest/ManifestForm.jsx index 1952b72..77c4f56 100644 --- a/src/components/manifest/ManifestForm.jsx +++ b/src/components/manifest/ManifestForm.jsx @@ -106,6 +106,7 @@ class ManifestForm extends PureComponent { paidTotal: initialEdit?.paidTotal ?? null, paid: Boolean(initialEdit?.paid), shipped: Boolean(initialEdit?.shipped), + isShipped: Boolean(initialEdit?.isShipped ?? initialEdit?.shipped), delivered: Boolean(initialEdit?.delivered), isDirty: Boolean(initialEdit?.isDirty), dirtyFields: initialEdit?.dirtyFields || null, @@ -249,6 +250,7 @@ class ManifestForm extends PureComponent { paidTotal: state.paidTotal ?? null, paid: Boolean(state.paid), shipped: Boolean(state.shipped), + isShipped: Boolean(state.isShipped ?? state.shipped), delivered: Boolean(state.delivered), isDirty: Boolean(state.isDirty), dirtyFields: state.dirtyFields, @@ -477,6 +479,7 @@ class ManifestForm extends PureComponent { paidTotal: doc.paidTotal ?? null, paid: Boolean(doc.paid), shipped: Boolean(doc.shipped), + isShipped: Boolean(doc.isShipped ?? doc.shipped), delivered: Boolean(doc.delivered), }; @@ -812,24 +815,52 @@ class ManifestForm extends PureComponent { } isFullyDelivered() { - if (this.state.delivered) return true; + if (this.state.delivered || this.state.isShipped || this.state.shipped) return true; const lines = this.state.lines || []; - const validLines = lines.filter((l) => l.sku && Number(l.amount) > 0); - if (validLines.length === 0) return false; + const shippableLines = lines.filter((l) => l.shippable !== false && Number(l.amount) > 0); + if (shippableLines.length === 0) return lines.length > 0; const shippedBySku = {}; + const shippedByOrderLineId = {}; + const shippedByName = {}; for (const sh of this.state.shipments || []) { for (const sl of sh.lines || []) { + const amt = Number(sl.amount) || 0; if (sl.sku) { const k = String(sl.sku).trim().toLowerCase(); - shippedBySku[k] = (shippedBySku[k] || 0) + (Number(sl.amount) || 0); + shippedBySku[k] = (shippedBySku[k] || 0) + amt; + } + if (sl.orderLineId != null) { + shippedByOrderLineId[sl.orderLineId] = (shippedByOrderLineId[sl.orderLineId] || 0) + amt; + } + if (sl.name) { + const n = String(sl.name).trim().toLowerCase(); + shippedByName[n] = (shippedByName[n] || 0) + amt; } } } - return validLines.every((l) => { - const k = String(l.sku).trim().toLowerCase(); - return (shippedBySku[k] || 0) >= Number(l.amount); + const remainingShippedByName = { ...shippedByName }; + + return shippableLines.every((l) => { + const amt = Number(l.amount) || 0; + if (l.sku) { + const k = String(l.sku).trim().toLowerCase(); + return (shippedBySku[k] || 0) >= amt; + } + if (l.id != null && shippedByOrderLineId[l.id] != null) { + return shippedByOrderLineId[l.id] >= amt; + } + if (l.name) { + const n = String(l.name).trim().toLowerCase(); + const pool = remainingShippedByName[n] || 0; + if (pool >= amt) { + remainingShippedByName[n] = pool - amt; + return true; + } + return false; + } + return false; }); } @@ -838,10 +869,15 @@ class ManifestForm extends PureComponent { const lineToRemove = prev.lines.find((line) => line.key === key); if (lineToRemove && this.props.kind === 'order') { const rawSku = lineToRemove.sku != null ? String(lineToRemove.sku).trim().toLowerCase() : ''; + const orderLineId = lineToRemove.id; + const lineName = lineToRemove.name != null ? String(lineToRemove.name).trim().toLowerCase() : ''; const isShipped = Boolean( - rawSku && (prev.shipments || []).some((sh) => - (sh.lines || []).some((sl) => sl.sku && String(sl.sku).trim().toLowerCase() === rawSku) + (sh.lines || []).some((sl) => + (rawSku && sl.sku && String(sl.sku).trim().toLowerCase() === rawSku) || + (orderLineId != null && sl.orderLineId != null && sl.orderLineId === orderLineId) || + (!rawSku && !sl.sku && lineName && sl.name && String(sl.name).trim().toLowerCase() === lineName) + ) ) ); if (isShipped) { diff --git a/src/components/manifest/ManifestFormLine.jsx b/src/components/manifest/ManifestFormLine.jsx index 8cab002..fba22cc 100644 --- a/src/components/manifest/ManifestFormLine.jsx +++ b/src/components/manifest/ManifestFormLine.jsx @@ -2,6 +2,7 @@ import { Fragment, PureComponent } from 'react'; import { Link as RouterLink } from 'react-router-dom'; 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 IconButton from '@mui/material/IconButton'; @@ -155,6 +156,7 @@ class ManifestFormLine extends PureComponent { } handleStartEdit(columnId) { + if (columnId === 'pos' || columnId === 'shippable') return; const { saving } = this.props; const isFullyLedgered = this.isFullyLedgered(); const isDisabled = saving || isFullyLedgered || (columnId === 'sku' && Boolean(this.props.isOnShipment)); @@ -236,6 +238,79 @@ class ManifestFormLine extends PureComponent { ); } + if (id === 'shippable') { + const isShippable = line.shippable !== false; + const isFullyLedgered = this.isFullyLedgered(); + const isDisabled = saving || isFullyLedgered; + + const hasPosCol = columns?.[0]?.id === 'pos'; + const isFirstInput = hasPosCol ? colIdx === 1 : colIdx === 0; + const isLast = colIdx === totalCols - 1; + + const topLeftRadius = isFirstInput && isFirstLine ? 4 : 0; + const topRightRadius = isLast && isFirstLine ? 4 : 0; + const bottomRightRadius = isLast && isLastLine ? 4 : 0; + const bottomLeftRadius = isFirstInput && isLastLine ? 4 : 0; + const borderRadius = `${topLeftRadius}px ${topRightRadius}px ${bottomRightRadius}px ${bottomLeftRadius}px`; + + const { dirtyFields } = this.props; + const isFieldDirty = Boolean(dirtyFields && (dirtyFields.__all__ || dirtyFields[id])); + + return ( + onChange(line.key, 'shippable', !isShippable)} + > + { + e.stopPropagation(); + onChange(line.key, 'shippable', e.target.checked); + }} + slotProps={{ + input: { 'aria-label': isShippable ? 'Lieferbar' : 'Nicht lieferbar' }, + }} + sx={{ p: 0.5 }} + /> + + ); + } + let val = line[id]; if (val == null || val === '') { if (id === 'name' && this.state.cachedArticle?.name) { diff --git a/src/components/manifest/ManifestLines.jsx b/src/components/manifest/ManifestLines.jsx index f172eae..03878e8 100644 --- a/src/components/manifest/ManifestLines.jsx +++ b/src/components/manifest/ManifestLines.jsx @@ -107,10 +107,14 @@ class ManifestLines extends PureComponent { {(() => { const shippedSkus = new Set(); + const shippedOrderLineIds = new Set(); + const shippedNames = new Set(); if (kind === 'order' && Array.isArray(shipments)) { for (const sh of shipments) { for (const sl of sh.lines || []) { if (sl.sku) shippedSkus.add(String(sl.sku).trim().toLowerCase()); + if (sl.orderLineId != null) shippedOrderLineIds.add(sl.orderLineId); + if (!sl.sku && sl.name) shippedNames.add(String(sl.name).trim().toLowerCase()); } } } @@ -122,7 +126,12 @@ class ManifestLines extends PureComponent { return lines.map((line, index) => { const lineKey = line.key ?? index; const rawSku = line.sku != null ? String(line.sku).trim() : ''; - const isOnShipment = kind === 'order' && Boolean(rawSku && shippedSkus.has(rawSku.toLowerCase())); + const lineName = line.name != null ? String(line.name).trim() : ''; + const isOnShipment = kind === 'order' && Boolean( + (rawSku && shippedSkus.has(rawSku.toLowerCase())) || + (line.id != null && shippedOrderLineIds.has(line.id)) || + (!rawSku && lineName && shippedNames.has(lineName.toLowerCase())) + ); const isDelivery = kind === 'delivery'; const matched = isDelivery ? supplierArticlesByLineKey[lineKey] || diff --git a/src/components/manifest/OrderShipmentDialog.jsx b/src/components/manifest/OrderShipmentDialog.jsx index f908cb2..2b1bec4 100644 --- a/src/components/manifest/OrderShipmentDialog.jsx +++ b/src/components/manifest/OrderShipmentDialog.jsx @@ -29,17 +29,39 @@ import { formatStock } from '../article/searchFormat'; import artikelAutomat, { getArticle } from '../../automats/artikelAutomat'; import { todayInputValue } from './manifestConfig'; -// MARK: helper to sum already shipped quantities per SKU across order shipments -function computeShippedBySku(shipments) { - const map = {}; +// 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) { - map[sl.sku] = (map[sl.sku] || 0) + (Number(sl.amount) || 0); + 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 map; + 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 @@ -78,7 +100,8 @@ export default class OrderShipmentDialog extends PureComponent { }; this.handleAmountChange = this.handleAmountChange.bind(this); - this.handleToggleSku = this.handleToggleSku.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); @@ -128,35 +151,146 @@ export default class OrderShipmentDialog extends PureComponent { } calculateInitialState(props) { - const shippedBySku = computeShippedBySku(props.shipments); + const { bySku, byOrderLineId, byName } = computeShippedSummary(props.shipments); + const remainingShippedByName = { ...byName }; const initialAmounts = {}; const initialSelected = new Set(); - for (const l of props.lines || []) { - if (!l.sku) continue; + (props.lines || []).forEach((l, idx) => { + const isShippable = l.shippable !== false; + const key = getItemKey(l, idx); const ordered = Number(l.amount) || 0; - const shipped = shippedBySku[l.sku] || 0; - const rem = Math.max(0, ordered - shipped); - if (rem > 0) { - initialAmounts[l.sku] = String(rem); - initialSelected.add(l.sku); - } else { - initialAmounts[l.sku] = '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 }; } - handleToggleSku(sku, remaining) { + 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(sku)) { - nextSelected.delete(sku); + if (nextSelected.has(itemKey)) { + nextSelected.delete(itemKey); } else { - nextSelected.add(sku); - if (!Number(nextAmounts[sku]) || Number(nextAmounts[sku]) <= 0) { - nextAmounts[sku] = String(remaining); + nextSelected.add(itemKey); + if (!Number(nextAmounts[itemKey]) || Number(nextAmounts[itemKey]) <= 0) { + nextAmounts[itemKey] = String(remaining); } } return { selectedSkus: nextSelected, lineAmounts: nextAmounts }; @@ -165,15 +299,15 @@ export default class OrderShipmentDialog extends PureComponent { handleToggleAll(openItems) { this.setState((prev) => { - const allSelected = openItems.length > 0 && openItems.every((it) => prev.selectedSkus.has(it.sku)); + 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.sku); - if (!Number(nextAmounts[it.sku]) || Number(nextAmounts[it.sku]) <= 0) { - nextAmounts[it.sku] = String(it.remaining); + nextSelected.add(it.itemKey); + if (!Number(nextAmounts[it.itemKey]) || Number(nextAmounts[it.itemKey]) <= 0) { + nextAmounts[it.itemKey] = String(it.remaining); } } } @@ -181,15 +315,15 @@ export default class OrderShipmentDialog extends PureComponent { }); } - handleAmountChange(sku, val) { + handleAmountChange(itemKey, val) { this.setState((prev) => { - const nextAmounts = { ...prev.lineAmounts, [sku]: val }; + const nextAmounts = { ...prev.lineAmounts, [itemKey]: val }; const nextSelected = new Set(prev.selectedSkus); const num = Number(val); if (num > 0) { - nextSelected.add(sku); + nextSelected.add(itemKey); } else { - nextSelected.delete(sku); + nextSelected.delete(itemKey); } return { lineAmounts: nextAmounts, selectedSkus: nextSelected }; }); @@ -203,11 +337,17 @@ export default class OrderShipmentDialog extends PureComponent { const { date, externalNumber, lineAmounts, selectedSkus, saving } = this.state; if (saving || !orderId) return; + const items = this.getItems(); const linesToShip = []; - for (const sku of selectedSkus) { - const amount = Number(lineAmounts[sku]); + for (const it of items) { + if (!it.isShippable || !selectedSkus.has(it.itemKey)) continue; + const amount = Number(lineAmounts[it.itemKey]); if (amount > 0) { - linesToShip.push({ sku, amount }); + if (it.sku) { + linesToShip.push({ sku: it.sku, amount }); + } else if (it.orderLineId != null) { + linesToShip.push({ orderLineId: it.orderLineId, amount }); + } } } @@ -240,9 +380,7 @@ export default class OrderShipmentDialog extends PureComponent { this.setState({ saving: false, error: err?.message || String(err) }); } } - // #endregion - - // MARK: calls orderApi.createShipment to ship all remaining lines + // // 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; @@ -275,73 +413,14 @@ export default class OrderShipmentDialog extends PureComponent { // #endregion render() { - const { open, onClose, orderId, orderNumber, lines = [], shipments = [] } = this.props; - const { date, externalNumber, lineAmounts, selectedSkus, saving, error } = this.state; - const nextShipmentNumber = getNextShipmentNumber(orderNumber, shipments); + const { open, onClose, orderNumber } = this.props; + const { date, externalNumber, saving, error } = this.state; + const nextShipmentNumber = getNextShipmentNumber(orderNumber, this.props.shipments); - const shippedBySku = computeShippedBySku(shipments); - const activeLines = lines.filter((l) => Boolean(l.sku)); - const currentOrderId = orderId != null ? Number(orderId) : null; - - const items = activeLines.map((line) => { - const ordered = Number(line.amount) || 0; - const shipped = shippedBySku[line.sku] || 0; - const remaining = Math.max(0, ordered - shipped); - const isSelected = selectedSkus.has(line.sku); - const shipAmount = lineAmounts[line.sku] ?? (remaining > 0 ? String(remaining) : '0'); - - // Stock & reservation details from artikelAutomat - const cached = getArticle(line.sku); - const rawWarehouses = Array.isArray(cached?.warehouses) ? cached.warehouses : []; - const isTracked = cached ? (cached.track_stock ?? cached.trackStock ?? (rawWarehouses.length > 0)) : null; - const 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); - const availableVal = cached == null || isTracked === false - ? null - : (stockVal != null ? stockVal - reservedInOtherOrders : null); - - const 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; - - const stockTitle = isTracked !== false && rawWarehouses.length > 1 - ? `Lagerbestände:\n${rawWarehouses - .map((wh) => `• ${wh.name || 'Lager'}: ${formatStock(wh.stock)}`) - .join('\n')}` - : undefined; - - return { - ...line, - ordered, - shipped, - remaining, - isSelected, - shipAmount, - cached, - isTracked, - stockVal, - availableVal, - reservedInOtherOrders, - reservedTitle, - stockTitle, - }; - }); - - const openItems = items.filter((it) => it.remaining > 0); - const allShipped = activeLines.length > 0 && openItems.length === 0; + 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; @@ -451,10 +530,11 @@ export default class OrderShipmentDialog extends PureComponent { ) : ( items.map((item) => { - const isClosed = item.remaining <= 0; + const isNonShippable = !item.isShippable; + const isClosed = isNonShippable || item.remaining <= 0; return ( this.handleToggleSku(item.sku, item.remaining)} + onChange={() => this.handleToggleItem(item.itemKey, item.remaining)} /> - {item.sku} + {item.sku || '–'} {item.name || '–'} {item.ordered} - {item.shipped} + {isNonShippable ? '–' : item.shipped} 0 ? 'primary.main' : 'text.disabled', }} > - {item.remaining} + {isNonShippable ? '–' : item.remaining} - {!item.cached ? '…' : item.isTracked === false ? '∞' : formatStock(item.stockVal)} + {!item.sku ? '–' : !item.cached ? '…' : item.isTracked === false ? '∞' : formatStock(item.stockVal)} - {!item.cached ? '…' : item.isTracked === false ? '∞' : formatStock(item.availableVal)} + {!item.sku ? '–' : !item.cached ? '…' : item.isTracked === false ? '∞' : formatStock(item.availableVal)} - {isClosed ? ( + {isNonShippable ? ( + + ) : isClosed ? ( ) : ( this.handleAmountChange(item.sku, e.target.value)} + onChange={(e) => this.handleAmountChange(item.itemKey, e.target.value)} slotProps={{ htmlInput: { min: 0, diff --git a/src/components/manifest/ShipmentList.jsx b/src/components/manifest/ShipmentList.jsx index 38014b5..d44222f 100644 --- a/src/components/manifest/ShipmentList.jsx +++ b/src/components/manifest/ShipmentList.jsx @@ -32,7 +32,17 @@ import { getMaxDocumentNumber } from '../../utils/documentNumber'; import PartyFilterPanel, { PartyFilterButton, PartyFilterChip } from './PartyFilterPanel'; import withNavigate from '../withNavigate'; import ShipmentDetailDialog from './ShipmentDetailDialog'; -import { getNextShipmentNumber } from './OrderShipmentDialog'; +import { getNextShipmentNumber, computeShippedSummary } from './OrderShipmentDialog'; + +function getShipmentLineKey(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}`; +} import { formatDate, loadParties, @@ -73,6 +83,7 @@ class NewShipmentModal extends PureComponent { componentDidUpdate(prevProps) { if (!prevProps.open && this.props.open) { const openOrders = (this.props.orders || []).filter((o) => { + if (o.isShipped != null) return !o.isShipped; const ordQty = Number(o.orderedQuantity) || 0; const shpQty = Number(o.shippedQuantity) || 0; return ordQty === 0 || shpQty < ordQty; @@ -89,23 +100,35 @@ class NewShipmentModal extends PureComponent { 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 { bySku, byOrderLineId, byName } = computeShippedSummary(order?.shipments); + const remainingShippedByName = { ...byName }; const lineAmounts = {}; - for (const l of lines) { - if (!l.sku) continue; + lines.forEach((l, idx) => { + if (l.shippable === false) return; + const key = getShipmentLineKey(l, idx); const ordered = Number(l.amount) || 0; - const shipped = shippedBySku[l.sku] || 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 remaining = Math.max(0, ordered - shipped); if (remaining > 0) { - lineAmounts[l.sku] = String(remaining); + lineAmounts[key] = String(remaining); } - } + }); this.setState({ orderId, @@ -114,22 +137,34 @@ class NewShipmentModal extends PureComponent { }); } - handleAmountChange(sku, val) { + handleAmountChange(key, val) { this.setState((prev) => ({ - lineAmounts: { ...prev.lineAmounts, [sku]: val }, + lineAmounts: { ...prev.lineAmounts, [key]: val }, })); } async handleSubmit() { const { orderId, date, externalNumber, lineAmounts } = this.state; + const { orders = [] } = this.props; + const currentOrder = orders.find((o) => o.id === Number(orderId)); 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) })); + const lines = []; + (currentOrder?.lines || []).forEach((l, idx) => { + if (l.shippable === false) return; + const key = getShipmentLineKey(l, idx); + const amt = Number(lineAmounts[key]); + if (amt > 0) { + if (l.sku) { + lines.push({ sku: l.sku, amount: amt }); + } else if (l.id != null) { + lines.push({ orderLineId: l.id, amount: amt }); + } + } + }); if (lines.length === 0) { enqueueSnackbar('Mindestens eine Position muss mit Menge > 0 geliefert werden.', { variant: 'warning' }); @@ -164,12 +199,8 @@ class NewShipmentModal extends PureComponent { 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); - } - } + const { bySku, byOrderLineId, byName } = computeShippedSummary(currentOrder?.shipments); + const remainingShippedByName = { ...byName }; return ( - {lines.map((line) => { + {lines.map((line, idx) => { + const key = getShipmentLineKey(line, idx); + const isShippable = line.shippable !== false; const orderedAmt = Number(line.amount) || 0; - const shippedAmt = shippedBySku[line.sku] || 0; - const remaining = Math.max(0, orderedAmt - shippedAmt); - const currentVal = lineAmounts[line.sku] ?? ''; + const sku = line.sku ? String(line.sku).trim() : null; + const orderLineId = line.id != null ? line.id : null; + + let shippedAmt = 0; + if (sku) { + shippedAmt = bySku[sku] || 0; + } else if (orderLineId != null && byOrderLineId[orderLineId] != null) { + shippedAmt = byOrderLineId[orderLineId]; + } else if (line.name) { + const n = String(line.name).trim().toLowerCase(); + const pool = remainingShippedByName[n] || 0; + const consumed = Math.min(orderedAmt, pool); + shippedAmt = consumed; + remainingShippedByName[n] = pool - consumed; + } + + const remaining = isShippable ? Math.max(0, orderedAmt - shippedAmt) : 0; + const currentVal = lineAmounts[key] ?? ''; return ( - {line.sku} — {line.name || '–'} + {line.sku ? `${line.sku} — ` : ''}{line.name || '–'} - Bestellt: {orderedAmt} • Bisher geliefert: {shippedAmt} • Noch offen: {remaining} + {!isShippable + ? 'Nicht lieferbare Position (z.B. Dienstleistung)' + : `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 }} - /> + {!isShippable ? ( + + ) : ( + this.handleAmountChange(key, e.target.value)} + disabled={remaining <= 0} + slotProps={{ htmlInput: { min: 0, max: remaining, step: 'any', style: { textAlign: 'right' } } }} + sx={{ width: 110 }} + /> + )} ); })} diff --git a/src/components/manifest/manifestConfig.js b/src/components/manifest/manifestConfig.js index 3eec2b1..57a87be 100644 --- a/src/components/manifest/manifestConfig.js +++ b/src/components/manifest/manifestConfig.js @@ -136,6 +136,7 @@ export function emptyLine() { pu: '1', unit: '', netprice: '', + shippable: true, }; } @@ -167,6 +168,7 @@ export function lineFromApi(row) { unit: row.unit ?? '', netprice: netVal, taxrate: taxVal, + shippable: row.shippable !== undefined ? Boolean(row.shippable) : true, }; } @@ -197,7 +199,7 @@ export function hasArticleLines(lines) { } /** Build create/update line array. Throws Error on client validation. */ -export function buildLines(lines, { requireAtLeastOne }) { +export function buildLines(lines, { requireAtLeastOne, kind } = {}) { const outLines = []; const errors = []; @@ -255,6 +257,9 @@ export function buildLines(lines, { requireAtLeastOne }) { if (name) row.name = name; if (rebate != null) row.rebate = rebate; if (pu != null) row.pu = pu; + if (kind !== 'delivery') { + row.shippable = line.shippable !== undefined ? Boolean(line.shippable) : true; + } outLines.push(row); } @@ -503,7 +508,7 @@ export async function buildManifestBody( date, }; if (shouldIncludeLines) { - body.lines = buildLines(form.lines, { requireAtLeastOne: shouldRequireLine }); + body.lines = buildLines(form.lines, { requireAtLeastOne: shouldRequireLine, kind: 'delivery' }); } } else { let number = String(form.number ?? '').trim(); @@ -516,7 +521,7 @@ export async function buildManifestBody( date, }; if (shouldIncludeLines) { - body.lines = buildLines(form.lines, { requireAtLeastOne: shouldRequireLine }); + body.lines = buildLines(form.lines, { requireAtLeastOne: shouldRequireLine, kind }); } if (useExternalNumber || form.externalNumber != null) { body.externalNumber = externalNumber || null; @@ -564,13 +569,24 @@ export const LINE_COLUMNS = [ { id: 'gross', label: 'Brutto', width: 95, align: 'right', type: 'number' }, ]; -export const QUOTE_LINE_COLUMNS = LINE_COLUMNS; +export const QUOTE_LINE_COLUMNS = [ + { id: 'pos', label: 'Pos', width: 44, align: 'center' }, + { id: 'amount', label: 'Anzahl', width: 80, align: 'right', type: 'number' }, + { id: 'sku', label: 'ArtNr', width: 130, align: 'left', type: 'text' }, + { id: 'name', label: 'Name', width: 200, align: 'left', type: 'text' }, + { id: 'shippable', label: 'Lieferbar', width: 68, align: 'center', type: 'boolean' }, + { id: 'tax', label: 'MwSt', width: 70, align: 'right', type: 'number' }, + { id: 'rebate', label: 'Rabatt', width: 75, align: 'right', type: 'number' }, + { id: 'net', label: 'Netto', width: 95, align: 'right', type: 'number' }, + { id: 'gross', label: 'Brutto', width: 95, align: 'right', type: 'number' }, +]; export const ORDER_LINE_COLUMNS = [ { id: 'pos', label: 'Pos', width: 44, align: 'center' }, { id: 'sku', label: 'ArtNr', width: 130, align: 'left', type: 'text' }, { id: 'name', label: 'Name', width: 200, align: 'left', type: 'text' }, { id: 'amount', label: 'Anzahl', width: 80, align: 'right', type: 'number' }, + { id: 'shippable', label: 'Lieferbar', width: 68, align: 'center', type: 'boolean' }, { id: 'tax', label: 'MwSt', width: 70, align: 'right', type: 'number' }, { id: 'rebate', label: 'Rabatt', width: 75, align: 'right', type: 'number' }, { id: 'net', label: 'Netto', width: 95, align: 'right', type: 'number' }, @@ -710,7 +726,7 @@ export function manifestConfig(cfgOrKind) { listAutomat: quoteAutomat, partiesByIdFromList: (parties) => Object.fromEntries(parties.map((row) => [row.id, row])), - columns: LINE_COLUMNS, + columns: QUOTE_LINE_COLUMNS, }; } if (kind === 'order') {