Files
dashfront/src/components/manifest/OrderShipmentDialog.jsx
2026-10-17 04:39:37 +02:00

461 lines
17 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 { todayInputValue } from './manifestConfig';
// MARK: helper to sum already shipped quantities per SKU across order shipments
function computeShippedBySku(shipments) {
const map = {};
for (const sh of shipments || []) {
for (const sl of sh.lines || []) {
if (sl.sku) {
map[sl.sku] = (map[sl.sku] || 0) + (Number(sl.amount) || 0);
}
}
}
return map;
}
// 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.handleToggleSku = this.handleToggleSku.bind(this);
this.handleToggleAll = this.handleToggleAll.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleShipAll = this.handleShipAll.bind(this);
}
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,
});
}
}
calculateInitialState(props) {
const shippedBySku = computeShippedBySku(props.shipments);
const initialAmounts = {};
const initialSelected = new Set();
for (const l of props.lines || []) {
if (!l.sku) continue;
const ordered = Number(l.amount) || 0;
const shipped = shippedBySku[l.sku] || 0;
const rem = Math.max(0, ordered - shipped);
if (rem > 0) {
initialAmounts[l.sku] = String(rem);
initialSelected.add(l.sku);
} else {
initialAmounts[l.sku] = '0';
}
}
return { initialAmounts, initialSelected };
}
handleToggleSku(sku, remaining) {
this.setState((prev) => {
const nextSelected = new Set(prev.selectedSkus);
const nextAmounts = { ...prev.lineAmounts };
if (nextSelected.has(sku)) {
nextSelected.delete(sku);
} else {
nextSelected.add(sku);
if (!Number(nextAmounts[sku]) || Number(nextAmounts[sku]) <= 0) {
nextAmounts[sku] = String(remaining);
}
}
return { selectedSkus: nextSelected, lineAmounts: nextAmounts };
});
}
handleToggleAll(openItems) {
this.setState((prev) => {
const allSelected = openItems.length > 0 && openItems.every((it) => prev.selectedSkus.has(it.sku));
const nextSelected = new Set();
const nextAmounts = { ...prev.lineAmounts };
if (!allSelected) {
for (const it of openItems) {
nextSelected.add(it.sku);
if (!Number(nextAmounts[it.sku]) || Number(nextAmounts[it.sku]) <= 0) {
nextAmounts[it.sku] = String(it.remaining);
}
}
}
return { selectedSkus: nextSelected, lineAmounts: nextAmounts };
});
}
handleAmountChange(sku, val) {
this.setState((prev) => {
const nextAmounts = { ...prev.lineAmounts, [sku]: val };
const nextSelected = new Set(prev.selectedSkus);
const num = Number(val);
if (num > 0) {
nextSelected.add(sku);
} else {
nextSelected.delete(sku);
}
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 linesToShip = [];
for (const sku of selectedSkus) {
const amount = Number(lineAmounts[sku]);
if (amount > 0) {
linesToShip.push({ sku, 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) });
}
}
// #endregion
// 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, lines = [], shipments = [] } = this.props;
const { date, externalNumber, lineAmounts, selectedSkus, saving, error } = this.state;
const nextShipmentNumber = getNextShipmentNumber(orderNumber, shipments);
const shippedBySku = computeShippedBySku(shipments);
const activeLines = lines.filter((l) => Boolean(l.sku));
const items = activeLines.map((line) => {
const ordered = Number(line.amount) || 0;
const shipped = shippedBySku[line.sku] || 0;
const remaining = Math.max(0, ordered - shipped);
const isSelected = selectedSkus.has(line.sku);
const shipAmount = lineAmounts[line.sku] ?? (remaining > 0 ? String(remaining) : '0');
return {
...line,
ordered,
shipped,
remaining,
isSelected,
shipAmount,
};
});
const openItems = items.filter((it) => it.remaining > 0);
const allShipped = activeLines.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="md" 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>{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 }}
/>
<TextField
size="small"
label="Referenz"
value={externalNumber}
disabled={saving}
onChange={(e) => this.setState({ externalNumber: e.target.value })}
fullWidth
/>
</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 }}>SKU</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Artikel</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>Bestellt</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>Geliefert</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>Offen</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, width: 130 }}>Liefermenge</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={7} align="center" sx={{ py: 3, color: 'text.secondary' }}>
Keine Artikel im Auftrag vorhanden.
</TableCell>
</TableRow>
) : (
items.map((item) => {
const isClosed = item.remaining <= 0;
return (
<TableRow
key={item.sku}
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.handleToggleSku(item.sku, item.remaining)}
/>
</TableCell>
<TableCell sx={{ fontWeight: 600 }}>{item.sku}</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{item.name || '–'}
</Typography>
</TableCell>
<TableCell align="right">{item.ordered}</TableCell>
<TableCell align="right">{item.shipped}</TableCell>
<TableCell
align="right"
sx={{
fontWeight: 700,
color: item.remaining > 0 ? 'primary.main' : 'text.disabled',
}}
>
{item.remaining}
</TableCell>
<TableCell align="right">
{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.sku, 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>
);
}
}