This commit is contained in:
seb
2026-10-15 20:15:19 +02:00
parent 8e783a571f
commit beed294cb0
26 changed files with 3165 additions and 906 deletions

View File

@@ -0,0 +1,465 @@
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;
}
/**
* 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 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 Auftrag / Bestellung: <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 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: 170 }}
/>
<TextField
size="small"
label="Lieferschein-Nr. / Externe Referenz (optional)"
placeholder="z.B. LS-2026-0042"
value={externalNumber}
disabled={saving}
onChange={(e) => this.setState({ externalNumber: e.target.value })}
fullWidth
/>
</Stack>
{/* Items selection section */}
<Box sx={{ mb: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
Zu liefernde Artikel ({selectedCount} von {openItems.length} ausgewählt)
</Typography>
{openItems.length > 0 && (
<Button
size="small"
variant="text"
disabled={saving}
onClick={() => this.handleToggleAll(openItems)}
sx={{ textTransform: 'none', fontWeight: 600, fontSize: '0.8rem' }}
>
{isAllSelected ? 'Auswahl aufheben' : 'Alle offenen auswählen'}
</Button>
)}
</Box>
<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: 'space-between' }}>
<Button variant="outlined" onClick={onClose} disabled={saving}>
Abbrechen
</Button>
<Stack direction="row" spacing={1.5}>
{!allShipped && openItems.length > 0 && (
<Button
variant="outlined"
color="secondary"
disabled={saving}
onClick={this.handleShipAll}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Alles liefern
</Button>
)}
<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>
</Stack>
</DialogActions>
</AppDialog>
);
}
}