diff --git a/src/api/shipment.js b/src/api/shipment.js index 7b7b22f..66e4207 100644 --- a/src/api/shipment.js +++ b/src/api/shipment.js @@ -8,7 +8,7 @@ export const shipment = { // #region GET /api/shipment — list list(params = {}) { const q = new URLSearchParams(); - if (params.offset != null) q.set('offset', String(params.offset)); + if (params.offset) q.set('offset', String(params.offset)); if (params.limit != null) q.set('limit', String(params.limit)); if (params.search) q.set('search', String(params.search)); if (params.state) q.set('state', String(params.state)); diff --git a/src/automats/shipmentAutomat.js b/src/automats/shipmentAutomat.js index 373cbaf..0be4777 100644 --- a/src/automats/shipmentAutomat.js +++ b/src/automats/shipmentAutomat.js @@ -47,6 +47,7 @@ import orderAutomat from './orderAutomat'; * @property {string} filter * @property {string} stateFilter * @property {number} seekMonths + * @property {number} seekYears * @property {'idle'|'loading'|'ready'|'failed'} status * @property {Error|string|null} error */ @@ -58,6 +59,7 @@ const initialState = { filter: '', stateFilter: '', seekMonths: 0, + seekYears: 0, status: 'idle', error: null, }; @@ -69,7 +71,6 @@ let debounceTimer = null; // #region fetch helper async function fetchShipments(state) { const params = { - offset: 0, limit: 100, }; if (state.filter && state.filter.trim()) { @@ -78,8 +79,9 @@ async function fetchShipments(state) { if (state.stateFilter) { params.state = state.stateFilter; } - if (state.seekMonths) { - params.seekMonths = state.seekMonths; + const totalMonths = (state.seekMonths || 0) + (state.seekYears || 0) * 12; + if (totalMonths > 0) { + params.seekMonths = totalMonths; } const res = await shipmentApi.list(params); @@ -164,7 +166,22 @@ const actions = { }, setSeekMonths(months) { - automat.setState({ seekMonths: months }); + automat.setState({ seekMonths: months, seekYears: 0 }); + void automat.actions.load().catch(() => {}); + }, + + addSeekMonth() { + automat.setState((s) => ({ seekMonths: (s.seekMonths ?? 0) + 1 })); + void automat.actions.load().catch(() => {}); + }, + + addSeekYear() { + automat.setState((s) => ({ seekYears: (s.seekYears ?? 0) + 1 })); + void automat.actions.load().catch(() => {}); + }, + + resetNegativeSeek() { + automat.setState({ seekMonths: 0, seekYears: 0 }); void automat.actions.load().catch(() => {}); }, // #endregion @@ -205,16 +222,31 @@ const actions = { void orderAutomat.actions.reload().catch(() => {}); }, - reset() { - if (debounceTimer) clearTimeout(debounceTimer); - inflightLoad = null; - automat.setState({ ...initialState }); - }, - // #endregion -}; + reset() { + if (debounceTimer) clearTimeout(debounceTimer); + inflightLoad = null; + automat.setState({ ...initialState }); + }, + // #endregion + }; automat = new Automat(initialState, actions, { name: 'shipment' }); +// MARK: filter key & items helpers +export function getFilterKey(state = automat?.state || initialState) { + return `${state.filter || ''}|${state.stateFilter || ''}|${state.seekMonths || 0}|${state.seekYears || 0}`; +} + +export function getItems() { + if (automat.state.status === 'idle') { + void automat.actions.load().catch(() => {}); + } + return automat.state.items; +} + +automat.getFilterKey = getFilterKey; +automat.getItems = getItems; + // #region authAutomat synchronization let lastCompanyId = null; authAutomat.subscribe((auth) => { diff --git a/src/components/AppShell.jsx b/src/components/AppShell.jsx index a493e07..f9595b2 100644 --- a/src/components/AppShell.jsx +++ b/src/components/AppShell.jsx @@ -171,10 +171,12 @@ class AppSidebar extends PureComponent { )} diff --git a/src/components/manifest/ShipmentList.jsx b/src/components/manifest/ShipmentList.jsx index a690a0c..7023c07 100644 --- a/src/components/manifest/ShipmentList.jsx +++ b/src/components/manifest/ShipmentList.jsx @@ -1,4 +1,4 @@ -import { PureComponent } from 'react'; +import { PureComponent, createRef } from 'react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Chip from '@mui/material/Chip'; @@ -9,9 +9,8 @@ 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 ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import Skeleton from '@mui/material/Skeleton'; 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'; @@ -19,14 +18,17 @@ 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 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 { @@ -38,9 +40,7 @@ import { } from './manifestConfig'; import { GREEN_PANEL, - SURFACE_BASE, SURFACE_RAISED, - WHITE, } from '../../colors'; const cfg = manifestConfig('order'); @@ -119,63 +119,46 @@ class NewShipmentModal extends PureComponent { } 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 (!orderId) { + enqueueSnackbar('Bitte wählen Sie einen Auftrag aus.', { variant: 'warning' }); + return; } - if (linesToShip.length === 0) { - enqueueSnackbar('Mindestens ein Artikel muss eine Liefermenge > 0 haben.', { variant: 'warning' }); + 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(order.id, { + const res = await shipmentApi.create(orderId, { date, externalNumber: externalNumber.trim() || undefined, - lines: linesToShip, + lines, }); - enqueueSnackbar(`Lieferschein ${res.shipment?.number || ''} erfolgreich angelegt!`, { variant: 'success' }); - await Promise.all([ - orderAutomat.actions.reload(), - shipmentAutomat.actions.reload(), - ]); - onSuccess?.(res.shipment); - onClose(); + + 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 { 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 : []; @@ -187,32 +170,29 @@ class NewShipmentModal extends PureComponent { } 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}`; + {orders.map((o) => { + const customer = partiesById[o.entityId]; + const custName = partyDisplayName('order', customer) || (o.entityId ? `Kunde #${o.entityId}` : ''); return ( - {o.number} — {partyName} ({o.shippedQuantity || 0}/{o.orderedQuantity || 0} geliefert) + {o.number} {custName ? `— ${custName}` : ''} {o.date ? `(${formatDate(o.date)})` : ''} ); })} @@ -389,141 +369,255 @@ class DeliveryStateMenu extends PureComponent { } } -// MARK: +// 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: '170px 105px 130px 1.4fr 120px 160px 110px 48px', - alignItems: 'center', + 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%', - px: 2, - py: 1.1, 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 { shipment, onOpen, onDelete, disabled } = this.props; - if (!shipment) return null; + 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)} - sx={{ - p: 0, - borderBottom: '1px solid', - borderColor: 'divider', - bgcolor: WHITE, - '&:hover': { - bgcolor: 'action.hover', - }, - }} + divider + sx={{ ...rowGridSx, height: 64, minHeight: 64, maxHeight: 64, px: 2, py: 0 }} + onClick={disabled ? undefined : () => onOpen?.(shipment)} + disabled={disabled} > - - {/* 1. Lieferschein-Nr & Ref */} - - - {shipment.number} - - {shipment.externalNumber ? ( - - {numberDisplay(shipment.externalNumber)} - - ) : null} - + {renderInvisibleDummyRow(dummy)} - {/* 2. Datum */} + {/* 1. Lieferschein-Nr & Ref */} + + + {shipment.number} + + + {numberDisplay(shipment.externalNumber)} + + + + {/* 2. Datum */} + {shipment.date ? formatDate(shipment.date) : '–'} + - {/* 3. Bezugsauftrag */} - - {shipment.orderNumber ? ( - - {shipment.orderNumber} - - ) : ( - - – - - )} - + {/* 3. Bezugsauftrag */} + + {shipment.orderNumber ? ( + + {shipment.orderNumber} + + ) : ( + + – + + )} + - {/* 4. Kunde */} - - {shipment.customerName || (shipment.entityId ? `Kunde #${shipment.entityId}` : '–')} + {/* 4. Kunde */} + + + {shipment.customerName || this.partyName(shipment.entityId)} + - {/* 5. Positionen & Menge */} - - - {shipment.positions || 0} Pos. - - - {shipment.totalQuantity || 0} Stk - - + {/* 5. Positionen */} + + + - {/* 6. Paket & Tracking */} - - {shipment.carrier || shipment.trackingCode ? ( - - {shipment.carrier && ( - - )} - {shipment.trackingCode && ( - { - e.stopPropagation(); - navigator.clipboard.writeText(shipment.trackingCode); - enqueueSnackbar('Tracking-Code kopiert!', { variant: 'info' }); - }} - title="Tracking-Code kopieren" - > - - {shipment.trackingCode} - - - - )} - - ) : ( - - {shipment.packageCount ? `${shipment.packageCount} Paket(e)` : 'Keine Pakete'} + {/* 6. Paket / Tracking */} + + {shipment.trackingCode ? ( + + {shipment.carrier && ( + + )} + + {shipment.trackingCode} - )} - + + ) : shipment.packageCount > 0 ? ( + + {shipment.packageCount} {shipment.packageCount === 1 ? 'Paket' : 'Pakete'} + + ) : ( + + – + + )} + - {/* 7. Status */} - - - + {/* 7. Status */} + + + - {/* 8. Aktionen */} - - {shipment.state === 'new' && ( - { - e.stopPropagation(); - onDelete(shipment); - }} - sx={{ p: 0.5, color: 'text.secondary', '&:hover': { color: 'error.main' } }} - > - - - )} - + {/* 8. Aktionen (Löschen) */} + + {canDelete ? ( + { + e.stopPropagation(); + onDelete?.(shipment); + }} + onMouseDown={(e) => e.stopPropagation()} + sx={{ width: 40, height: 40 }} + > + + + ) : ( + + )} ); @@ -532,12 +626,42 @@ class ShipmentRow extends PureComponent { // 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 { input, stateFilter, onInputChange, onStateFilterChange, onSeekChange, onCreate, seekMonths } = this.props; + 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 & create */} - + {/* Top row: search field & create button */} + shipmentAutomat.actions.setFilter(next)} /> - - {/* Filter Bar */} - + {/* Filter Bar: Status chips & Negativeseek (-1 Month, -1 Year, Reset) */} + + {/* Left: Status chips */} - - Status: - onStateFilterChange('')} - sx={{ fontWeight: 600, cursor: 'pointer' }} + onClick={() => shipmentAutomat.actions.setStateFilter('')} + sx={{ fontWeight: 600, cursor: 'pointer', height: 26 }} /> onStateFilterChange('new')} - sx={{ fontWeight: 600, cursor: 'pointer' }} + onClick={() => shipmentAutomat.actions.setStateFilter('new')} + sx={{ fontWeight: 600, cursor: 'pointer', height: 26 }} /> onStateFilterChange('delivered')} - sx={{ fontWeight: 600, cursor: 'pointer' }} + onClick={() => shipmentAutomat.actions.setStateFilter('delivered')} + sx={{ fontWeight: 600, cursor: 'pointer', height: 26 }} /> onStateFilterChange('returned')} - sx={{ fontWeight: 600, cursor: 'pointer' }} + onClick={() => shipmentAutomat.actions.setStateFilter('returned')} + sx={{ fontWeight: 600, cursor: 'pointer', height: 26 }} /> - {/* Date Seek Cutoff */} - - - - + + + + + + @@ -652,22 +838,18 @@ class ShipmentSearchField extends PureComponent { export class ShipmentList extends PureComponent { constructor(props) { super(props); - const s = shipmentAutomat.state; + this.headerItem = createDummyTestShipment(); this.state = { - items: s.items, - total: s.total, - unfilteredTotal: s.unfilteredTotal, - filter: s.filter, - stateFilter: s.stateFilter, - seekMonths: s.seekMonths, - status: s.status, - error: s.error ? backendErrorFrom(s.error) : null, + items: shipmentAutomat.getItems(), + 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); @@ -679,19 +861,20 @@ export class ShipmentList extends PureComponent { this._cancelled = false; this.unsub = shipmentAutomat.subscribe(this, (state) => ({ items: state.items, - total: state.total, - unfilteredTotal: state.unfilteredTotal, - filter: state.filter, - stateFilter: state.stateFilter, - seekMonths: state.seekMonths, - status: state.status, - error: state.error ? backendErrorFrom(state.error) : null, + 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(); @@ -720,6 +903,7 @@ export class ShipmentList extends PureComponent { this._cancelled = true; this.unsub?.(); this.unsubOrder?.(); + this.unsubNumber?.(); } async loadParties() { @@ -746,6 +930,7 @@ export class ShipmentList extends PureComponent { } 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 { @@ -761,13 +946,8 @@ export class ShipmentList extends PureComponent { render() { const { items, - total, unfilteredTotal, - filter, - stateFilter, - seekMonths, - status, - error, + filterKey, orders, partiesById, selectedShipment, @@ -775,113 +955,87 @@ export class ShipmentList extends PureComponent { busyId, } = this.state; - if (items === null && status === 'failed') { + 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 })} /> + + + + + + ); } - const shipmentList = Array.isArray(items) ? items : []; - return ( <> shipmentAutomat.actions.setFilter(val)} - onStateFilterChange={(st) => shipmentAutomat.actions.setStateFilter(st)} - onSeekChange={(m) => shipmentAutomat.actions.setSeekMonths(m)} + inputRef={this.searchRef} onCreate={() => this.setState({ newShipmentModalOpen: true })} /> - - {/* Table Header */} - - - Lieferschein / Ref - - - Datum - - - Auftrag - - - Kunde - - - Positionen - - - Paket / Tracking - - - Status - - - - - {/* Rows */} - {items === null && status === 'loading' ? ( - - - - ) : shipmentList.length === 0 ? ( - - - - Keine Lieferscheine gefunden. - - - {filter || stateFilter ? 'Passen Sie die Filter an oder erstellen Sie einen neuen Lieferschein.' : 'Erstellen Sie den ersten Lieferschein aus einem Auftrag.'} - - - ) : ( - - {shipmentList.map((sh) => ( - - ))} + + + {renderInvisibleDummyRow(this.headerItem)} + Lieferschein / Ref + Datum + Auftrag + Kunde + Pos. + Paket / Tracking + Status + + + } + /> + {items === null && ( + + )} - - {/* Footer Summary */} - - - {shipmentList.length} von {total} {total === 1 ? 'Lieferschein' : 'Lieferscheinen'} angezeigt - {unfilteredTotal > total ? ` (gefiltert von ${unfilteredTotal})` : ''} - -