Files
dashfront/src/components/manifest/ShipmentList.jsx
2026-10-14 01:13:09 +02:00

928 lines
37 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 } 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: <AddPackageDialog/>
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 (
<AppDialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<Box sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Paket zu Lieferschein hinzufügen
</Typography>
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Lieferschein: <strong>{shipment?.number}</strong>
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}>
<TextField
select
label="Versanddienstleister (Carrier)"
size="small"
value={carrier}
onChange={(e) => this.setState({ carrier: e.target.value })}
>
{CARRIERS.map((c) => (
<MenuItem key={c} value={c}>{c}</MenuItem>
))}
</TextField>
<TextField
label="Tracking-Code / Sendungsnummer"
size="small"
value={trackingCode}
onChange={(e) => this.setState({ trackingCode: e.target.value })}
placeholder="z.B. 00340434234234234"
/>
<TextField
select
label="Paketart"
size="small"
value={type}
onChange={(e) => this.setState({ type: e.target.value })}
>
<MenuItem value="parcel">Paket (Parcel)</MenuItem>
<MenuItem value="pallet">Palette (Pallet)</MenuItem>
<MenuItem value="letter">Brief / Dokument</MenuItem>
</TextField>
<TextField
select
label="Status"
size="small"
value={state}
onChange={(e) => this.setState({ state: e.target.value })}
helperText={state === 'shipped' ? 'Bucht bei Auswahl direkt das Lager ab.' : undefined}
>
<MenuItem value="draft">Entwurf (draft)</MenuItem>
<MenuItem value="packed">Gepackt (packed)</MenuItem>
<MenuItem value="shipped">Versendet (shipped — Lagerabbuchung)</MenuItem>
</TextField>
</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}>
{saving ? 'Wird gespeichert…' : 'Paket anlegen'}
</Button>
</Box>
</Box>
</AppDialog>
);
}
}
// MARK: <NewShipmentModal/>
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 (
<AppDialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<Box sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Neuen Lieferschein erstellen (Shipment Manifest)
</Typography>
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}>
<TextField
select
label="Auftrag auswählen"
size="small"
value={orderId}
onChange={(e) => this.handleOrderChange(e.target.value)}
fullWidth
>
{openOrders.map((o) => {
const party = partiesById[o.entityId];
const partyName = partyDisplayName('order', party) || `Kunde #${o.entityId}`;
return (
<MenuItem key={o.id} value={o.id}>
{o.number} — {partyName} ({o.shippedQuantity || 0}/{o.orderedQuantity || 0} geliefert)
</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: 180 }}
/>
<TextField
label="Externe Referenz / Lieferschein-Nr."
size="small"
placeholder="z.B. LS-2026-99"
value={externalNumber}
onChange={(e) => this.setState({ externalNumber: e.target.value })}
sx={{ flex: 1 }}
/>
</Box>
</Box>
{currentOrder && (
<>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Zu liefernde Positionen (Maximal restante Bestellmenge)
</Typography>
<TableContainer sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1, mb: 3 }}>
<Table size="small">
<TableHead sx={{ bgcolor: 'action.hover' }}>
<TableRow>
<TableCell sx={{ fontWeight: 600 }}>SKU</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Artikel</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>Bestellt</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>Geliefert</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>Offen</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, width: 120 }}>Liefermenge</TableCell>
</TableRow>
</TableHead>
<TableBody>
{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 (
<TableRow key={line.id || line.sku}>
<TableCell sx={{ fontWeight: 500 }}>{line.sku}</TableCell>
<TableCell>{line.name}</TableCell>
<TableCell align="right">{orderedAmt}</TableCell>
<TableCell align="right">{shippedAmt}</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, color: remaining > 0 ? 'primary.main' : 'text.disabled' }}>
{remaining}
</TableCell>
<TableCell align="right">
<TextField
size="small"
type="number"
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: 100 }}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</>
)}
<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: <ShipmentList/>
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 (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
);
}
if (status === 'failed') {
return <BackendError error={error} onRetry={this.handleRetry} />;
}
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 */}
<PanelTileGrid>
<Box
sx={{
gridColumn: { xs: '1 / -1', sm: 'span 2', md: 'span 2' },
display: 'flex',
alignItems: 'center',
gap: 1.5,
pb: 2,
}}
>
<SingleLineTextEdit
sx={{ flex: 1 }}
size="small"
search
clear
placeholder="Lieferschein, Auftrag, Tracking-Code oder Carrier suchen…"
value={filter}
onChange={(next) => this.setState({ filter: next })}
/>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => this.setState({ newShipmentModalOpen: true })}
>
Neuer Lieferschein
</Button>
</Box>
</PanelTileGrid>
{/* Master-Detail Split Grid */}
<PanelTileGrid>
{/* Left: Shipment Manifests List */}
<PanelTile wide={1.4} minHeight={480} color={GREEN_PANEL} sx={{ p: 0, display: 'flex', flexDirection: 'column' }}>
<Box sx={{ p: 1.5, borderBottom: '1px solid', borderColor: 'divider', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
Lieferscheine (Shipments)
</Typography>
<Chip size="small" label={`${filteredShipments.length} Einträge`} sx={{ fontWeight: 600, height: 20 }} />
</Box>
<Box sx={{ flex: 1, overflowY: 'auto', maxHeight: 600 }}>
{filteredShipments.length === 0 ? (
<Box sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
Keine Lieferscheine gefunden.
</Typography>
</Box>
) : (
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 (
<ListItemButton
key={sh.id}
divider
selected={isSelected}
onClick={() => this.handleSelectShipment(sh)}
sx={{
py: 1.25,
px: 1.75,
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
gap: 0.5,
bgcolor: isSelected ? 'action.selected' : 'inherit',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="body1" sx={{ fontWeight: 700, ...ellipsisSx }}>
{sh.number}
</Typography>
<Typography variant="caption" color="text.secondary">
{sh.date ? formatDate(sh.date) : '–'}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="body2" color="text.secondary" sx={{ ...ellipsisSx, maxWidth: '65%' }}>
Auftrag: <strong>{sh.parentOrderNumber}</strong> • {partyName}
</Typography>
<Box sx={{ display: 'flex', gap: 0.75 }}>
<Chip size="small" label={`${lineCount} Pos.`} sx={{ height: 18, fontSize: '0.65rem' }} />
<Chip
size="small"
color={allShipped ? 'success' : pkgCount > 0 ? 'primary' : 'default'}
label={`${pkgCount} Paket${pkgCount === 1 ? '' : 'e'}`}
sx={{ height: 18, fontSize: '0.65rem', fontWeight: 600 }}
/>
</Box>
</Box>
</ListItemButton>
);
})
)}
</Box>
</PanelTile>
{/* Right: Selected Shipment Detail & Shipping Packages */}
<PanelTile wide={1.6} minHeight={480} color={SURFACE_BASE} sx={{ p: 2.5 }}>
{selectedShipment ? (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 2.5 }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', pb: 1.5, borderBottom: '1px solid', borderColor: 'divider' }}>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<LocalShippingIcon color="primary" sx={{ fontSize: 28 }} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>
{selectedShipment.number}
</Typography>
<Chip
size="small"
color={hasShippedPackages ? 'success' : 'default'}
label={hasShippedPackages ? 'Im Versand' : 'Bereitstellung'}
sx={{ fontWeight: 600 }}
/>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
Zugehöriger Auftrag: <strong>{selectedShipment.parentOrderNumber}</strong> • Datum: {selectedShipment.date ? formatDate(selectedShipment.date) : '–'}
{selectedShipment.externalNumber ? ` • Ref: ${selectedShipment.externalNumber}` : ''}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
size="small"
variant="contained"
startIcon={<AddIcon />}
onClick={() => this.setState({ addPackageDialogOpen: true })}
disabled={busyAction}
>
Paket hinzufügen
</Button>
{!hasShippedPackages && (
<IconButton
size="small"
color="error"
disabled={busyAction}
onClick={() => this.handleDeleteShipment(selectedShipment)}
aria-label={`Lieferschein ${selectedShipment.number} löschen`}
>
<DeleteOutlinedIcon fontSize="small" />
</IconButton>
)}
</Box>
</Box>
{/* Shipped Article Positions */}
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, textTransform: 'uppercase', color: 'text.secondary', display: 'block', mb: 1 }}>
Positionen dieses Lieferscheins ({shLines.length})
</Typography>
<TableContainer sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1.5, bgcolor: WHITE }}>
<Table size="small">
<TableHead sx={{ bgcolor: SURFACE_RAISED }}>
<TableRow>
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>SKU</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Bezeichnung</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, py: 0.5 }}>Menge</TableCell>
</TableRow>
</TableHead>
<TableBody>
{shLines.map((line, idx) => (
<TableRow key={line.id || idx}>
<TableCell sx={{ py: 0.5, fontWeight: 500 }}>{line.sku}</TableCell>
<TableCell sx={{ py: 0.5 }}>{line.name}</TableCell>
<TableCell align="right" sx={{ py: 0.5, fontWeight: 700 }}>{line.amount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
{/* Shipping Packages from 'shipping' Table */}
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 700, textTransform: 'uppercase', color: 'text.secondary' }}>
Pakete aus der Versandtabelle (shipping) ({shPackages.length})
</Typography>
</Box>
{shPackages.length === 0 ? (
<Box sx={{ p: 3, textAlign: 'center', bgcolor: WHITE, borderRadius: 1.5, border: '1px dashed', borderColor: 'divider' }}>
<Inventory2Icon sx={{ color: 'text.secondary', fontSize: 32, mb: 1 }} />
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Keine Pakete (Shippings) erfasst.
</Typography>
<Button
size="small"
variant="outlined"
startIcon={<AddIcon />}
onClick={() => this.setState({ addPackageDialogOpen: true })}
>
Paket anlegen
</Button>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
{shPackages.map((pkg) => {
const isShipped = pkg.state === 'shipped';
return (
<Box
key={pkg.id}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
p: 1.5,
bgcolor: WHITE,
borderRadius: 1.5,
border: '1px solid',
borderColor: 'divider',
boxShadow: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Chip size="small" label={`#${pkg.number || pkg.id}`} sx={{ fontWeight: 700 }} />
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{pkg.carrier || 'DHL'} ({pkg.type === 'pallet' ? 'Palette' : 'Paket'})
</Typography>
{pkg.trackingCode ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontWeight: 600, color: 'primary.main' }}>
{pkg.trackingCode}
</Typography>
<IconButton
size="small"
onClick={() => {
navigator.clipboard.writeText(pkg.trackingCode);
enqueueSnackbar('Tracking-Code kopiert!', { variant: 'info' });
}}
sx={{ p: 0.25 }}
>
<ContentCopyIcon sx={{ fontSize: 13 }} />
</IconButton>
</Box>
) : (
<Typography variant="caption" color="text.secondary">
Ohne Tracking
</Typography>
)}
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{isShipped ? (
<Chip
size="small"
color="success"
icon={<CheckCircleOutlineOutlinedIcon />}
label="Versendet (Lager abgebucht)"
sx={{ fontWeight: 600 }}
/>
) : (
<>
<Chip size="small" variant="outlined" label={pkg.state} />
<Button
size="small"
variant="contained"
color="success"
disabled={busyAction}
onClick={() => this.handleMarkPackageShipped(pkg)}
sx={{ textTransform: 'none', py: 0.25, px: 1, height: 26 }}
>
Als versendet buchen
</Button>
<IconButton
size="small"
color="error"
disabled={busyAction}
onClick={() => this.handleDeletePackage(pkg)}
>
<DeleteOutlinedIcon fontSize="small" />
</IconButton>
</>
)}
</Box>
</Box>
);
})}
</Box>
)}
</Box>
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', minHeight: 240 }}>
<Typography variant="body1" color="text.secondary">
Wählen Sie einen Lieferschein aus der linken Liste aus.
</Typography>
</Box>
)}
</PanelTile>
</PanelTileGrid>
{addPackageDialogOpen && selectedShipment && (
<AddPackageDialog
open={addPackageDialogOpen}
shipment={selectedShipment}
onClose={() => this.setState({ addPackageDialogOpen: false })}
/>
)}
{newShipmentModalOpen && (
<NewShipmentModal
open={newShipmentModalOpen}
orders={items}
partiesById={partiesById}
onClose={() => this.setState({ newShipmentModalOpen: false })}
onSuccess={(newId) => {
if (newId) this.setState({ selectedShipmentId: newId });
}}
/>
)}
</>
);
}
}
export default withNavigate(ShipmentList);