This commit is contained in:
seb
2026-10-15 21:04:37 +02:00
parent beed294cb0
commit fafeec93b6
5 changed files with 538 additions and 348 deletions

View File

@@ -8,7 +8,7 @@ export const shipment = {
// #region GET /api/shipment — list
list(params = {}) {
const q = new URLSearchParams();
if (params.offset != null) q.set('offset', String(params.offset));
if (params.offset) q.set('offset', String(params.offset));
if (params.limit != null) q.set('limit', String(params.limit));
if (params.search) q.set('search', String(params.search));
if (params.state) q.set('state', String(params.state));

View File

@@ -47,6 +47,7 @@ import orderAutomat from './orderAutomat';
* @property {string} filter
* @property {string} stateFilter
* @property {number} seekMonths
* @property {number} seekYears
* @property {'idle'|'loading'|'ready'|'failed'} status
* @property {Error|string|null} error
*/
@@ -58,6 +59,7 @@ const initialState = {
filter: '',
stateFilter: '',
seekMonths: 0,
seekYears: 0,
status: 'idle',
error: null,
};
@@ -69,7 +71,6 @@ let debounceTimer = null;
// #region fetch helper
async function fetchShipments(state) {
const params = {
offset: 0,
limit: 100,
};
if (state.filter && state.filter.trim()) {
@@ -78,8 +79,9 @@ async function fetchShipments(state) {
if (state.stateFilter) {
params.state = state.stateFilter;
}
if (state.seekMonths) {
params.seekMonths = state.seekMonths;
const totalMonths = (state.seekMonths || 0) + (state.seekYears || 0) * 12;
if (totalMonths > 0) {
params.seekMonths = totalMonths;
}
const res = await shipmentApi.list(params);
@@ -164,7 +166,22 @@ const actions = {
},
setSeekMonths(months) {
automat.setState({ seekMonths: months });
automat.setState({ seekMonths: months, seekYears: 0 });
void automat.actions.load().catch(() => {});
},
addSeekMonth() {
automat.setState((s) => ({ seekMonths: (s.seekMonths ?? 0) + 1 }));
void automat.actions.load().catch(() => {});
},
addSeekYear() {
automat.setState((s) => ({ seekYears: (s.seekYears ?? 0) + 1 }));
void automat.actions.load().catch(() => {});
},
resetNegativeSeek() {
automat.setState({ seekMonths: 0, seekYears: 0 });
void automat.actions.load().catch(() => {});
},
// #endregion
@@ -211,10 +228,25 @@ const actions = {
automat.setState({ ...initialState });
},
// #endregion
};
};
automat = new Automat(initialState, actions, { name: 'shipment' });
// MARK: filter key & items helpers
export function getFilterKey(state = automat?.state || initialState) {
return `${state.filter || ''}|${state.stateFilter || ''}|${state.seekMonths || 0}|${state.seekYears || 0}`;
}
export function getItems() {
if (automat.state.status === 'idle') {
void automat.actions.load().catch(() => {});
}
return automat.state.items;
}
automat.getFilterKey = getFilterKey;
automat.getItems = getItems;
// #region authAutomat synchronization
let lastCompanyId = null;
authAutomat.subscribe((auth) => {

View File

@@ -171,10 +171,12 @@ class AppSidebar extends PureComponent {
</ListItemIcon>
<ListItemText
primary={label}
primaryTypographyProps={{
slotProps={{
primary: {
fontSize: { xs: '0.8rem', sm: '0.875rem' },
fontWeight: active ? 600 : 500,
noWrap: true,
},
}}
sx={{
m: { xs: 0, lg: 'initial' },

View File

@@ -167,7 +167,9 @@ class UserMenu extends PureComponent {
<ListItemText
primary="Update verfügbar"
secondary="Jetzt aktualisieren"
primaryTypographyProps={{ fontWeight: 600, color: 'primary.main' }}
slotProps={{
primary: { fontWeight: 600, color: 'primary.main' },
}}
/>
</MenuItem>
)}

View File

@@ -1,4 +1,4 @@
import { PureComponent } from 'react';
import { PureComponent, createRef } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
@@ -9,9 +9,8 @@ import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import MenuItem from '@mui/material/MenuItem';
import Stack from '@mui/material/Stack';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import Skeleton from '@mui/material/Skeleton';
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
import AddIcon from '@mui/icons-material/Add';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import CloseIcon from '@mui/icons-material/Close';
@@ -19,14 +18,17 @@ import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import Menu from '@mui/material/Menu';
import { enqueueSnackbar } from 'notistack';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import FillSpace from 'dashgui/FillSpace';
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import BackendError from '../BackendError';
import AppDialog from '../AppDialog';
import shipmentAutomat from '../../automats/shipmentAutomat';
import orderAutomat from '../../automats/orderAutomat';
import numberAutomat from '../../automats/numberAutomat';
import { shipment as shipmentApi } from '../../api';
import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
import { getMaxDocumentNumber } from '../../utils/documentNumber';
import withNavigate from '../withNavigate';
import ShipmentDetailDialog from './ShipmentDetailDialog';
import {
@@ -38,9 +40,7 @@ import {
} from './manifestConfig';
import {
GREEN_PANEL,
SURFACE_BASE,
SURFACE_RAISED,
WHITE,
} from '../../colors';
const cfg = manifestConfig('order');
@@ -119,63 +119,46 @@ class NewShipmentModal extends PureComponent {
}
async handleSubmit() {
const { orders = [], onClose, onSuccess } = this.props;
const { orderId, date, externalNumber, lineAmounts } = this.state;
const order = orders.find((o) => o.id === Number(orderId));
if (!order) return;
const linesToShip = [];
for (const [sku, amtStr] of Object.entries(lineAmounts)) {
const amt = Number(amtStr);
if (amt > 0) {
const orderLine = order.lines?.find((l) => l.sku === sku);
linesToShip.push({
sku,
amount: amt,
name: orderLine?.name ?? '',
pu: orderLine?.pu ?? 1,
net: orderLine?.net ?? 0,
rebate: orderLine?.rebate ?? 0,
taxrate: orderLine?.taxrate ?? 0,
});
}
if (!orderId) {
enqueueSnackbar('Bitte wählen Sie einen Auftrag aus.', { variant: 'warning' });
return;
}
if (linesToShip.length === 0) {
enqueueSnackbar('Mindestens ein Artikel muss eine Liefermenge > 0 haben.', { variant: 'warning' });
const lines = Object.entries(lineAmounts)
.filter(([, amt]) => Number(amt) > 0)
.map(([sku, amt]) => ({ sku, amount: Number(amt) }));
if (lines.length === 0) {
enqueueSnackbar('Mindestens eine Position muss mit Menge > 0 geliefert werden.', { variant: 'warning' });
return;
}
this.setState({ saving: true });
try {
const res = await shipmentApi.create(order.id, {
const res = await shipmentApi.create(orderId, {
date,
externalNumber: externalNumber.trim() || undefined,
lines: linesToShip,
lines,
});
enqueueSnackbar(`Lieferschein ${res.shipment?.number || ''} erfolgreich angelegt!`, { variant: 'success' });
await Promise.all([
orderAutomat.actions.reload(),
shipmentAutomat.actions.reload(),
]);
onSuccess?.(res.shipment);
onClose();
enqueueSnackbar(res.message || 'Lieferschein erfolgreich erstellt.', { variant: 'success' });
void orderAutomat.actions.reload().catch(() => {});
void shipmentAutomat.actions.reload().catch(() => {});
this.props.onSuccess?.(res.shipment);
this.props.onClose?.();
} catch (err) {
notifyError(err);
} finally {
this.setState({ saving: false });
}
}
render() {
const { open, onClose, orders, partiesById } = this.props;
const { open, onClose, orders = [], partiesById = {} } = this.props;
const { orderId, date, externalNumber, lineAmounts, saving } = this.state;
const openOrders = orders.filter((o) => {
const ordQty = Number(o.orderedQuantity) || 0;
const shpQty = Number(o.shippedQuantity) || 0;
return ordQty === 0 || shpQty < ordQty;
});
const currentOrder = orders.find((o) => o.id === Number(orderId));
const lines = Array.isArray(currentOrder?.lines) ? currentOrder.lines : [];
@@ -187,32 +170,29 @@ class NewShipmentModal extends PureComponent {
}
return (
<AppDialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<AppDialog
open={open}
onClose={saving ? undefined : onClose}
title="Neuen Lieferschein erstellen"
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 }}>
<Box sx={{ mb: 2.5, display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
select
label="Auftrag auswählen"
label="Bezugsauftrag 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}`;
{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} — {partyName} ({o.shippedQuantity || 0}/{o.orderedQuantity || 0} geliefert)
{o.number} {custName ? `— ${custName}` : ''} {o.date ? `(${formatDate(o.date)})` : ''}
</MenuItem>
);
})}
@@ -389,56 +369,182 @@ class DeliveryStateMenu extends PureComponent {
}
}
// MARK: <ShipmentRow/>
// Dummy shipment generator with max digits for layout testing and probe measurement
export function createDummyTestShipment() {
const maxOrderNumber = getMaxDocumentNumber('order');
return {
id: 999999,
number: `${maxOrderNumber}-L-99`,
externalNumber: 'REF-999999',
date: '2030-10-30T12:00:00Z',
orderNumber: maxOrderNumber,
customerName: 'Kunde',
positions: 999,
totalQuantity: 999,
packageCount: 9,
carrier: 'DHL',
trackingCode: '00340434161094042578',
state: 'delivered',
};
}
const rowGridSx = {
display: 'grid',
gridTemplateColumns: '170px 105px 130px 1.4fr 120px 160px 110px 48px',
alignItems: 'center',
gridTemplateColumns: 'max-content max-content max-content 1fr max-content max-content max-content auto',
gridTemplateRows: '0px 1fr',
rowGap: 0,
columnGap: 1.5,
alignItems: 'center',
width: '100%',
px: 2,
py: 1.1,
boxSizing: 'border-box',
};
const measuringCellSx = {
visibility: 'hidden',
height: 0,
boxSizing: 'border-box',
pointerEvents: 'none',
textTransform: 'none',
letterSpacing: 'normal',
};
const headerCellSx = {
color: 'text.secondary',
typography: 'caption',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.04em',
userSelect: 'none',
};
function renderInvisibleDummyRow(dummy) {
const d = dummy || createDummyTestShipment();
return (
<>
<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 { shipment, onOpen, onDelete, disabled } = this.props;
if (!shipment) return null;
const { item: shipment, disabled } = this.props;
const dummy = this.props.headerItem || rowContext.headerItem;
// Data-free probe (FillSpace measures the row height with `item` undefined).
if (!shipment) {
if (disabled) {
return (
<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
onClick={() => onOpen(shipment)}
sx={{
p: 0,
borderBottom: '1px solid',
borderColor: 'divider',
bgcolor: WHITE,
'&:hover': {
bgcolor: 'action.hover',
},
}}
divider
sx={{ ...rowGridSx, height: 64, minHeight: 64, maxHeight: 64, px: 2, py: 0 }}
onClick={disabled ? undefined : () => onOpen?.(shipment)}
disabled={disabled}
>
<Box sx={rowGridSx}>
{renderInvisibleDummyRow(dummy)}
{/* 1. Lieferschein-Nr & Ref */}
<Box sx={{ minWidth: 0, overflow: 'hidden' }}>
<Typography variant="body2" sx={{ fontWeight: 700, ...ellipsisSx }}>
<Box sx={{ gridColumn: 1, gridRow: 2, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Typography variant="body1" sx={{ fontWeight: 600, ...ellipsisSx }}>
{shipment.number}
</Typography>
{shipment.externalNumber ? (
<Typography variant="caption" color="text.secondary" sx={ellipsisSx}>
<Typography variant="body2" color="text.secondary" sx={ellipsisSx}>
{numberDisplay(shipment.externalNumber)}
</Typography>
) : null}
</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={{ minWidth: 0, overflow: 'hidden' }}>
<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}
@@ -451,80 +557,68 @@ class ShipmentRow extends PureComponent {
</Box>
{/* 4. Kunde */}
<Typography variant="body2" sx={{ fontWeight: 500, ...ellipsisSx }}>
{shipment.customerName || (shipment.entityId ? `Kunde #${shipment.entityId}` : '–')}
</Typography>
{/* 5. Positionen & Menge */}
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="caption" sx={{ fontWeight: 700 }}>
{shipment.positions || 0} Pos.
</Typography>
<Typography variant="caption" color="text.secondary">
{shipment.totalQuantity || 0} Stk
<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>
{/* 6. Paket & Tracking */}
<Box sx={{ minWidth: 0, overflow: 'hidden' }}>
{shipment.carrier || shipment.trackingCode ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{shipment.carrier && (
{/* 5. Positionen */}
<Box sx={{ gridColumn: 5, gridRow: 2, display: 'flex', justifyContent: 'center' }}>
<Chip
size="small"
variant="outlined"
color="primary"
label={shipment.carrier}
sx={{ height: 20, fontSize: '0.65rem', fontWeight: 600 }}
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' }} />
)}
{shipment.trackingCode && (
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 0.25, cursor: 'pointer' }}
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(shipment.trackingCode);
enqueueSnackbar('Tracking-Code kopiert!', { variant: 'info' });
}}
title="Tracking-Code kopieren"
>
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontWeight: 600, color: 'text.secondary', ...ellipsisSx }}>
<Typography variant="caption" sx={{ fontWeight: 600, fontVariantNumeric: 'tabular-nums', ...ellipsisSx }}>
{shipment.trackingCode}
</Typography>
<ContentCopyIcon sx={{ fontSize: 13, color: 'text.secondary' }} />
</Box>
)}
</Box>
) : shipment.packageCount > 0 ? (
<Typography variant="caption" color="text.secondary">
{shipment.packageCount} {shipment.packageCount === 1 ? 'Paket' : 'Pakete'}
</Typography>
) : (
<Typography variant="caption" color="text.secondary">
{shipment.packageCount ? `${shipment.packageCount} Paket(e)` : 'Keine Pakete'}
–
</Typography>
)}
</Box>
{/* 7. Status */}
<Box>
<Box sx={{ gridColumn: 7, gridRow: 2, display: 'flex', justifyContent: 'center' }}>
<DeliveryStateMenu shipment={shipment} />
</Box>
{/* 8. Aktionen */}
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
{shipment.state === 'new' && (
{/* 8. Aktionen (Löschen) */}
<Box sx={{ gridColumn: 8, gridRow: 2, display: 'flex', justifyContent: 'center' }}>
{canDelete ? (
<IconButton
size="small"
title={`Lieferschein „${shipment.number}“ löschen`}
disabled={disabled}
aria-label={`„${shipment.number}“ löschen`}
disabled={isBusy || disabled}
onClick={(e) => {
e.stopPropagation();
onDelete(shipment);
onDelete?.(shipment);
}}
sx={{ p: 0.5, color: 'text.secondary', '&:hover': { color: 'error.main' } }}
onMouseDown={(e) => e.stopPropagation()}
sx={{ width: 40, height: 40 }}
>
<DeleteOutlinedIcon sx={{ fontSize: 18 }} />
<DeleteOutlinedIcon sx={{ fontSize: 24 }} />
</IconButton>
) : (
<Box sx={{ width: 40, height: 40 }} />
)}
</Box>
</Box>
</ListItemButton>
);
}
@@ -532,12 +626,42 @@ class ShipmentRow extends PureComponent {
// 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 { input, stateFilter, onInputChange, onStateFilterChange, onSeekChange, onCreate, seekMonths } = this.props;
const { inputRef, onCreate } = this.props;
const { input, stateFilter, seekMonths, seekYears } = this.state;
const monthActive = seekMonths > 0;
const monthLabel = !monthActive ? '-1 Monat' : `-${seekMonths} Monat${seekMonths > 1 ? 'e' : ''}`;
const yearActive = seekYears > 0;
const yearLabel = !yearActive ? '-1 Jahr' : `-${seekYears} Jahr${seekYears > 1 ? 'e' : ''}`;
return (
<PanelTileGrid>
<Box
ref={inputRef}
sx={{
gridColumn: { xs: '1 / -1', sm: 'span 2', md: 'span 2' },
display: 'flex',
@@ -546,103 +670,165 @@ class ShipmentSearchField extends PureComponent {
pb: 2,
}}
>
{/* Top row: search & create */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{/* Top row: search field & create button */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<SingleLineTextEdit
sx={{ flex: 1 }}
size="small"
search
clear
placeholder="Lieferschein, Auftrag, Kunde, Paket suchen…"
placeholder="Lieferschein-, Auftrags-, Tracking-Nr. oder Kunde suchen…"
value={input}
onChange={onInputChange}
onChange={(next) => shipmentAutomat.actions.setFilter(next)}
/>
<Button variant="contained" startIcon={<AddIcon />} onClick={onCreate}>
<Button variant="contained" onClick={onCreate}>
Lieferschein erstellen
</Button>
</Box>
{/* Filter Bar */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 1.5 }}>
{/* 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: 1,
gap: 0.75,
px: 1.25,
py: 0.5,
py: 0.25,
borderRadius: 2,
bgcolor: 'background.paper',
boxShadow: 1,
}}
>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary', mr: 0.5 }}>
Status:
</Typography>
<Chip
size="small"
label="Alle"
color={!stateFilter ? 'primary' : 'default'}
variant={!stateFilter ? 'filled' : 'outlined'}
onClick={() => onStateFilterChange('')}
sx={{ fontWeight: 600, cursor: 'pointer' }}
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={() => onStateFilterChange('new')}
sx={{ fontWeight: 600, cursor: 'pointer' }}
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={() => onStateFilterChange('delivered')}
sx={{ fontWeight: 600, cursor: 'pointer' }}
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={() => onStateFilterChange('returned')}
sx={{ fontWeight: 600, cursor: 'pointer' }}
onClick={() => shipmentAutomat.actions.setStateFilter('returned')}
sx={{ fontWeight: 600, cursor: 'pointer', height: 26 }}
/>
</Box>
{/* Date Seek Cutoff */}
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.75, px: 1, py: 0.25, borderRadius: 2, bgcolor: 'background.paper', boxShadow: 1 }}>
{/* 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={seekMonths === 1 ? 'contained' : 'text'}
onClick={() => onSeekChange(seekMonths === 1 ? 0 : 1)}
sx={{ minWidth: 'auto', px: 1, py: 0.25, height: 28, fontSize: '0.75rem', fontWeight: 600, textTransform: 'none' }}
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',
}}
>
-1 Monat
{monthLabel}
</Button>
<Button
size="small"
variant={seekMonths === 12 ? 'contained' : 'text'}
onClick={() => onSeekChange(seekMonths === 12 ? 0 : 12)}
sx={{ minWidth: 'auto', px: 1, py: 0.25, height: 28, fontSize: '0.75rem', fontWeight: 600, textTransform: 'none' }}
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',
}}
>
-1 Jahr
{yearLabel}
</Button>
<Button
size="small"
variant="text"
disabled={seekMonths === 0}
onClick={() => onSeekChange(0)}
sx={{ minWidth: 'auto', px: 1, py: 0.25, height: 28, fontSize: '0.75rem', fontWeight: 600, textTransform: 'none' }}
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>
);
}
@@ -652,22 +838,18 @@ class ShipmentSearchField extends PureComponent {
export class ShipmentList extends PureComponent {
constructor(props) {
super(props);
const s = shipmentAutomat.state;
this.headerItem = createDummyTestShipment();
this.state = {
items: s.items,
total: s.total,
unfilteredTotal: s.unfilteredTotal,
filter: s.filter,
stateFilter: s.stateFilter,
seekMonths: s.seekMonths,
status: s.status,
error: s.error ? backendErrorFrom(s.error) : null,
items: shipmentAutomat.getItems(),
unfilteredTotal: shipmentAutomat.state.unfilteredTotal ?? 0,
filterKey: shipmentAutomat.getFilterKey?.() ?? '',
orders: orderAutomat.state?.items || [],
partiesById: {},
selectedShipment: null,
newShipmentModalOpen: false,
busyId: null,
};
this.searchRef = createRef();
this._cancelled = false;
this.handleRetry = this.handleRetry.bind(this);
this.handleOpenShipment = this.handleOpenShipment.bind(this);
@@ -679,19 +861,20 @@ export class ShipmentList extends PureComponent {
this._cancelled = false;
this.unsub = shipmentAutomat.subscribe(this, (state) => ({
items: state.items,
total: state.total,
unfilteredTotal: state.unfilteredTotal,
filter: state.filter,
stateFilter: state.stateFilter,
seekMonths: state.seekMonths,
status: state.status,
error: state.error ? backendErrorFrom(state.error) : null,
unfilteredTotal: state.unfilteredTotal ?? 0,
filterKey: shipmentAutomat.getFilterKey(state),
}));
this.unsubOrder = orderAutomat.subscribe(this, (state) => ({
orders: state.items || [],
}));
this.unsubNumber = numberAutomat.subscribe(this, () => {
this.headerItem = createDummyTestShipment();
this.forceUpdate();
});
void numberAutomat.actions.ensureLoaded().catch(() => {});
void shipmentAutomat.actions.load().catch(() => {});
void orderAutomat.actions.load().catch(() => {});
void this.loadParties();
@@ -720,6 +903,7 @@ export class ShipmentList extends PureComponent {
this._cancelled = true;
this.unsub?.();
this.unsubOrder?.();
this.unsubNumber?.();
}
async loadParties() {
@@ -746,6 +930,7 @@ export class ShipmentList extends PureComponent {
}
async handleDeleteShipment(sh) {
if (this.state.busyId != null) return;
if (!window.confirm(`Lieferschein „${sh.number}“ wirklich löschen?`)) return;
this.setState({ busyId: sh.id });
try {
@@ -761,13 +946,8 @@ export class ShipmentList extends PureComponent {
render() {
const {
items,
total,
unfilteredTotal,
filter,
stateFilter,
seekMonths,
status,
error,
filterKey,
orders,
partiesById,
selectedShipment,
@@ -775,113 +955,87 @@ export class ShipmentList extends PureComponent {
busyId,
} = this.state;
if (items === null && status === 'failed') {
rowContext.partiesById = partiesById;
rowContext.busyId = busyId;
rowContext.onOpen = this.handleOpenShipment;
rowContext.onDelete = this.handleDeleteShipment;
rowContext.headerItem = this.headerItem;
if (items === null && shipmentAutomat.state.status === 'failed') {
return (
<>
<ShipmentSearchField inputRef={this.searchRef} onCreate={() => this.setState({ newShipmentModalOpen: true })} />
<PanelTileGrid>
<PanelTile wide={3} minHeight={0} color={GREEN_PANEL} sx={{ p: 2 }}>
<BackendError error={error} onRetry={this.handleRetry} />
<BackendError error={backendErrorFrom(shipmentAutomat.state.error)} onRetry={this.handleRetry} />
</PanelTile>
</PanelTileGrid>
</>
);
}
const shipmentList = Array.isArray(items) ? items : [];
return (
<>
<ShipmentSearchField
input={filter}
stateFilter={stateFilter}
seekMonths={seekMonths}
onInputChange={(val) => shipmentAutomat.actions.setFilter(val)}
onStateFilterChange={(st) => shipmentAutomat.actions.setStateFilter(st)}
onSeekChange={(m) => shipmentAutomat.actions.setSeekMonths(m)}
inputRef={this.searchRef}
onCreate={() => this.setState({ newShipmentModalOpen: true })}
/>
<PanelTileGrid>
<PanelTile wide={3} minHeight={480} color={GREEN_PANEL} sx={{ p: 0, position: 'relative' }}>
{/* Table Header */}
<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: 38,
bgcolor: SURFACE_BASE,
height: 36,
minHeight: 36,
px: 2,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary', textTransform: 'uppercase' }}>
Lieferschein / Ref
</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary', textTransform: 'uppercase' }}>
Datum
</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary', textTransform: 'uppercase' }}>
Auftrag
</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary', textTransform: 'uppercase' }}>
Kunde
</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary', textTransform: 'uppercase' }}>
Positionen
</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary', textTransform: 'uppercase' }}>
Paket / Tracking
</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary', textTransform: 'uppercase' }}>
Status
</Typography>
<Box />
{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>
{/* Rows */}
{items === null && status === 'loading' ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress size={36} />
</Box>
) : shipmentList.length === 0 ? (
<Box sx={{ p: 4, textAlign: 'center' }}>
<LocalShippingIcon sx={{ fontSize: 40, color: 'text.secondary', mb: 1 }} />
<Typography variant="body1" sx={{ fontWeight: 600 }}>
Keine Lieferscheine gefunden.
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{filter || stateFilter ? 'Passen Sie die Filter an oder erstellen Sie einen neuen Lieferschein.' : 'Erstellen Sie den ersten Lieferschein aus einem Auftrag.'}
</Typography>
</Box>
) : (
<Box sx={{ maxHeight: 'calc(100vh - 280px)', overflowY: 'auto' }}>
{shipmentList.map((sh) => (
<ShipmentRow
key={sh.id}
shipment={sh}
onOpen={this.handleOpenShipment}
onDelete={this.handleDeleteShipment}
disabled={busyId === sh.id}
}
/>
))}
</Box>
)}
{/* Footer Summary */}
{items === null && (
<Box
sx={{
position: 'absolute',
top: 52,
left: 0,
right: 0,
bottom: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2,
py: 1,
bgcolor: SURFACE_BASE,
borderTop: '1px solid',
borderColor: 'divider',
justifyContent: 'center',
pointerEvents: 'none',
zIndex: 2,
}}
>
<Typography variant="caption" color="text.secondary">
{shipmentList.length} von {total} {total === 1 ? 'Lieferschein' : 'Lieferscheinen'} angezeigt
{unfilteredTotal > total ? ` (gefiltert von ${unfilteredTotal})` : ''}
</Typography>
<CircularProgress size={32} />
</Box>
)}
</PanelTile>
</PanelTileGrid>