Files
dashfront/src/components/manifest/ShipmentList.jsx
2026-10-19 13:24:39 +02:00

1174 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: <NewShipmentModal/>
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 (
<AppDialog
open={open}
onClose={saving ? undefined : onClose}
title="Neuen Lieferschein erstellen"
maxWidth="md"
fullWidth
>
<Box sx={{ p: 2.5 }}>
<Box sx={{ mb: 2.5, display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
select
label="Bezugsauftrag auswählen"
size="small"
value={orderId}
onChange={(e) => 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 (
<MenuItem key={o.id} value={o.id}>
{o.number} {custName ? `— ${custName}` : ''} {o.date ? `(${formatDate(o.date)})` : ''}
</MenuItem>
);
})}
</TextField>
<Box sx={{ display: 'flex', gap: 2 }}>
<TextField
label="Lieferdatum"
type="date"
size="small"
value={date}
onChange={(e) => this.setState({ date: e.target.value })}
InputLabelProps={{ shrink: true }}
sx={{ width: 170 }}
/>
{currentOrder ? (
<TextField
label="Lieferschein-Nr."
size="small"
value={getNextShipmentNumber(currentOrder.number, currentOrder.shipments)}
disabled
slotProps={{ input: { readOnly: true } }}
sx={{ width: 190 }}
/>
) : null}
<TextField
label="Referenz"
size="small"
value={externalNumber}
onChange={(e) => this.setState({ externalNumber: e.target.value })}
sx={{ flex: 1 }}
/>
</Box>
</Box>
{currentOrder && (
<Box sx={{ mb: 2.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Lieferbare Positionen für Auftrag {currentOrder.number}:
</Typography>
<Stack spacing={1}>
{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 (
<Box
key={line.sku}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
p: 1.25,
borderRadius: 1,
bgcolor: SURFACE_RAISED,
}}
>
<Box sx={{ minWidth: 0, flex: 1, pr: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 600, ...ellipsisSx }}>
{line.sku} — {line.name || '–'}
</Typography>
<Typography variant="caption" color="text.secondary">
Bestellt: {orderedAmt} • Bisher geliefert: {shippedAmt} • Noch offen: <strong>{remaining}</strong>
</Typography>
</Box>
<TextField
size="small"
type="number"
label="Liefern"
value={currentVal}
onChange={(e) => this.handleAmountChange(line.sku, e.target.value)}
disabled={remaining <= 0}
slotProps={{ htmlInput: { min: 0, max: remaining, step: 'any', style: { textAlign: 'right' } } }}
sx={{ width: 110 }}
/>
</Box>
);
})}
</Stack>
</Box>
)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1.5 }}>
<Button variant="outlined" onClick={onClose} disabled={saving}>
Abbrechen
</Button>
<Button variant="contained" onClick={this.handleSubmit} disabled={saving || !currentOrder} startIcon={<LocalShippingIcon />}>
{saving ? 'Wird erstellt…' : 'Lieferschein anlegen'}
</Button>
</Box>
</Box>
</AppDialog>
);
}
}
// MARK: <DeliveryStateMenu/>
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 (
<Box onClick={(e) => e.stopPropagation()}>
<Chip
size="small"
color={conf.color}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<span>{conf.label}</span>
<ArrowDropDownIcon sx={{ fontSize: 16 }} />
</Box>
}
disabled={busy}
onClick={this.handleOpen}
sx={{ fontWeight: 600, height: 24, cursor: 'pointer' }}
/>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={this.handleClose}
onClick={(e) => e.stopPropagation()}
>
<MenuItem
selected={currentState === 'new'}
onClick={() => this.handleSelectState('new')}
>
Neu (In Vorbereitung)
</MenuItem>
<MenuItem
selected={currentState === 'delivered'}
onClick={() => this.handleSelectState('delivered')}
>
Geliefert (Zugestellt)
</MenuItem>
<MenuItem
selected={currentState === 'returned'}
onClick={() => this.handleSelectState('returned')}
>
Retoure (Retourniert)
</MenuItem>
</Menu>
</Box>
);
}
}
// 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 (
<>
<Box aria-hidden sx={{ gridColumn: 1, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body1" sx={{ fontWeight: 600 }}>{d.number}</Typography>
<Typography variant="body2">{d.externalNumber || 'Nr. / Ref.'}</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 2, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body2">{d.date ? formatDate(d.date) : '–'}</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 3, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{d.orderNumber}</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 4, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body1">{d.customerName}</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 5, gridRow: 1, display: 'flex', justifyContent: 'center', ...measuringCellSx }}>
<Chip size="small" label={d.positions} sx={{ height: 24, '& .MuiChip-label': { px: 0.5 } }} />
</Box>
<Box aria-hidden sx={{ gridColumn: 6, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Chip size="small" variant="outlined" label="DHL" sx={{ height: 20, fontSize: '0.7rem' }} />
<Typography variant="caption" sx={{ fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>
{d.trackingCode}
</Typography>
</Box>
</Box>
<Box aria-hidden sx={{ gridColumn: 7, gridRow: 1, display: 'flex', justifyContent: 'center', ...measuringCellSx }}>
<Chip
size="small"
color="success"
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<span>Geliefert</span>
<ArrowDropDownIcon sx={{ fontSize: 16 }} />
</Box>
}
sx={{ fontWeight: 600, height: 24 }}
/>
</Box>
<Box aria-hidden sx={{ gridColumn: 8, gridRow: 1, display: 'flex', justifyContent: 'center', ...measuringCellSx }}>
<IconButton sx={{ width: 40, height: 40 }} />
</Box>
</>
);
}
/**
* Ambient context for `ShipmentRow`: FillSpace renders rows as bare elements
* (`<Row item index />`), so the list publishes callbacks and party lookup here.
*/
const rowContext = {
partiesById: {},
busyId: null,
onOpen: null,
onDelete: null,
headerItem: null,
};
// MARK: <ShipmentRow/>
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 (
<ListItemButton disabled divider sx={{ ...rowGridSx, height: 64, minHeight: 64, maxHeight: 64, px: 2, py: 0, opacity: 0, pointerEvents: 'none' }}>
<Typography variant="body2">&nbsp;</Typography>
</ListItemButton>
);
}
return (
<ListItemButton disabled divider sx={{ ...rowGridSx, height: 64, minHeight: 64, maxHeight: 64, px: 2, py: 0 }}>
{renderInvisibleDummyRow(dummy)}
<Box sx={{ gridColumn: 1, gridRow: 2 }}><Skeleton variant="text" width="90%" height={20} /></Box>
<Box sx={{ gridColumn: 2, gridRow: 2 }}><Skeleton variant="text" width="90%" height={20} /></Box>
<Box sx={{ gridColumn: 3, gridRow: 2 }}><Skeleton variant="text" width="90%" height={20} /></Box>
<Box sx={{ gridColumn: 4, gridRow: 2 }}><Skeleton variant="text" width="60%" height={20} /></Box>
<Box sx={{ gridColumn: 5, gridRow: 2, display: 'flex', justifyContent: 'center' }}><Skeleton variant="rounded" width={28} height={20} /></Box>
<Box sx={{ gridColumn: 6, gridRow: 2 }}><Skeleton variant="text" width={100} height={20} /></Box>
<Box sx={{ gridColumn: 7, gridRow: 2, display: 'flex', justifyContent: 'center' }}><Skeleton variant="rounded" width={70} height={24} /></Box>
<Box sx={{ gridColumn: 8, gridRow: 2 }} />
</ListItemButton>
);
}
const { busyId, onOpen, onDelete } = rowContext;
const isBusy = busyId === shipment.id;
const canDelete = shipment.state === 'new';
return (
<ListItemButton
divider
sx={{ ...rowGridSx, height: 64, minHeight: 64, maxHeight: 64, px: 2, py: 0 }}
onClick={disabled ? undefined : () => onOpen?.(shipment)}
disabled={disabled}
>
{renderInvisibleDummyRow(dummy)}
{/* 1. Lieferschein-Nr & Ref */}
<Box sx={{ gridColumn: 1, gridRow: 2, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Typography variant="body1" sx={{ fontWeight: 600, ...ellipsisSx }}>
{shipment.number}
</Typography>
<Typography variant="body2" color="text.secondary" sx={ellipsisSx}>
{numberDisplay(shipment.externalNumber)}
</Typography>
</Box>
{/* 2. Datum */}
<Box sx={{ gridColumn: 2, gridRow: 2, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Typography variant="body2" color="text.secondary" sx={ellipsisSx}>
{shipment.date ? formatDate(shipment.date) : '–'}
</Typography>
</Box>
{/* 3. Bezugsauftrag */}
<Box sx={{ gridColumn: 3, gridRow: 2, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{shipment.orderNumber ? (
<Typography variant="body2" sx={{ fontWeight: 600, color: 'primary.main', ...ellipsisSx }}>
{shipment.orderNumber}
</Typography>
) : (
<Typography variant="body2" color="text.secondary">
–
</Typography>
)}
</Box>
{/* 4. Kunde */}
<Box sx={{ gridColumn: 4, gridRow: 2, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Typography variant="body1" sx={{ fontWeight: 500, ...ellipsisSx }}>
{shipment.customerName || this.partyName(shipment.entityId)}
</Typography>
</Box>
{/* 5. Positionen */}
<Box sx={{ gridColumn: 5, gridRow: 2, display: 'flex', justifyContent: 'center' }}>
<Chip
size="small"
label={shipment.positions || 0}
title={`${shipment.positions || 0} Positionen (${shipment.totalQuantity || 0} Stk.)`}
sx={{ height: 24, '& .MuiChip-label': { px: 0.5 } }}
/>
</Box>
{/* 6. Paket / Tracking */}
<Box sx={{ gridColumn: 6, gridRow: 2, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{shipment.trackingCode ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{shipment.carrier && (
<Chip size="small" variant="outlined" label={shipment.carrier} sx={{ height: 20, fontSize: '0.7rem' }} />
)}
<Typography variant="caption" sx={{ fontWeight: 600, fontVariantNumeric: 'tabular-nums', ...ellipsisSx }}>
{shipment.trackingCode}
</Typography>
</Box>
) : shipment.packageCount > 0 ? (
<Typography variant="caption" color="text.secondary">
{shipment.packageCount} {shipment.packageCount === 1 ? 'Paket' : 'Pakete'}
</Typography>
) : (
<Typography variant="caption" color="text.secondary">
–
</Typography>
)}
</Box>
{/* 7. Status */}
<Box sx={{ gridColumn: 7, gridRow: 2, display: 'flex', justifyContent: 'center' }}>
<DeliveryStateMenu shipment={shipment} />
</Box>
{/* 8. Aktionen (Löschen) */}
<Box sx={{ gridColumn: 8, gridRow: 2, display: 'flex', justifyContent: 'center' }}>
{canDelete ? (
<IconButton
aria-label={`„${shipment.number}“ löschen`}
disabled={isBusy || disabled}
onClick={(e) => {
e.stopPropagation();
onDelete?.(shipment);
}}
onMouseDown={(e) => e.stopPropagation()}
sx={{ width: 40, height: 40 }}
>
<DeleteOutlinedIcon sx={{ fontSize: 24 }} />
</IconButton>
) : (
<Box sx={{ width: 40, height: 40 }} />
)}
</Box>
</ListItemButton>
);
}
}
// MARK: <ShipmentSearchField/>
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 (
<PanelTileGrid>
<Box
ref={inputRef}
sx={{
gridColumn: { xs: '1 / -1', sm: 'span 2', md: 'span 2' },
display: 'flex',
flexDirection: 'column',
gap: 1.25,
pb: 2,
}}
>
{/* Top row: search field & create button */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<SingleLineTextEdit
sx={{ flex: 1 }}
size="small"
search
clear
placeholder="Lieferschein-, Auftrags-, Tracking-Nr. oder Kunde suchen…"
value={input}
onChange={(next) => shipmentAutomat.actions.setFilter(next)}
/>
<PartyFilterButton
title="Kunden"
open={customerFilterOpen}
onClick={onToggleCustomerFilter}
/>
{selectedCustomer && (
<PartyFilterChip
label={partyDisplayName('order', selectedCustomer)}
onDelete={onClearCustomerFilter}
/>
)}
<Button variant="contained" onClick={onCreate}>
Lieferschein erstellen
</Button>
</Box>
{/* Filter Bar: Status chips & Negativeseek (-1 Month, -1 Year, Reset) */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 1.5,
}}
>
{/* Left: Status chips */}
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
px: 1.25,
py: 0.25,
borderRadius: 2,
bgcolor: 'background.paper',
boxShadow: 1,
}}
>
<Chip
size="small"
label="Alle"
color={!stateFilter ? 'primary' : 'default'}
variant={!stateFilter ? 'filled' : 'outlined'}
onClick={() => shipmentAutomat.actions.setStateFilter('')}
sx={{ fontWeight: 600, cursor: 'pointer', height: 26 }}
/>
<Chip
size="small"
label="Neu"
color={stateFilter === 'new' ? 'info' : 'default'}
variant={stateFilter === 'new' ? 'filled' : 'outlined'}
onClick={() => shipmentAutomat.actions.setStateFilter('new')}
sx={{ fontWeight: 600, cursor: 'pointer', height: 26 }}
/>
<Chip
size="small"
label="Geliefert"
color={stateFilter === 'delivered' ? 'success' : 'default'}
variant={stateFilter === 'delivered' ? 'filled' : 'outlined'}
onClick={() => shipmentAutomat.actions.setStateFilter('delivered')}
sx={{ fontWeight: 600, cursor: 'pointer', height: 26 }}
/>
<Chip
size="small"
label="Retoure"
color={stateFilter === 'returned' ? 'error' : 'default'}
variant={stateFilter === 'returned' ? 'filled' : 'outlined'}
onClick={() => shipmentAutomat.actions.setStateFilter('returned')}
sx={{ fontWeight: 600, cursor: 'pointer', height: 26 }}
/>
</Box>
{/* Right: Negativeseek controls (-1 Month, -1 Year, Reset) */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
px: 1.25,
py: 0.25,
borderRadius: 2,
bgcolor: 'background.paper',
boxShadow: 1,
}}
>
<Button
size="small"
variant={monthActive ? 'contained' : 'outlined'}
color={monthActive ? 'primary' : 'inherit'}
onClick={() => shipmentAutomat.actions.addSeekMonth()}
sx={{
minWidth: 88,
px: 1.25,
py: 0.25,
height: 28,
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'none',
boxSizing: 'border-box',
border: '1px solid',
borderColor: monthActive ? 'primary.main' : 'divider',
whiteSpace: 'nowrap',
}}
>
{monthLabel}
</Button>
<Button
size="small"
variant={yearActive ? 'contained' : 'outlined'}
color={yearActive ? 'primary' : 'inherit'}
onClick={() => shipmentAutomat.actions.addSeekYear()}
sx={{
minWidth: 78,
px: 1.25,
py: 0.25,
height: 28,
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'none',
boxSizing: 'border-box',
border: '1px solid',
borderColor: yearActive ? 'primary.main' : 'divider',
whiteSpace: 'nowrap',
}}
>
{yearLabel}
</Button>
<Button
size="small"
variant="text"
color="inherit"
disabled={!monthActive && !yearActive}
onClick={() => shipmentAutomat.actions.resetNegativeSeek()}
sx={{
minWidth: 'auto',
px: 1,
py: 0.25,
height: 28,
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'none',
}}
>
Reset
</Button>
</Box>
</Box>
</Box>
</Box>
</PanelTileGrid>
);
}
}
// MARK: <ShipmentList/>
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 = (
<ShipmentSearchField
inputRef={this.searchRef}
onCreate={() => this.setState({ newShipmentModalOpen: true })}
customerFilterOpen={customerFilterOpen}
onToggleCustomerFilter={this.handleToggleCustomerFilter}
selectedCustomer={selectedCustomer}
onClearCustomerFilter={() => this.handleSelectCustomer(null)}
/>
);
if (items === null && shipmentAutomat.state.status === 'failed') {
return (
<>
{searchField}
<PanelTileGrid>
<PanelTile wide={3} minHeight={0} color={GREEN_PANEL} sx={{ p: 2 }}>
<BackendError error={backendErrorFrom(shipmentAutomat.state.error)} onRetry={this.handleRetry} />
</PanelTile>
</PanelTileGrid>
</>
);
}
return (
<>
{searchField}
<Box sx={{ display: 'flex', gap: 2.5, alignItems: 'stretch' }}>
{customerFilterOpen && (
<PartyFilterPanel
title="Kunden"
parties={partiesList}
selectedId={entityId}
onSelect={this.handleSelectCustomer}
onClose={this.handleToggleCustomerFilter}
counts={customerCounts}
searchPlaceholder="Kunde suchen…"
/>
)}
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<PanelTileGrid>
<PanelTile wide={3} minHeight={0} color={GREEN_PANEL} sx={{ pb: 0, position: 'relative' }}>
<FillSpace
aboveRef={[this.searchRef]}
reserveBottom={32}
minPageSize={8}
minHeight={0}
resetKey={filterKey}
items={items || []}
totalCount={items ? items.length : 0}
unfilteredTotal={unfilteredTotal}
headerItem={this.headerItem}
renderItem={ShipmentRow}
virtualScrollbar
header={
<Box
sx={{
...rowGridSx,
height: 36,
minHeight: 36,
px: 2,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
{renderInvisibleDummyRow(this.headerItem)}
<Box sx={{ gridColumn: 1, gridRow: 2, ...headerCellSx }}>Lieferschein / Ref</Box>
<Box sx={{ gridColumn: 2, gridRow: 2, ...headerCellSx }}>Datum</Box>
<Box sx={{ gridColumn: 3, gridRow: 2, ...headerCellSx }}>Auftrag</Box>
<Box sx={{ gridColumn: 4, gridRow: 2, ...headerCellSx }}>Kunde</Box>
<Box sx={{ gridColumn: 5, gridRow: 2, display: 'flex', justifyContent: 'center', textAlign: 'center', ...headerCellSx }}>Pos.</Box>
<Box sx={{ gridColumn: 6, gridRow: 2, ...headerCellSx }}>Paket / Tracking</Box>
<Box sx={{ gridColumn: 7, gridRow: 2, display: 'flex', justifyContent: 'center', textAlign: 'center', ...headerCellSx }}>Status</Box>
<Box sx={{ gridColumn: 8, gridRow: 2, ...headerCellSx }} />
</Box>
}
onPageChange={this.handlePageChange}
/>
{items === null && (
<Box
sx={{
position: 'absolute',
top: 52,
left: 0,
right: 0,
bottom: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
zIndex: 2,
}}
>
<CircularProgress size={32} />
</Box>
)}
</PanelTile>
</PanelTileGrid>
</Box>
</Box>
{/* Modal: View / Manage Shipment Details */}
{selectedShipment && (
<ShipmentDetailDialog
open={Boolean(selectedShipment)}
shipment={selectedShipment}
onClose={this.handleCloseShipment}
onChanged={() => {
void shipmentAutomat.actions.reload();
void orderAutomat.actions.reload();
}}
/>
)}
{/* Modal: Create new shipment from open order */}
{newShipmentModalOpen && (
<NewShipmentModal
open={newShipmentModalOpen}
orders={orders}
partiesById={partiesById}
onClose={() => this.setState({ newShipmentModalOpen: false })}
onSuccess={(created) => {
if (created) {
this.setState({ selectedShipment: created });
}
}}
/>
)}
</>
);
}
}
export default withNavigate(ShipmentList);