u
This commit is contained in:
@@ -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));
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -215,6 +232,21 @@ const actions = {
|
|||||||
|
|
||||||
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) => {
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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"> </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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user