import { PureComponent } 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 Divider from '@mui/material/Divider';
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 Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import MenuItem from '@mui/material/MenuItem';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
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';
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
import Inventory2Icon from '@mui/icons-material/Inventory2';
import { enqueueSnackbar } from 'notistack';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import BackendError from '../BackendError';
import AppDialog from '../AppDialog';
import orderAutomat from '../../automats/orderAutomat';
import { shipment as shipmentApi, shipping as shippingApi } from '../../api';
import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
import withNavigate from '../withNavigate';
import {
formatDate,
loadParties,
manifestConfig,
numberDisplay,
partyDisplayName,
} from './manifestConfig';
import {
GREEN_PANEL,
SURFACE_BASE,
SURFACE_RAISED,
WHITE,
} from '../../colors';
const cfg = manifestConfig('order');
const CARRIERS = ['DHL', 'UPS', 'DPD', 'GLS', 'Hermes', 'Spedition', 'Selbstabholung'];
const ellipsisSx = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
// MARK:
class AddPackageDialog extends PureComponent {
constructor(props) {
super(props);
this.state = {
carrier: 'DHL',
trackingCode: '',
type: 'parcel',
state: 'draft',
saving: false,
};
this.handleSubmit = this.handleSubmit.bind(this);
}
async handleSubmit() {
const { shipment, onClose, onSuccess } = this.props;
const { carrier, trackingCode, type, state } = this.state;
this.setState({ saving: true });
try {
await shippingApi.create(shipment.id, {
carrier: carrier.trim() || undefined,
trackingCode: trackingCode.trim() || undefined,
type,
state,
});
enqueueSnackbar('Paket angelegt.', { variant: 'success' });
await orderAutomat.actions.reload();
onSuccess?.();
onClose();
} catch (err) {
notifyError(err);
this.setState({ saving: false });
}
}
render() {
const { open, onClose, shipment } = this.props;
const { carrier, trackingCode, type, state, saving } = this.state;
return (
Paket zu Lieferschein hinzufügen
Lieferschein: {shipment?.number}
this.setState({ carrier: e.target.value })}
>
{CARRIERS.map((c) => (
))}
this.setState({ trackingCode: e.target.value })}
placeholder="z.B. 00340434234234234"
/>
this.setState({ type: e.target.value })}
>
this.setState({ state: e.target.value })}
helperText={state === 'shipped' ? 'Bucht bei Auswahl direkt das Lager ab.' : undefined}
>
);
}
}
// MARK:
class NewShipmentModal extends PureComponent {
constructor(props) {
super(props);
const orders = props.orders.filter((o) => {
const ordQty = Number(o.orderedQuantity) || 0;
const shpQty = Number(o.shippedQuantity) || 0;
return ordQty === 0 || shpQty < ordQty;
});
const selectedOrder = orders[0] || null;
const initialAmounts = {};
if (selectedOrder) {
this.populateAmounts(selectedOrder, initialAmounts);
}
this.state = {
orderId: selectedOrder?.id || '',
date: new Date().toISOString().slice(0, 10),
externalNumber: '',
lineAmounts: initialAmounts,
saving: false,
};
this.handleOrderChange = this.handleOrderChange.bind(this);
this.handleAmountChange = this.handleAmountChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
populateAmounts(order, targetMap) {
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);
}
}
for (const l of order.lines || []) {
if (!l.sku) continue;
const orderedAmt = Number(l.amount) || 0;
const shippedAmt = shippedBySku[l.sku] || 0;
const rem = Math.max(0, orderedAmt - shippedAmt);
targetMap[l.sku] = rem > 0 ? String(rem) : '0';
}
}
handleOrderChange(newOrderId) {
const order = this.props.orders.find((o) => o.id === newOrderId);
const newAmounts = {};
if (order) {
this.populateAmounts(order, newAmounts);
}
this.setState({ orderId: newOrderId, lineAmounts: newAmounts });
}
handleAmountChange(sku, val) {
this.setState((prev) => ({
lineAmounts: { ...prev.lineAmounts, [sku]: val },
}));
}
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 (linesToShip.length === 0) {
enqueueSnackbar('Mindestens ein Artikel muss eine Liefermenge > 0 haben.', { variant: 'warning' });
return;
}
this.setState({ saving: true });
try {
const res = await shipmentApi.create(order.id, {
date,
externalNumber: externalNumber.trim() || undefined,
lines: linesToShip,
});
enqueueSnackbar(`Lieferschein ${res.shipment?.number || ''} erfolgreich angelegt!`, { variant: 'success' });
await orderAutomat.actions.reload();
onSuccess?.(res.shipment?.id);
onClose();
} catch (err) {
notifyError(err);
this.setState({ saving: false });
}
}
render() {
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 : [];
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 (
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}`;
return (
);
})}
this.setState({ date: e.target.value })}
InputLabelProps={{ shrink: true }}
sx={{ width: 180 }}
/>
this.setState({ externalNumber: e.target.value })}
sx={{ flex: 1 }}
/>
{currentOrder && (
<>
Zu liefernde Positionen (Maximal restante Bestellmenge)
SKU
Artikel
Bestellt
Geliefert
Offen
Liefermenge
{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] ?? '0';
return (
{line.sku}
{line.name}
{orderedAmt}
{shippedAmt}
0 ? 'primary.main' : 'text.disabled' }}>
{remaining}
this.handleAmountChange(line.sku, e.target.value)}
disabled={remaining <= 0}
inputProps={{ min: 0, max: remaining, step: 'any', style: { textAlign: 'right' } }}
sx={{ width: 100 }}
/>
);
})}
>
)}
}>
{saving ? 'Wird erstellt…' : 'Lieferschein anlegen'}
);
}
}
// MARK:
export class ShipmentList extends PureComponent {
constructor(props) {
super(props);
const s = orderAutomat.state;
this.state = {
status: s.status === 'ready' ? 'ready' : 'loading',
error: s.error ? backendErrorFrom(s.error) : null,
items: s.items ?? [],
filter: '',
partiesById: {},
selectedShipmentId: null,
addPackageDialogOpen: false,
newShipmentModalOpen: false,
busyAction: false,
};
this._cancelled = false;
this.handleRetry = this.handleRetry.bind(this);
this.handleSelectShipment = this.handleSelectShipment.bind(this);
this.handleDeleteShipment = this.handleDeleteShipment.bind(this);
this.handleDeletePackage = this.handleDeletePackage.bind(this);
this.handleMarkPackageShipped = this.handleMarkPackageShipped.bind(this);
}
componentDidMount() {
this._cancelled = false;
this.unsub = orderAutomat.subscribe(this, (state) => {
const items = state.items ?? [];
this.setState((prev) => {
// If current selectedShipmentId is no longer found, pick first
const allShipments = this.extractAllShipments(items);
let selectedShipmentId = prev.selectedShipmentId;
if (!selectedShipmentId && allShipments.length > 0) {
selectedShipmentId = allShipments[0].id;
} else if (selectedShipmentId && !allShipments.some((s) => s.id === selectedShipmentId)) {
selectedShipmentId = allShipments[0]?.id || null;
}
return {
items,
status: state.status,
error: state.error ? backendErrorFrom(state.error) : null,
selectedShipmentId,
};
});
});
if (orderAutomat.state.status === 'idle') {
void orderAutomat.actions.load().catch(() => {});
}
void this.loadPartiesData();
}
componentWillUnmount() {
this._cancelled = true;
this.unsub?.();
}
async loadPartiesData() {
try {
const parties = await loadParties('order');
if (!this._cancelled) {
this.setState({ partiesById: cfg.partiesByIdFromList(parties) });
}
} catch {
// Non-fatal
}
}
extractAllShipments(orders = this.state.items) {
const list = [];
for (const order of orders) {
if (!order || !Array.isArray(order.shipments)) continue;
for (const sh of order.shipments) {
list.push({
...sh,
parentOrderId: order.id,
parentOrderNumber: order.number,
parentOrderDate: order.date,
entityId: order.entityId,
parentOrder: order,
});
}
}
// Sort descending by date / id
return list.sort((a, b) => {
const cmp = String(b.date || '').localeCompare(String(a.date || ''));
if (cmp !== 0) return cmp;
return Number(b.id || 0) - Number(a.id || 0);
});
}
handleRetry() {
void orderAutomat.actions.load().catch(() => {});
}
handleSelectShipment(shipment) {
this.setState({ selectedShipmentId: shipment.id });
}
async handleDeleteShipment(sh) {
if (!window.confirm(`Lieferschein „${sh.number}“ wirklich löschen?`)) return;
this.setState({ busyAction: true });
try {
await shipmentApi.delete(sh.id);
enqueueSnackbar(`Lieferschein ${sh.number} gelöscht.`, { variant: 'success' });
await orderAutomat.actions.reload();
} catch (err) {
notifyError(err);
} finally {
this.setState({ busyAction: false });
}
}
async handleDeletePackage(pkg) {
if (!window.confirm(`Paket „#${pkg.number || pkg.id}“ wirklich löschen?`)) return;
this.setState({ busyAction: true });
try {
await shippingApi.delete(pkg.id);
enqueueSnackbar('Paket gelöscht.', { variant: 'success' });
await orderAutomat.actions.reload();
} catch (err) {
notifyError(err);
} finally {
this.setState({ busyAction: false });
}
}
async handleMarkPackageShipped(pkg) {
this.setState({ busyAction: true });
try {
await shippingApi.update(pkg.id, { state: 'shipped' });
enqueueSnackbar('Paket als versendet markiert (Lagerbestand abgebucht).', { variant: 'success' });
await orderAutomat.actions.reload();
} catch (err) {
notifyError(err);
} finally {
this.setState({ busyAction: false });
}
}
getFilteredShipments() {
const all = this.extractAllShipments();
const q = this.state.filter.trim().toLowerCase();
if (!q) return all;
const { partiesById } = this.state;
return all.filter((sh) => {
const party = partiesById[sh.entityId];
const partyName = partyDisplayName('order', party);
const trackingCodes = (sh.shippings || []).map((p) => p.trackingCode).filter(Boolean);
const carriers = (sh.shippings || []).map((p) => p.carrier).filter(Boolean);
return [sh.number, sh.parentOrderNumber, sh.externalNumber, partyName, ...trackingCodes, ...carriers]
.some((val) => String(val || '').toLowerCase().includes(q));
});
}
render() {
const { status, error, filter, partiesById, selectedShipmentId, addPackageDialogOpen, newShipmentModalOpen, busyAction, items } = this.state;
if (status === 'loading' || status === 'idle') {
return (
);
}
if (status === 'failed') {
return ;
}
const filteredShipments = this.getFilteredShipments();
const selectedShipment = filteredShipments.find((s) => s.id === selectedShipmentId)
|| this.extractAllShipments().find((s) => s.id === selectedShipmentId)
|| filteredShipments[0]
|| null;
const shPackages = Array.isArray(selectedShipment?.shippings) ? selectedShipment.shippings : [];
const shLines = Array.isArray(selectedShipment?.lines) ? selectedShipment.lines : [];
const hasShippedPackages = shPackages.some((p) => p.state === 'shipped');
return (
<>
{/* Top Filter and Action Bar */}
this.setState({ filter: next })}
/>
}
onClick={() => this.setState({ newShipmentModalOpen: true })}
>
Neuer Lieferschein
{/* Master-Detail Split Grid */}
{/* Left: Shipment Manifests List */}
Lieferscheine (Shipments)
{filteredShipments.length === 0 ? (
Keine Lieferscheine gefunden.
) : (
filteredShipments.map((sh) => {
const isSelected = selectedShipment?.id === sh.id;
const party = partiesById[sh.entityId];
const partyName = partyDisplayName('order', party) || `Kunde #${sh.entityId}`;
const pkgCount = Array.isArray(sh.shippings) ? sh.shippings.length : 0;
const lineCount = Array.isArray(sh.lines) ? sh.lines.length : 0;
const allShipped = pkgCount > 0 && sh.shippings.every((p) => p.state === 'shipped');
return (
this.handleSelectShipment(sh)}
sx={{
py: 1.25,
px: 1.75,
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
gap: 0.5,
bgcolor: isSelected ? 'action.selected' : 'inherit',
}}
>
{sh.number}
{sh.date ? formatDate(sh.date) : '–'}
Auftrag: {sh.parentOrderNumber} • {partyName}
0 ? 'primary' : 'default'}
label={`${pkgCount} Paket${pkgCount === 1 ? '' : 'e'}`}
sx={{ height: 18, fontSize: '0.65rem', fontWeight: 600 }}
/>
);
})
)}
{/* Right: Selected Shipment Detail & Shipping Packages */}
{selectedShipment ? (
{/* Header */}
{selectedShipment.number}
Zugehöriger Auftrag: {selectedShipment.parentOrderNumber} • Datum: {selectedShipment.date ? formatDate(selectedShipment.date) : '–'}
{selectedShipment.externalNumber ? ` • Ref: ${selectedShipment.externalNumber}` : ''}
}
onClick={() => this.setState({ addPackageDialogOpen: true })}
disabled={busyAction}
>
Paket hinzufügen
{!hasShippedPackages && (
this.handleDeleteShipment(selectedShipment)}
aria-label={`Lieferschein ${selectedShipment.number} löschen`}
>
)}
{/* Shipped Article Positions */}
Positionen dieses Lieferscheins ({shLines.length})
SKU
Bezeichnung
Menge
{shLines.map((line, idx) => (
{line.sku}
{line.name}
{line.amount}
))}
{/* Shipping Packages from 'shipping' Table */}
Pakete aus der Versandtabelle (shipping) ({shPackages.length})
{shPackages.length === 0 ? (
Keine Pakete (Shippings) erfasst.
}
onClick={() => this.setState({ addPackageDialogOpen: true })}
>
Paket anlegen
) : (
{shPackages.map((pkg) => {
const isShipped = pkg.state === 'shipped';
return (
{pkg.carrier || 'DHL'} ({pkg.type === 'pallet' ? 'Palette' : 'Paket'})
{pkg.trackingCode ? (
{pkg.trackingCode}
{
navigator.clipboard.writeText(pkg.trackingCode);
enqueueSnackbar('Tracking-Code kopiert!', { variant: 'info' });
}}
sx={{ p: 0.25 }}
>
) : (
Ohne Tracking
)}
{isShipped ? (
}
label="Versendet (Lager abgebucht)"
sx={{ fontWeight: 600 }}
/>
) : (
<>
this.handleDeletePackage(pkg)}
>
>
)}
);
})}
)}
) : (
Wählen Sie einen Lieferschein aus der linken Liste aus.
)}
{addPackageDialogOpen && selectedShipment && (
this.setState({ addPackageDialogOpen: false })}
/>
)}
{newShipmentModalOpen && (
this.setState({ newShipmentModalOpen: false })}
onSuccess={(newId) => {
if (newId) this.setState({ selectedShipmentId: newId });
}}
/>
)}
>
);
}
}
export default withNavigate(ShipmentList);