u
This commit is contained in:
465
src/components/manifest/OrderShipmentDialog.jsx
Normal file
465
src/components/manifest/OrderShipmentDialog.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user