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 // #region GET /api/shipment — list
list(params = {}) { list(params = {}) {
const q = new URLSearchParams(); 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.limit != null) q.set('limit', String(params.limit));
if (params.search) q.set('search', String(params.search)); if (params.search) q.set('search', String(params.search));
if (params.state) q.set('state', String(params.state)); if (params.state) q.set('state', String(params.state));

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { PureComponent } from 'react'; import { PureComponent, createRef } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip'; import Chip from '@mui/material/Chip';
@@ -9,9 +9,8 @@ import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import MenuItem from '@mui/material/MenuItem'; import MenuItem from '@mui/material/MenuItem';
import Stack from '@mui/material/Stack'; 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 LocalShippingIcon from '@mui/icons-material/LocalShipping';
import AddIcon from '@mui/icons-material/Add';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import CloseIcon from '@mui/icons-material/Close'; 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 Menu from '@mui/material/Menu';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import FillSpace from 'dashgui/FillSpace';
import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import BackendError from '../BackendError'; import BackendError from '../BackendError';
import AppDialog from '../AppDialog'; import AppDialog from '../AppDialog';
import shipmentAutomat from '../../automats/shipmentAutomat'; import shipmentAutomat from '../../automats/shipmentAutomat';
import orderAutomat from '../../automats/orderAutomat'; import orderAutomat from '../../automats/orderAutomat';
import numberAutomat from '../../automats/numberAutomat';
import { shipment as shipmentApi } from '../../api'; import { shipment as shipmentApi } from '../../api';
import { backendErrorFrom } from '../../utils/backendError'; import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
import { getMaxDocumentNumber } from '../../utils/documentNumber';
import withNavigate from '../withNavigate'; import withNavigate from '../withNavigate';
import ShipmentDetailDialog from './ShipmentDetailDialog'; import ShipmentDetailDialog from './ShipmentDetailDialog';
import { import {
@@ -38,9 +40,7 @@ import {
} from './manifestConfig'; } from './manifestConfig';
import { import {
GREEN_PANEL, GREEN_PANEL,
SURFACE_BASE,
SURFACE_RAISED, SURFACE_RAISED,
WHITE,
} from '../../colors'; } from '../../colors';
const cfg = manifestConfig('order'); const cfg = manifestConfig('order');
@@ -119,63 +119,46 @@ class NewShipmentModal extends PureComponent {
} }
async handleSubmit() { async handleSubmit() {
const { orders = [], onClose, onSuccess } = this.props;
const { orderId, date, externalNumber, lineAmounts } = this.state; const { orderId, date, externalNumber, lineAmounts } = this.state;
const order = orders.find((o) => o.id === Number(orderId)); if (!orderId) {
if (!order) return; enqueueSnackbar('Bitte wählen Sie einen Auftrag aus.', { variant: 'warning' });
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) { const lines = Object.entries(lineAmounts)
enqueueSnackbar('Mindestens ein Artikel muss eine Liefermenge > 0 haben.', { variant: 'warning' }); .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; return;
} }
this.setState({ saving: true }); this.setState({ saving: true });
try { try {
const res = await shipmentApi.create(order.id, { const res = await shipmentApi.create(orderId, {
date, date,
externalNumber: externalNumber.trim() || undefined, externalNumber: externalNumber.trim() || undefined,
lines: linesToShip, lines,
}); });
enqueueSnackbar(`Lieferschein ${res.shipment?.number || ''} erfolgreich angelegt!`, { variant: 'success' });
await Promise.all([ enqueueSnackbar(res.message || 'Lieferschein erfolgreich erstellt.', { variant: 'success' });
orderAutomat.actions.reload(), void orderAutomat.actions.reload().catch(() => {});
shipmentAutomat.actions.reload(), void shipmentAutomat.actions.reload().catch(() => {});
]);
onSuccess?.(res.shipment); this.props.onSuccess?.(res.shipment);
onClose(); this.props.onClose?.();
} catch (err) { } catch (err) {
notifyError(err); notifyError(err);
} finally {
this.setState({ saving: false }); this.setState({ saving: false });
} }
} }
render() { render() {
const { open, onClose, orders, partiesById } = this.props; const { open, onClose, orders = [], partiesById = {} } = this.props;
const { orderId, date, externalNumber, lineAmounts, saving } = this.state; 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 currentOrder = orders.find((o) => o.id === Number(orderId));
const lines = Array.isArray(currentOrder?.lines) ? currentOrder.lines : []; const lines = Array.isArray(currentOrder?.lines) ? currentOrder.lines : [];
@@ -187,32 +170,29 @@ class NewShipmentModal extends PureComponent {
} }
return ( 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={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pb: 2 }}> <Box sx={{ mb: 2.5, display: 'flex', flexDirection: 'column', gap: 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 <TextField
select select
label="Auftrag auswählen" label="Bezugsauftrag auswählen"
size="small" size="small"
value={orderId} value={orderId}
onChange={(e) => this.handleOrderChange(e.target.value)} onChange={(e) => this.handleOrderChange(e.target.value)}
fullWidth fullWidth
> >
{openOrders.map((o) => { {orders.map((o) => {
const party = partiesById[o.entityId]; const customer = partiesById[o.entityId];
const partyName = partyDisplayName('order', party) || `Kunde #${o.entityId}`; const custName = partyDisplayName('order', customer) || (o.entityId ? `Kunde #${o.entityId}` : '');
return ( return (
<MenuItem key={o.id} value={o.id}> <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> </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 = { const rowGridSx = {
display: 'grid', display: 'grid',
gridTemplateColumns: '170px 105px 130px 1.4fr 120px 160px 110px 48px', gridTemplateColumns: 'max-content max-content max-content 1fr max-content max-content max-content auto',
alignItems: 'center', gridTemplateRows: '0px 1fr',
rowGap: 0,
columnGap: 1.5, columnGap: 1.5,
alignItems: 'center',
width: '100%', width: '100%',
px: 2,
py: 1.1,
boxSizing: 'border-box', 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 { class ShipmentRow extends PureComponent {
partyName(partyId) {
if (partyId == null) return '–';
const party = rowContext.partiesById[partyId];
return partyDisplayName('order', party) || `Kunde #${partyId}`;
}
render() { render() {
const { shipment, onOpen, onDelete, disabled } = this.props; const { item: shipment, disabled } = this.props;
if (!shipment) return null; 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 ( return (
<ListItemButton <ListItemButton
onClick={() => onOpen(shipment)} divider
sx={{ sx={{ ...rowGridSx, height: 64, minHeight: 64, maxHeight: 64, px: 2, py: 0 }}
p: 0, onClick={disabled ? undefined : () => onOpen?.(shipment)}
borderBottom: '1px solid', disabled={disabled}
borderColor: 'divider',
bgcolor: WHITE,
'&:hover': {
bgcolor: 'action.hover',
},
}}
> >
<Box sx={rowGridSx}> {renderInvisibleDummyRow(dummy)}
{/* 1. Lieferschein-Nr & Ref */} {/* 1. Lieferschein-Nr & Ref */}
<Box sx={{ minWidth: 0, overflow: 'hidden' }}> <Box sx={{ gridColumn: 1, gridRow: 2, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Typography variant="body2" sx={{ fontWeight: 700, ...ellipsisSx }}> <Typography variant="body1" sx={{ fontWeight: 600, ...ellipsisSx }}>
{shipment.number} {shipment.number}
</Typography> </Typography>
{shipment.externalNumber ? ( <Typography variant="body2" color="text.secondary" sx={ellipsisSx}>
<Typography variant="caption" color="text.secondary" sx={ellipsisSx}>
{numberDisplay(shipment.externalNumber)} {numberDisplay(shipment.externalNumber)}
</Typography> </Typography>
) : null}
</Box> </Box>
{/* 2. Datum */} {/* 2. Datum */}
<Box sx={{ gridColumn: 2, gridRow: 2, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Typography variant="body2" color="text.secondary" sx={ellipsisSx}> <Typography variant="body2" color="text.secondary" sx={ellipsisSx}>
{shipment.date ? formatDate(shipment.date) : '–'} {shipment.date ? formatDate(shipment.date) : '–'}
</Typography> </Typography>
</Box>
{/* 3. Bezugsauftrag */} {/* 3. Bezugsauftrag */}
<Box sx={{ minWidth: 0, overflow: 'hidden' }}> <Box sx={{ gridColumn: 3, gridRow: 2, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{shipment.orderNumber ? ( {shipment.orderNumber ? (
<Typography variant="body2" sx={{ fontWeight: 600, color: 'primary.main', ...ellipsisSx }}> <Typography variant="body2" sx={{ fontWeight: 600, color: 'primary.main', ...ellipsisSx }}>
{shipment.orderNumber} {shipment.orderNumber}
@@ -451,80 +557,68 @@ class ShipmentRow extends PureComponent {
</Box> </Box>
{/* 4. Kunde */} {/* 4. Kunde */}
<Typography variant="body2" sx={{ fontWeight: 500, ...ellipsisSx }}> <Box sx={{ gridColumn: 4, gridRow: 2, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{shipment.customerName || (shipment.entityId ? `Kunde #${shipment.entityId}` : '–')} <Typography variant="body1" sx={{ fontWeight: 500, ...ellipsisSx }}>
</Typography> {shipment.customerName || this.partyName(shipment.entityId)}
{/* 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
</Typography> </Typography>
</Box> </Box>
{/* 6. Paket & Tracking */} {/* 5. Positionen */}
<Box sx={{ minWidth: 0, overflow: 'hidden' }}> <Box sx={{ gridColumn: 5, gridRow: 2, display: 'flex', justifyContent: 'center' }}>
{shipment.carrier || shipment.trackingCode ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{shipment.carrier && (
<Chip <Chip
size="small" size="small"
variant="outlined" label={shipment.positions || 0}
color="primary" title={`${shipment.positions || 0} Positionen (${shipment.totalQuantity || 0} Stk.)`}
label={shipment.carrier} sx={{ height: 24, '& .MuiChip-label': { px: 0.5 } }}
sx={{ height: 20, fontSize: '0.65rem', fontWeight: 600 }}
/> />
</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 && ( <Typography variant="caption" sx={{ fontWeight: 600, fontVariantNumeric: 'tabular-nums', ...ellipsisSx }}>
<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 }}>
{shipment.trackingCode} {shipment.trackingCode}
</Typography> </Typography>
<ContentCopyIcon sx={{ fontSize: 13, color: 'text.secondary' }} />
</Box>
)}
</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"> <Typography variant="caption" color="text.secondary">
{shipment.packageCount ? `${shipment.packageCount} Paket(e)` : 'Keine Pakete'} –
</Typography> </Typography>
)} )}
</Box> </Box>
{/* 7. Status */} {/* 7. Status */}
<Box> <Box sx={{ gridColumn: 7, gridRow: 2, display: 'flex', justifyContent: 'center' }}>
<DeliveryStateMenu shipment={shipment} /> <DeliveryStateMenu shipment={shipment} />
</Box> </Box>
{/* 8. Aktionen */} {/* 8. Aktionen (Löschen) */}
<Box sx={{ display: 'flex', justifyContent: 'center' }}> <Box sx={{ gridColumn: 8, gridRow: 2, display: 'flex', justifyContent: 'center' }}>
{shipment.state === 'new' && ( {canDelete ? (
<IconButton <IconButton
size="small" aria-label={`„${shipment.number}“ löschen`}
title={`Lieferschein „${shipment.number}“ löschen`} disabled={isBusy || disabled}
disabled={disabled}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); 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> </IconButton>
) : (
<Box sx={{ width: 40, height: 40 }} />
)} )}
</Box> </Box>
</Box>
</ListItemButton> </ListItemButton>
); );
} }
@@ -532,12 +626,42 @@ class ShipmentRow extends PureComponent {
// MARK: <ShipmentSearchField/> // MARK: <ShipmentSearchField/>
class ShipmentSearchField extends PureComponent { 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() { 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 ( return (
<PanelTileGrid> <PanelTileGrid>
<Box <Box
ref={inputRef}
sx={{ sx={{
gridColumn: { xs: '1 / -1', sm: 'span 2', md: 'span 2' }, gridColumn: { xs: '1 / -1', sm: 'span 2', md: 'span 2' },
display: 'flex', display: 'flex',
@@ -546,103 +670,165 @@ class ShipmentSearchField extends PureComponent {
pb: 2, pb: 2,
}} }}
> >
{/* Top row: search & create */} {/* Top row: search field & create button */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<SingleLineTextEdit <SingleLineTextEdit
sx={{ flex: 1 }} sx={{ flex: 1 }}
size="small" size="small"
search search
clear clear
placeholder="Lieferschein, Auftrag, Kunde, Paket suchen…" placeholder="Lieferschein-, Auftrags-, Tracking-Nr. oder Kunde suchen…"
value={input} value={input}
onChange={onInputChange} onChange={(next) => shipmentAutomat.actions.setFilter(next)}
/> />
<Button variant="contained" startIcon={<AddIcon />} onClick={onCreate}> <Button variant="contained" onClick={onCreate}>
Lieferschein erstellen Lieferschein erstellen
</Button> </Button>
</Box> </Box>
{/* Filter Bar */} {/* Filter Bar: Status chips & Negativeseek (-1 Month, -1 Year, Reset) */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 1.5 }}> <Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 1.5,
}}
>
{/* Left: Status chips */}
<Box <Box
sx={{ sx={{
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
gap: 1, gap: 0.75,
px: 1.25, px: 1.25,
py: 0.5, py: 0.25,
borderRadius: 2, borderRadius: 2,
bgcolor: 'background.paper', bgcolor: 'background.paper',
boxShadow: 1, boxShadow: 1,
}} }}
> >
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary', mr: 0.5 }}>
Status:
</Typography>
<Chip <Chip
size="small" size="small"
label="Alle" label="Alle"
color={!stateFilter ? 'primary' : 'default'} color={!stateFilter ? 'primary' : 'default'}
variant={!stateFilter ? 'filled' : 'outlined'} variant={!stateFilter ? 'filled' : 'outlined'}
onClick={() => onStateFilterChange('')} onClick={() => shipmentAutomat.actions.setStateFilter('')}
sx={{ fontWeight: 600, cursor: 'pointer' }} sx={{ fontWeight: 600, cursor: 'pointer', height: 26 }}
/> />
<Chip <Chip
size="small" size="small"
label="Neu" label="Neu"
color={stateFilter === 'new' ? 'info' : 'default'} color={stateFilter === 'new' ? 'info' : 'default'}
variant={stateFilter === 'new' ? 'filled' : 'outlined'} variant={stateFilter === 'new' ? 'filled' : 'outlined'}
onClick={() => onStateFilterChange('new')} onClick={() => shipmentAutomat.actions.setStateFilter('new')}
sx={{ fontWeight: 600, cursor: 'pointer' }} sx={{ fontWeight: 600, cursor: 'pointer', height: 26 }}
/> />
<Chip <Chip
size="small" size="small"
label="Geliefert" label="Geliefert"
color={stateFilter === 'delivered' ? 'success' : 'default'} color={stateFilter === 'delivered' ? 'success' : 'default'}
variant={stateFilter === 'delivered' ? 'filled' : 'outlined'} variant={stateFilter === 'delivered' ? 'filled' : 'outlined'}
onClick={() => onStateFilterChange('delivered')} onClick={() => shipmentAutomat.actions.setStateFilter('delivered')}
sx={{ fontWeight: 600, cursor: 'pointer' }} sx={{ fontWeight: 600, cursor: 'pointer', height: 26 }}
/> />
<Chip <Chip
size="small" size="small"
label="Retoure" label="Retoure"
color={stateFilter === 'returned' ? 'error' : 'default'} color={stateFilter === 'returned' ? 'error' : 'default'}
variant={stateFilter === 'returned' ? 'filled' : 'outlined'} variant={stateFilter === 'returned' ? 'filled' : 'outlined'}
onClick={() => onStateFilterChange('returned')} onClick={() => shipmentAutomat.actions.setStateFilter('returned')}
sx={{ fontWeight: 600, cursor: 'pointer' }} sx={{ fontWeight: 600, cursor: 'pointer', height: 26 }}
/> />
</Box> </Box>
{/* Date Seek Cutoff */} {/* Right: Negativeseek controls (-1 Month, -1 Year, Reset) */}
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.75, px: 1, py: 0.25, borderRadius: 2, bgcolor: 'background.paper', boxShadow: 1 }}> <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 <Button
size="small" size="small"
variant={seekMonths === 1 ? 'contained' : 'text'} variant={monthActive ? 'contained' : 'outlined'}
onClick={() => onSeekChange(seekMonths === 1 ? 0 : 1)} color={monthActive ? 'primary' : 'inherit'}
sx={{ minWidth: 'auto', px: 1, py: 0.25, height: 28, fontSize: '0.75rem', fontWeight: 600, textTransform: 'none' }} 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>
<Button <Button
size="small" size="small"
variant={seekMonths === 12 ? 'contained' : 'text'} variant={yearActive ? 'contained' : 'outlined'}
onClick={() => onSeekChange(seekMonths === 12 ? 0 : 12)} color={yearActive ? 'primary' : 'inherit'}
sx={{ minWidth: 'auto', px: 1, py: 0.25, height: 28, fontSize: '0.75rem', fontWeight: 600, textTransform: 'none' }} 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>
<Button <Button
size="small" size="small"
variant="text" variant="text"
disabled={seekMonths === 0} color="inherit"
onClick={() => onSeekChange(0)} disabled={!monthActive && !yearActive}
sx={{ minWidth: 'auto', px: 1, py: 0.25, height: 28, fontSize: '0.75rem', fontWeight: 600, textTransform: 'none' }} onClick={() => shipmentAutomat.actions.resetNegativeSeek()}
sx={{
minWidth: 'auto',
px: 1,
py: 0.25,
height: 28,
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'none',
}}
> >
Reset Reset
</Button> </Button>
</Box> </Box>
</Box> </Box>
</Box> </Box>
</Box>
</PanelTileGrid> </PanelTileGrid>
); );
} }
@@ -652,22 +838,18 @@ class ShipmentSearchField extends PureComponent {
export class ShipmentList extends PureComponent { export class ShipmentList extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
const s = shipmentAutomat.state; this.headerItem = createDummyTestShipment();
this.state = { this.state = {
items: s.items, items: shipmentAutomat.getItems(),
total: s.total, unfilteredTotal: shipmentAutomat.state.unfilteredTotal ?? 0,
unfilteredTotal: s.unfilteredTotal, filterKey: shipmentAutomat.getFilterKey?.() ?? '',
filter: s.filter,
stateFilter: s.stateFilter,
seekMonths: s.seekMonths,
status: s.status,
error: s.error ? backendErrorFrom(s.error) : null,
orders: orderAutomat.state?.items || [], orders: orderAutomat.state?.items || [],
partiesById: {}, partiesById: {},
selectedShipment: null, selectedShipment: null,
newShipmentModalOpen: false, newShipmentModalOpen: false,
busyId: null, busyId: null,
}; };
this.searchRef = createRef();
this._cancelled = false; this._cancelled = false;
this.handleRetry = this.handleRetry.bind(this); this.handleRetry = this.handleRetry.bind(this);
this.handleOpenShipment = this.handleOpenShipment.bind(this); this.handleOpenShipment = this.handleOpenShipment.bind(this);
@@ -679,19 +861,20 @@ export class ShipmentList extends PureComponent {
this._cancelled = false; this._cancelled = false;
this.unsub = shipmentAutomat.subscribe(this, (state) => ({ this.unsub = shipmentAutomat.subscribe(this, (state) => ({
items: state.items, items: state.items,
total: state.total, unfilteredTotal: state.unfilteredTotal ?? 0,
unfilteredTotal: state.unfilteredTotal, filterKey: shipmentAutomat.getFilterKey(state),
filter: state.filter,
stateFilter: state.stateFilter,
seekMonths: state.seekMonths,
status: state.status,
error: state.error ? backendErrorFrom(state.error) : null,
})); }));
this.unsubOrder = orderAutomat.subscribe(this, (state) => ({ this.unsubOrder = orderAutomat.subscribe(this, (state) => ({
orders: state.items || [], orders: state.items || [],
})); }));
this.unsubNumber = numberAutomat.subscribe(this, () => {
this.headerItem = createDummyTestShipment();
this.forceUpdate();
});
void numberAutomat.actions.ensureLoaded().catch(() => {});
void shipmentAutomat.actions.load().catch(() => {}); void shipmentAutomat.actions.load().catch(() => {});
void orderAutomat.actions.load().catch(() => {}); void orderAutomat.actions.load().catch(() => {});
void this.loadParties(); void this.loadParties();
@@ -720,6 +903,7 @@ export class ShipmentList extends PureComponent {
this._cancelled = true; this._cancelled = true;
this.unsub?.(); this.unsub?.();
this.unsubOrder?.(); this.unsubOrder?.();
this.unsubNumber?.();
} }
async loadParties() { async loadParties() {
@@ -746,6 +930,7 @@ export class ShipmentList extends PureComponent {
} }
async handleDeleteShipment(sh) { async handleDeleteShipment(sh) {
if (this.state.busyId != null) return;
if (!window.confirm(`Lieferschein „${sh.number}“ wirklich löschen?`)) return; if (!window.confirm(`Lieferschein „${sh.number}“ wirklich löschen?`)) return;
this.setState({ busyId: sh.id }); this.setState({ busyId: sh.id });
try { try {
@@ -761,13 +946,8 @@ export class ShipmentList extends PureComponent {
render() { render() {
const { const {
items, items,
total,
unfilteredTotal, unfilteredTotal,
filter, filterKey,
stateFilter,
seekMonths,
status,
error,
orders, orders,
partiesById, partiesById,
selectedShipment, selectedShipment,
@@ -775,113 +955,87 @@ export class ShipmentList extends PureComponent {
busyId, busyId,
} = this.state; } = 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 ( return (
<>
<ShipmentSearchField inputRef={this.searchRef} onCreate={() => this.setState({ newShipmentModalOpen: true })} />
<PanelTileGrid> <PanelTileGrid>
<PanelTile wide={3} minHeight={0} color={GREEN_PANEL} sx={{ p: 2 }}> <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> </PanelTile>
</PanelTileGrid> </PanelTileGrid>
</>
); );
} }
const shipmentList = Array.isArray(items) ? items : [];
return ( return (
<> <>
<ShipmentSearchField <ShipmentSearchField
input={filter} inputRef={this.searchRef}
stateFilter={stateFilter}
seekMonths={seekMonths}
onInputChange={(val) => shipmentAutomat.actions.setFilter(val)}
onStateFilterChange={(st) => shipmentAutomat.actions.setStateFilter(st)}
onSeekChange={(m) => shipmentAutomat.actions.setSeekMonths(m)}
onCreate={() => this.setState({ newShipmentModalOpen: true })} onCreate={() => this.setState({ newShipmentModalOpen: true })}
/> />
<PanelTileGrid> <PanelTileGrid>
<PanelTile wide={3} minHeight={480} color={GREEN_PANEL} sx={{ p: 0, position: 'relative' }}> <PanelTile wide={3} minHeight={0} color={GREEN_PANEL} sx={{ pb: 0, position: 'relative' }}>
{/* Table Header */} <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 <Box
sx={{ sx={{
...rowGridSx, ...rowGridSx,
height: 38, height: 36,
bgcolor: SURFACE_BASE, minHeight: 36,
px: 2,
borderBottom: '1px solid', borderBottom: '1px solid',
borderColor: 'divider', borderColor: 'divider',
}} }}
> >
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary', textTransform: 'uppercase' }}> {renderInvisibleDummyRow(this.headerItem)}
Lieferschein / Ref <Box sx={{ gridColumn: 1, gridRow: 2, ...headerCellSx }}>Lieferschein / Ref</Box>
</Typography> <Box sx={{ gridColumn: 2, gridRow: 2, ...headerCellSx }}>Datum</Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary', textTransform: 'uppercase' }}> <Box sx={{ gridColumn: 3, gridRow: 2, ...headerCellSx }}>Auftrag</Box>
Datum <Box sx={{ gridColumn: 4, gridRow: 2, ...headerCellSx }}>Kunde</Box>
</Typography> <Box sx={{ gridColumn: 5, gridRow: 2, display: 'flex', justifyContent: 'center', textAlign: 'center', ...headerCellSx }}>Pos.</Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary', textTransform: 'uppercase' }}> <Box sx={{ gridColumn: 6, gridRow: 2, ...headerCellSx }}>Paket / Tracking</Box>
Auftrag <Box sx={{ gridColumn: 7, gridRow: 2, display: 'flex', justifyContent: 'center', textAlign: 'center', ...headerCellSx }}>Status</Box>
</Typography> <Box sx={{ gridColumn: 8, gridRow: 2, ...headerCellSx }} />
<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 />
</Box> </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}
/> />
))} {items === null && (
</Box>
)}
{/* Footer Summary */}
<Box <Box
sx={{ sx={{
position: 'absolute',
top: 52,
left: 0,
right: 0,
bottom: 32,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'center',
px: 2, pointerEvents: 'none',
py: 1, zIndex: 2,
bgcolor: SURFACE_BASE,
borderTop: '1px solid',
borderColor: 'divider',
}} }}
> >
<Typography variant="caption" color="text.secondary"> <CircularProgress size={32} />
{shipmentList.length} von {total} {total === 1 ? 'Lieferschein' : 'Lieferscheinen'} angezeigt
{unfilteredTotal > total ? ` (gefiltert von ${unfilteredTotal})` : ''}
</Typography>
</Box> </Box>
)}
</PanelTile> </PanelTile>
</PanelTileGrid> </PanelTileGrid>