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 PartyFilterPanel, { PartyFilterButton, PartyFilterChip } from './PartyFilterPanel';
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 (
);
})}
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 }}
/>
);
})}
)}
}>
{saving ? 'Wird erstellt…' : 'Lieferschein anlegen'}
);
}
}
// 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' }}
/>
);
}
}
// 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,
customerFilterOpen,
onToggleCustomerFilter,
selectedCustomer,
onClearCustomerFilter,
} = 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)}
/>
{selectedCustomer && (
)}
{/* 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?.() ?? '',
entityId: shipmentAutomat.state.entityId ?? null,
customerFilterOpen: false,
orders: orderAutomat.state?.items || [],
partiesById: {},
partiesList: [],
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);
this.handleToggleCustomerFilter = this.handleToggleCustomerFilter.bind(this);
this.handleSelectCustomer = this.handleSelectCustomer.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),
entityId: state.entityId ?? null,
}));
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?.();
}
handleToggleCustomerFilter() {
this.setState((prev) => ({ customerFilterOpen: !prev.customerFilterOpen }));
}
handleSelectCustomer(entityId) {
shipmentAutomat.actions.setEntityId(entityId);
}
async loadParties() {
try {
const parties = await loadParties('order');
if (!this._cancelled) {
this.setState({
partiesById: cfg.partiesByIdFromList(parties),
partiesList: 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,
entityId,
customerFilterOpen,
orders,
partiesById,
partiesList,
selectedShipment,
newShipmentModalOpen,
busyId,
} = this.state;
rowContext.partiesById = partiesById;
rowContext.busyId = busyId;
rowContext.onOpen = this.handleOpenShipment;
rowContext.onDelete = this.handleDeleteShipment;
rowContext.headerItem = this.headerItem;
const customerCounts = {};
if (Array.isArray(items)) {
for (const s of items) {
if (s?.entityId) {
customerCounts[s.entityId] = (customerCounts[s.entityId] || 0) + 1;
}
}
}
const selectedCustomer = entityId ? (partiesById[entityId] || { id: entityId, name: `Kunde #${entityId}` }) : null;
const searchField = (
this.setState({ newShipmentModalOpen: true })}
customerFilterOpen={customerFilterOpen}
onToggleCustomerFilter={this.handleToggleCustomerFilter}
selectedCustomer={selectedCustomer}
onClearCustomerFilter={() => this.handleSelectCustomer(null)}
/>
);
if (items === null && shipmentAutomat.state.status === 'failed') {
return (
<>
{searchField}
>
);
}
return (
<>
{searchField}
{customerFilterOpen && (
)}
{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);