Files
dashfront/src/components/manifest/OrderShipmentDialog.jsx
2026-10-21 11:54:46 +02:00

651 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { PureComponent } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Checkbox from '@mui/material/Checkbox';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import Alert from '@mui/material/Alert';
import CloseIcon from '@mui/icons-material/Close';
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import { enqueueSnackbar } from 'notistack';
import AppDialog from '../AppDialog';
import { order as orderApi } from '../../api';
import { notifyError } from '../../utils/notify';
import { formatStock } from '../article/searchFormat';
import artikelAutomat, { getArticle } from '../../automats/artikelAutomat';
import { todayInputValue } from './manifestConfig';
// MARK: helper to sum already shipped quantities per SKU, orderLineId, and line name across shipments
export function computeShippedSummary(shipments) {
const bySku = {};
const byOrderLineId = {};
const byName = {};
for (const sh of shipments || []) {
for (const sl of sh.lines || []) {
const amt = Number(sl.amount) || 0;
if (sl.sku) {
const k = String(sl.sku).trim();
bySku[k] = (bySku[k] || 0) + amt;
}
if (sl.orderLineId != null) {
byOrderLineId[sl.orderLineId] = (byOrderLineId[sl.orderLineId] || 0) + amt;
}
if (sl.name) {
const n = String(sl.name).trim().toLowerCase();
byName[n] = (byName[n] || 0) + amt;
}
}
}
return { bySku, byOrderLineId, byName };
}
function getItemKey(line, index) {
if (line.sku && String(line.sku).trim()) {
return `sku:${String(line.sku).trim()}`;
}
if (line.id != null) {
return `id:${line.id}`;
}
return `idx:${index}`;
}
// MARK: helper to determine next shipment number preview
export function getNextShipmentNumber(orderNumber, shipments = []) {
if (!orderNumber) return '–';
let maxIdx = 0;
for (const s of shipments || []) {
const num = String(s.number ?? '').trim();
const match = num.match(/-L-(\d+)$/i) || num.match(/-(\d+)$/);
if (match) {
const parsed = parseInt(match[1], 10);
if (!Number.isNaN(parsed) && parsed > maxIdx) {
maxIdx = parsed;
}
}
}
const nextIdx = maxIdx > 0 ? maxIdx + 1 : ((shipments?.length || 0) + 1);
return `${orderNumber}-L-${nextIdx}`;
}
/**
* Dialog for selecting items and quantities to create a shipment from a sales order.
* Calls POST /api/order/:id/shipment.
*/
export default class OrderShipmentDialog extends PureComponent {
constructor(props) {
super(props);
const { initialAmounts, initialSelected } = this.calculateInitialState(props);
this.state = {
date: todayInputValue(),
externalNumber: '',
lineAmounts: initialAmounts,
selectedSkus: initialSelected,
saving: false,
error: null,
};
this.handleAmountChange = this.handleAmountChange.bind(this);
this.handleToggleItem = this.handleToggleItem.bind(this);
this.handleToggleSku = this.handleToggleItem; // Alias for backward compatibility
this.handleToggleAll = this.handleToggleAll.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleShipAll = this.handleShipAll.bind(this);
if (props.open) {
this.ensureArticlesLoaded(props.lines);
}
}
componentDidMount() {
this.unsubArticle = artikelAutomat.subscribe(() => {
this.forceUpdate();
});
if (this.props.open) {
this.ensureArticlesLoaded(this.props.lines);
}
}
componentWillUnmount() {
this.unsubArticle?.();
}
componentDidUpdate(prevProps) {
if (!prevProps.open && this.props.open) {
const { initialAmounts, initialSelected } = this.calculateInitialState(this.props);
this.setState({
date: todayInputValue(),
externalNumber: '',
lineAmounts: initialAmounts,
selectedSkus: initialSelected,
saving: false,
error: null,
});
this.ensureArticlesLoaded(this.props.lines);
} else if (this.props.open && prevProps.lines !== this.props.lines) {
this.ensureArticlesLoaded(this.props.lines);
}
}
ensureArticlesLoaded(lines) {
if (!Array.isArray(lines)) return;
for (const l of lines) {
if (l?.sku && !getArticle(l.sku)) {
artikelAutomat.actions.loadArticle(l.sku).catch(() => {});
}
}
}
calculateInitialState(props) {
const { bySku, byOrderLineId, byName } = computeShippedSummary(props.shipments);
const remainingShippedByName = { ...byName };
const initialAmounts = {};
const initialSelected = new Set();
(props.lines || []).forEach((l, idx) => {
const isShippable = l.shippable !== false;
const key = getItemKey(l, idx);
const ordered = Number(l.amount) || 0;
const sku = l.sku ? String(l.sku).trim() : null;
const orderLineId = l.id != null ? l.id : null;
let shipped = 0;
if (sku) {
shipped = bySku[sku] || 0;
} else if (orderLineId != null && byOrderLineId[orderLineId] != null) {
shipped = byOrderLineId[orderLineId];
} else if (l.name) {
const n = String(l.name).trim().toLowerCase();
const pool = remainingShippedByName[n] || 0;
const consumed = Math.min(ordered, pool);
shipped = consumed;
remainingShippedByName[n] = pool - consumed;
}
const rem = isShippable ? Math.max(0, ordered - shipped) : 0;
if (isShippable && rem > 0) {
initialAmounts[key] = String(rem);
initialSelected.add(key);
} else {
initialAmounts[key] = '0';
}
});
return { initialAmounts, initialSelected };
}
getItems() {
const { lines = [], shipments = [], orderId } = this.props;
const { lineAmounts, selectedSkus } = this.state;
const currentOrderId = orderId != null ? Number(orderId) : null;
const { bySku, byOrderLineId, byName } = computeShippedSummary(shipments);
const remainingShippedByName = { ...byName };
return (lines || []).map((line, idx) => {
const itemKey = getItemKey(line, idx);
const isShippable = line.shippable !== false;
const ordered = Number(line.amount) || 0;
const sku = line.sku ? String(line.sku).trim() : null;
const orderLineId = line.id != null ? line.id : null;
let shipped = 0;
if (sku) {
shipped = bySku[sku] || 0;
} else if (orderLineId != null && byOrderLineId[orderLineId] != null) {
shipped = byOrderLineId[orderLineId];
} else if (line.name) {
const n = String(line.name).trim().toLowerCase();
const pool = remainingShippedByName[n] || 0;
const consumed = Math.min(ordered, pool);
shipped = consumed;
remainingShippedByName[n] = pool - consumed;
}
const remaining = isShippable ? Math.max(0, ordered - shipped) : 0;
const isSelected = selectedSkus.has(itemKey);
const shipAmount = lineAmounts[itemKey] ?? (remaining > 0 ? String(remaining) : '0');
let cached = null;
let isTracked = null;
let stockVal = null;
let availableVal = null;
let reservedTitle = undefined;
let stockTitle = undefined;
if (sku) {
cached = getArticle(sku);
const rawWarehouses = Array.isArray(cached?.warehouses) ? cached.warehouses : [];
isTracked = cached ? (cached.track_stock ?? cached.trackStock ?? (rawWarehouses.length > 0)) : null;
stockVal = cached == null || isTracked === false
? null
: (cached.stock != null && !Number.isNaN(Number(cached.stock))
? Number(cached.stock)
: (rawWarehouses.length > 0 || isTracked
? rawWarehouses.reduce((sum, wh) => sum + (Number(wh.stock) || 0), 0)
: null));
const undeliveredOrders = Array.isArray(cached?.undeliveredOrders) ? cached.undeliveredOrders : [];
const otherOrders = currentOrderId != null
? undeliveredOrders.filter((ord) => Number(ord.orderId) !== currentOrderId)
: undeliveredOrders;
const reservedInOtherOrders = otherOrders.reduce((sum, ord) => sum + (Number(ord.amount) || 0), 0);
availableVal = cached == null || isTracked === false
? null
: (stockVal != null ? stockVal - reservedInOtherOrders : null);
reservedTitle = isTracked !== false && reservedInOtherOrders > 0
? `Bereits in anderen Aufträgen reserviert:\n${otherOrders
.map((ord) => `• ${ord.orderNumber || (ord.orderId ? `Auftrag #${ord.orderId}` : 'Auftrag')}: ${formatStock(ord.amount)}${ord.customerName ? ` (${ord.customerName})` : ''}`)
.join('\n')}`
: undefined;
stockTitle = isTracked !== false && rawWarehouses.length > 1
? `Lagerbestände:\n${rawWarehouses
.map((wh) => `• ${wh.name || 'Lager'}: ${formatStock(wh.stock)}`)
.join('\n')}`
: undefined;
}
return {
...line,
itemKey,
sku,
orderLineId,
isShippable,
ordered,
shipped,
remaining,
isSelected,
shipAmount,
cached,
isTracked,
stockVal,
availableVal,
reservedTitle,
stockTitle,
};
});
}
handleToggleItem(itemKey, remaining) {
this.setState((prev) => {
const nextSelected = new Set(prev.selectedSkus);
const nextAmounts = { ...prev.lineAmounts };
if (nextSelected.has(itemKey)) {
nextSelected.delete(itemKey);
} else {
nextSelected.add(itemKey);
if (!Number(nextAmounts[itemKey]) || Number(nextAmounts[itemKey]) <= 0) {
nextAmounts[itemKey] = String(remaining);
}
}
return { selectedSkus: nextSelected, lineAmounts: nextAmounts };
});
}
handleToggleAll(openItems) {
this.setState((prev) => {
const allSelected = openItems.length > 0 && openItems.every((it) => prev.selectedSkus.has(it.itemKey));
const nextSelected = new Set();
const nextAmounts = { ...prev.lineAmounts };
if (!allSelected) {
for (const it of openItems) {
nextSelected.add(it.itemKey);
if (!Number(nextAmounts[it.itemKey]) || Number(nextAmounts[it.itemKey]) <= 0) {
nextAmounts[it.itemKey] = String(it.remaining);
}
}
}
return { selectedSkus: nextSelected, lineAmounts: nextAmounts };
});
}
handleAmountChange(itemKey, val) {
this.setState((prev) => {
const nextAmounts = { ...prev.lineAmounts, [itemKey]: val };
const nextSelected = new Set(prev.selectedSkus);
const num = Number(val);
if (num > 0) {
nextSelected.add(itemKey);
} else {
nextSelected.delete(itemKey);
}
return { lineAmounts: nextAmounts, selectedSkus: nextSelected };
});
}
// MARK: calls orderApi.createShipment from ./api/index.js
// #region POST /api/order/:id/shipment — submit selected lines
async handleSubmit(e) {
e?.preventDefault?.();
const { orderId, onClose, onSuccess } = this.props;
const { date, externalNumber, lineAmounts, selectedSkus, saving } = this.state;
if (saving || !orderId) return;
const items = this.getItems();
const linesToShip = [];
for (const it of items) {
if (!it.isShippable || !selectedSkus.has(it.itemKey)) continue;
const amount = Number(lineAmounts[it.itemKey]);
if (amount > 0) {
if (it.sku) {
linesToShip.push({ sku: it.sku, amount });
} else if (it.orderLineId != null) {
linesToShip.push({ orderLineId: it.orderLineId, amount });
}
}
}
if (linesToShip.length === 0) {
enqueueSnackbar('Bitte wählen Sie mindestens einen Artikel mit Liefermenge > 0 aus.', { variant: 'warning' });
return;
}
this.setState({ saving: true, error: null });
try {
const payload = {
date: date || undefined,
externalNumber: externalNumber.trim() || undefined,
lines: linesToShip,
};
const res = await orderApi.createShipment(orderId, payload);
const shipment = res?.shipment;
const shNum = shipment?.number || '';
enqueueSnackbar(
shNum ? `Lieferschein ${shNum} erfolgreich angelegt!` : 'Lieferschein erfolgreich angelegt!',
{ variant: 'success' },
);
onSuccess?.(shipment);
onClose();
} catch (err) {
notifyError(err);
this.setState({ saving: false, error: err?.message || String(err) });
}
}
// // MARK: calls orderApi.createShipment to ship all remaining lines
// #region POST /api/order/:id/shipment — ship all remaining
async handleShipAll() {
const { orderId, onClose, onSuccess } = this.props;
const { date, externalNumber, saving } = this.state;
if (saving || !orderId) return;
this.setState({ saving: true, error: null });
try {
const payload = {
date: date || undefined,
externalNumber: externalNumber.trim() || undefined,
};
const res = await orderApi.createShipment(orderId, payload);
const shipment = res?.shipment;
const shNum = shipment?.number || '';
enqueueSnackbar(
shNum ? `Lieferschein ${shNum} erfolgreich angelegt!` : 'Lieferschein erfolgreich angelegt!',
{ variant: 'success' },
);
onSuccess?.(shipment);
onClose();
} catch (err) {
notifyError(err);
this.setState({ saving: false, error: err?.message || String(err) });
}
}
// #endregion
render() {
const { open, onClose, orderNumber } = this.props;
const { date, externalNumber, saving, error } = this.state;
const nextShipmentNumber = getNextShipmentNumber(orderNumber, this.props.shipments);
const items = this.getItems();
const shippableItems = items.filter((it) => it.isShippable);
const openItems = shippableItems.filter((it) => it.remaining > 0);
const allShipped = shippableItems.length > 0 && openItems.length === 0;
const isAllSelected = openItems.length > 0 && openItems.every((it) => it.isSelected);
const selectedCount = openItems.filter((it) => it.isSelected).length;
return (
<AppDialog open={open} onClose={saving ? undefined : onClose} maxWidth="lg" fullWidth>
<DialogTitle sx={{ m: 0, p: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
<LocalShippingOutlinedIcon color="primary" />
<Box>
<Typography variant="h6" component="div" sx={{ fontWeight: 700, lineHeight: 1.2 }}>
Lieferschein erstellen
</Typography>
{orderNumber ? (
<Typography variant="caption" color="text.secondary">
Zu: <strong style={{ fontFamily: 'monospace' }}>{orderNumber}</strong>
</Typography>
) : null}
</Box>
</Stack>
<IconButton
aria-label="close"
onClick={onClose}
disabled={saving}
size="small"
sx={{ color: (theme) => theme.palette.grey[500] }}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers sx={{ p: 2.5 }}>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => this.setState({ error: null })}>
{error}
</Alert>
)}
{allShipped && (
<Alert severity="success" icon={<CheckCircleOutlineOutlinedIcon />} sx={{ mb: 2 }}>
Alle Artikel dieses Auftrags wurden bereits vollständig geliefert.
</Alert>
)}
{/* Header controls: date, preview shipment number, and external reference */}
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2.5 }}>
<TextField
size="small"
type="date"
label="Lieferdatum"
value={date}
disabled={saving}
onChange={(e) => this.setState({ date: e.target.value })}
slotProps={{ inputLabel: { shrink: true } }}
sx={{ minWidth: 160 }}
/>
<TextField
size="small"
label="Lieferschein-Nr."
value={nextShipmentNumber}
disabled
slotProps={{
input: {
readOnly: true,
},
}}
sx={{ minWidth: 190, '& .MuiInputBase-input': { fontFamily: 'monospace' } }}
/>
<TextField
size="small"
label="Referenz"
value={externalNumber}
disabled={saving}
onChange={(e) => this.setState({ externalNumber: e.target.value })}
fullWidth
slotProps={{ input: { sx: { fontFamily: 'monospace' } } }}
/>
</Stack>
<TableContainer sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
<Table size="small">
<TableHead sx={{ bgcolor: 'action.hover' }}>
<TableRow>
<TableCell padding="checkbox" sx={{ width: 44 }}>
<Checkbox
size="small"
checked={isAllSelected}
indeterminate={selectedCount > 0 && !isAllSelected}
disabled={saving || openItems.length === 0}
onChange={() => this.handleToggleAll(openItems)}
/>
</TableCell>
<TableCell sx={{ fontWeight: 600 }}>ArtNr</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Artikel</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>Bestellt</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>Geliefert</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>Offen</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>Bestand</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>Verfügbar</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, width: 130 }}>Liefermenge</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={9} align="center" sx={{ py: 3, color: 'text.secondary' }}>
Keine Artikel im Auftrag vorhanden.
</TableCell>
</TableRow>
) : (
items.map((item) => {
const isNonShippable = !item.isShippable;
const isClosed = isNonShippable || item.remaining <= 0;
return (
<TableRow
key={item.itemKey}
hover={!isClosed}
sx={{
opacity: isClosed ? 0.55 : 1,
bgcolor: isClosed ? 'action.hover' : undefined,
}}
>
<TableCell padding="checkbox">
<Checkbox
size="small"
checked={item.isSelected && !isClosed}
disabled={saving || isClosed}
onChange={() => this.handleToggleItem(item.itemKey, item.remaining)}
/>
</TableCell>
<TableCell sx={{ fontWeight: 600, fontFamily: 'monospace' }}>{item.sku || '–'}</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{item.name || '–'}
</Typography>
</TableCell>
<TableCell align="right">{item.ordered}</TableCell>
<TableCell align="right">{isNonShippable ? '–' : item.shipped}</TableCell>
<TableCell
align="right"
sx={{
fontWeight: 700,
color: item.remaining > 0 ? 'primary.main' : 'text.disabled',
}}
>
{isNonShippable ? '–' : item.remaining}
</TableCell>
<TableCell
align="right"
title={item.stockTitle}
sx={{
fontWeight: 500,
color:
!item.sku || !item.cached || item.stockVal == null
? 'text.disabled'
: item.stockVal <= 0
? 'error.main'
: 'text.secondary',
}}
>
{!item.sku ? '–' : !item.cached ? '…' : item.isTracked === false ? '∞' : formatStock(item.stockVal)}
</TableCell>
<TableCell
align="right"
title={item.reservedTitle}
sx={{
fontWeight: 600,
color:
!item.sku || !item.cached || item.availableVal == null
? 'text.disabled'
: item.availableVal <= 0
? 'error.main'
: item.remaining > item.availableVal
? 'warning.main'
: 'text.secondary',
}}
>
{!item.sku ? '–' : !item.cached ? '…' : item.isTracked === false ? '∞' : formatStock(item.availableVal)}
</TableCell>
<TableCell align="right">
{isNonShippable ? (
<Chip size="small" label="Nicht lieferbar" variant="outlined" sx={{ height: 22, fontSize: '0.7rem' }} />
) : isClosed ? (
<Chip size="small" label="Geliefert" color="success" variant="outlined" sx={{ height: 22, fontSize: '0.7rem' }} />
) : (
<TextField
size="small"
type="number"
disabled={saving || !item.isSelected}
value={item.shipAmount}
onChange={(e) => this.handleAmountChange(item.itemKey, e.target.value)}
slotProps={{
htmlInput: {
min: 0,
max: item.remaining,
step: 'any',
style: { textAlign: 'right', fontWeight: 600 },
},
}}
sx={{ width: 100 }}
/>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
</DialogContent>
<DialogActions sx={{ p: 2, justifyContent: 'flex-end' }}>
<Button
variant="contained"
color="primary"
disabled={saving || allShipped || selectedCount === 0}
startIcon={saving ? <CircularProgress size={16} color="inherit" /> : <LocalShippingOutlinedIcon />}
onClick={this.handleSubmit}
sx={{ textTransform: 'none', fontWeight: 700 }}
>
{saving ? 'Wird erstellt…' : 'Lieferschein erstellen'}
</Button>
</DialogActions>
</AppDialog>
);
}
}