Files
dashfront/src/components/manifest/BookDeliveryDialog.jsx
2026-09-26 23:05:48 +02:00

710 lines
24 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 CircularProgress from '@mui/material/CircularProgress';
import Dialog from '@mui/material/Dialog';
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 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 Chip from '@mui/material/Chip';
import Alert from '@mui/material/Alert';
import Paper from '@mui/material/Paper';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import FormHelperText from '@mui/material/FormHelperText';
import CloseIcon from '@mui/icons-material/Close';
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import WarehouseIcon from '@mui/icons-material/Warehouse';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { enqueueSnackbar } from 'notistack';
import { notifyError } from '../../utils/notify';
import { formatDate } from './manifestConfig';
import {
fetchDeliveryLedgerSummary,
bookDeliveryLines,
} from '../../utils/deliveryLedger';
import warehouseAutomat from '../../automats/warehouseAutomat';
export default class BookDeliveryDialog extends PureComponent {
static contextType = StorageManagerContext;
constructor(props) {
super(props);
let initialAmounts = {};
if (Array.isArray(props.summary?.lines)) {
for (const line of props.summary.lines) {
if (props.prefillLineId != null) {
initialAmounts[line.id] =
line.id === props.prefillLineId
? line.remainingAmount > 0
? line.remainingAmount
: 0
: 0;
} else {
initialAmounts[line.id] = line.remainingAmount > 0 ? line.remainingAmount : 0;
}
}
}
const initialWarehouses = warehouseAutomat.state.warehouses || [];
this.state = {
summary: props.summary ?? null,
loading: !props.summary,
error: null,
amountsToBook: initialAmounts,
transactionText: props.delivery?.number
? `Lieferung ${props.delivery.number}`
: 'Lieferung Wareneingang',
isBooking: false,
warehouses: initialWarehouses,
selectedWarehouseId: initialWarehouses.length === 1 ? initialWarehouses[0].id : '',
warehouseError: null,
};
this.handleAmountChange = this.handleAmountChange.bind(this);
this.handleWarehouseChange = this.handleWarehouseChange.bind(this);
this.handleTransactionTextChange = this.handleTransactionTextChange.bind(this);
this.handleBookAll = this.handleBookAll.bind(this);
this.handleResetAmounts = this.handleResetAmounts.bind(this);
this.handleConfirmBook = this.handleConfirmBook.bind(this);
}
componentDidMount() {
this.unsubWarehouse = warehouseAutomat.subscribe((state) => {
const warehouses = state.warehouses || [];
this.setState((prev) => {
const selectedId =
prev.selectedWarehouseId ||
(warehouses.length === 1 ? warehouses[0].id : '');
return {
warehouses,
selectedWarehouseId: selectedId,
};
});
});
void warehouseAutomat.actions.load().catch(() => {});
if (this.props.open) {
void this.loadSummary();
}
}
componentWillUnmount() {
this.unsubWarehouse?.();
}
componentDidUpdate(prevProps) {
if (this.props.open && !prevProps.open) {
const currentWarehouses = this.state.warehouses || warehouseAutomat.state.warehouses || [];
this.setState(
{
summary: this.props.summary ?? null,
loading: !this.props.summary,
error: null,
warehouseError: null,
selectedWarehouseId:
this.state.selectedWarehouseId ||
(currentWarehouses.length === 1 ? currentWarehouses[0].id : ''),
transactionText: this.props.delivery?.number
? `Lieferung ${this.props.delivery.number}`
: 'Lieferung Wareneingang',
isBooking: false,
},
() => {
void warehouseAutomat.actions.load().catch(() => {});
if (this.props.summary) {
this.initAmountsFromSummary(this.props.summary, this.props.prefillLineId);
} else {
void this.loadSummary();
}
}
);
} else if (
this.props.open &&
(this.props.prefillLineId !== prevProps.prefillLineId ||
(this.props.summary && this.props.summary !== prevProps.summary)) &&
!this.state.isBooking
) {
const activeSummary = this.state.summary || this.props.summary;
if (activeSummary) {
this.initAmountsFromSummary(activeSummary, this.props.prefillLineId);
}
}
}
handleWarehouseChange(event) {
const value = event.target.value;
this.setState({
selectedWarehouseId: value ? Number(value) : '',
warehouseError: null,
});
}
async loadSummary() {
const { delivery, prefillLineId } = this.props;
if (!delivery?.id) return;
this.setState({ loading: true, error: null });
try {
const ledgerApi = this.context?.ledger;
const deliveryApi = this.context?.delivery;
const summary = await fetchDeliveryLedgerSummary(delivery.id, {
ledgerApi,
deliveryApi,
});
this.initAmountsFromSummary(summary, prefillLineId);
this.setState({ summary, loading: false });
} catch (err) {
console.error('[BookDeliveryDialog] Failed to load ledger summary:', err);
this.setState({ error: err.message || 'Laden fehlgeschlagen', loading: false });
}
}
initAmountsFromSummary(summary, prefillLineId = this.props.prefillLineId) {
const amounts = {};
if (Array.isArray(summary?.lines)) {
for (const line of summary.lines) {
if (prefillLineId != null) {
amounts[line.id] =
line.id === prefillLineId
? line.remainingAmount > 0
? line.remainingAmount
: 0
: 0;
} else {
amounts[line.id] = line.remainingAmount > 0 ? line.remainingAmount : 0;
}
}
}
this.setState({ amountsToBook: amounts });
}
handleAmountChange(lineId, value) {
const num = Math.max(0, Number(value) || 0);
this.setState((prev) => ({
amountsToBook: {
...prev.amountsToBook,
[lineId]: num,
},
}));
}
handleTransactionTextChange(e) {
this.setState({ transactionText: e.target.value });
}
handleBookAll() {
const { summary } = this.state;
if (!summary?.lines) return;
const amounts = {};
for (const line of summary.lines) {
amounts[line.id] = line.remainingAmount > 0 ? line.remainingAmount : 0;
}
this.setState({ amountsToBook: amounts });
}
handleResetAmounts() {
const { summary } = this.state;
if (!summary?.lines) return;
const amounts = {};
for (const line of summary.lines) {
amounts[line.id] = 0;
}
this.setState({ amountsToBook: amounts });
}
async handleConfirmBook() {
const { delivery, onBookSuccess, onClose } = this.props;
const { summary, amountsToBook, transactionText } = this.state;
if (!summary?.lines) return;
const linesToBook = summary.lines
.map((line) => ({
manifestarticleId: line.id,
sku: line.sku,
name: line.name,
amount: Number(amountsToBook[line.id] || 0),
}))
.filter((l) => l.amount > 0);
if (linesToBook.length === 0) {
enqueueSnackbar('Keine Mengen zum Buchen angegeben.', { variant: 'info' });
return;
}
const { selectedWarehouseId } = this.state;
if (!selectedWarehouseId) {
this.setState({ warehouseError: 'Bitte wählen Sie ein Ziellager aus.' });
enqueueSnackbar('Bitte wählen Sie ein Ziellager aus.', { variant: 'warning' });
return;
}
this.setState({ isBooking: true, error: null, warehouseError: null });
try {
const ledgerApi = this.context?.ledger;
const deliveryApi = this.context?.delivery;
await bookDeliveryLines({
delivery: summary.delivery || delivery,
linesToBook,
warehouseId: selectedWarehouseId,
ledgerApi,
transactionText,
});
const totalBookedQty = linesToBook.reduce((sum, l) => sum + l.amount, 0);
enqueueSnackbar(
`${linesToBook.length} Position${linesToBook.length === 1 ? '' : 'en'} (${totalBookedQty} Stk.) erfolgreich ins Lager gebucht.`,
{ variant: 'success' }
);
// Refresh summary
const updatedSummary = await fetchDeliveryLedgerSummary(delivery.id, {
ledgerApi,
deliveryApi,
});
if (onBookSuccess) {
onBookSuccess(updatedSummary);
}
if (onClose) {
onClose();
}
} catch (err) {
console.error('[BookDeliveryDialog] Booking failed:', err);
notifyError(err);
this.setState({
error: err.message || 'Buchung fehlgeschlagen',
isBooking: false,
});
}
}
renderHeaderInfo() {
const { delivery, partyName } = this.props;
const { summary } = this.state;
return (
<Paper
variant="outlined"
sx={{
p: 1.5,
mb: 2,
display: 'flex',
flexWrap: 'wrap',
gap: 3,
backgroundColor: '#f8fafc',
alignItems: 'center',
}}
>
<Box>
<Typography variant="caption" color="text.secondary" display="block">
Nummer
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{delivery?.number || '–'}
</Typography>
</Box>
{delivery?.externalNumber && (
<Box>
<Typography variant="caption" color="text.secondary" display="block">
Referenz
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{delivery.externalNumber}
</Typography>
</Box>
)}
<Box>
<Typography variant="caption" color="text.secondary" display="block">
Lieferdatum
</Typography>
<Typography variant="body2">
{formatDate(delivery?.date)}
</Typography>
</Box>
{partyName && (
<Box>
<Typography variant="caption" color="text.secondary" display="block">
Lieferant
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{partyName}
</Typography>
</Box>
)}
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" color="text.secondary">
Aktueller Status:
</Typography>
{summary?.status === 'booked' && (
<Chip
size="small"
icon={<CheckCircleOutlineOutlinedIcon />}
label="Vollständig gebucht"
color="success"
variant="outlined"
sx={{ fontWeight: 600 }}
/>
)}
{summary?.status === 'partial' && (
<Chip
size="small"
icon={<Inventory2OutlinedIcon />}
label={`Teilweise gebucht (${summary.bookedPositions}/${summary.totalPositions})`}
color="warning"
variant="outlined"
sx={{ fontWeight: 600 }}
/>
)}
{summary?.status === 'unbooked' && (
<Chip
size="small"
label={`Offen (${summary.totalPositions} Pos.)`}
variant="outlined"
sx={{ color: 'text.secondary', borderColor: 'divider' }}
/>
)}
{summary?.status === 'empty' && (
<Chip size="small" label="Keine Positionen" variant="outlined" />
)}
</Box>
</Paper>
);
}
renderLinesTable() {
const { summary, amountsToBook, isBooking } = this.state;
const lines = summary?.lines || [];
if (lines.length === 0) {
return (
<Alert severity="info" sx={{ my: 2 }}>
Diese Lieferung enthält keine Artikelpositionen zum Einbuchen.
</Alert>
);
}
return (
<TableContainer component={Paper} variant="outlined" sx={{ mb: 2 }}>
<Table size="small">
<TableHead sx={{ backgroundColor: '#f1f5f9' }}>
<TableRow>
<TableCell sx={{ fontWeight: 600 }}>SKU</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Artikel</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>Liefermenge</TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>Bereits gebucht</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, width: 140 }}>Jetzt einbuchen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{lines.map((line) => {
const toBook = amountsToBook[line.id] ?? 0;
const isAlreadyFullyBooked = line.isBooked && line.remainingAmount <= 0;
return (
<TableRow key={line.id} hover>
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
{line.sku || '–'}
</TableCell>
<TableCell sx={{ maxWidth: 280 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>
{line.name || '–'}
</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{line.targetAmount}
</Typography>
</TableCell>
<TableCell align="right">
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, justifyContent: 'flex-end' }}>
<Typography
variant="body2"
color={line.bookedAmount > 0 ? 'success.main' : 'text.secondary'}
sx={{ fontWeight: line.bookedAmount > 0 ? 600 : 400 }}
>
{line.bookedAmount}
</Typography>
{isAlreadyFullyBooked && (
<CheckCircleOutlineOutlinedIcon color="success" sx={{ fontSize: 16 }} />
)}
</Box>
</TableCell>
<TableCell align="right">
{isAlreadyFullyBooked ? (
<Chip
size="small"
label="Gebucht"
color="success"
variant="outlined"
sx={{ fontSize: '0.75rem', height: 24 }}
/>
) : (
<TextField
size="small"
type="number"
value={toBook}
onChange={(e) => this.handleAmountChange(line.id, e.target.value)}
disabled={isBooking}
slotProps={{
input: {
inputProps: {
min: 0,
step: 1,
style: { textAlign: 'right', padding: '4px 8px' },
},
},
}}
sx={{ width: 90 }}
/>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
);
}
render() {
const { open, onClose, delivery } = this.props;
const {
summary,
loading,
error,
amountsToBook,
transactionText,
isBooking,
warehouses,
selectedWarehouseId,
warehouseError,
} = this.state;
const lines = summary?.lines || [];
const prefillLine =
this.props.prefillLineId != null
? lines.find((l) => l.id === this.props.prefillLineId)
: null;
const totalToBook = lines.reduce(
(sum, l) => sum + (Number(amountsToBook[l.id]) || 0),
0
);
const countLinesToBook = lines.filter(
(l) => Number(amountsToBook[l.id]) > 0
).length;
const isFullyBooked = summary?.status === 'booked';
const selectedWarehouse = warehouses.find((w) => w.id === selectedWarehouseId);
const isWarehouseMissing = !selectedWarehouseId;
return (
<Dialog
open={Boolean(open)}
onClose={isBooking ? undefined : onClose}
maxWidth="md"
fullWidth
>
<DialogTitle
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
pb: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Inventory2OutlinedIcon color="primary" />
<Typography variant="h6" component="span" sx={{ fontWeight: 600 }}>
Lieferung {delivery?.number || ''} ins Lager buchen
</Typography>
</Box>
<IconButton size="small" onClick={onClose} disabled={isBooking}>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers sx={{ pt: 2 }}>
{this.renderHeaderInfo()}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6, gap: 2, alignItems: 'center' }}>
<CircularProgress size={24} />
<Typography color="text.secondary">Lade Positionen und Ledger-Status...</Typography>
</Box>
) : error ? (
<Alert severity="error" sx={{ my: 2 }}>
{error}
</Alert>
) : (
<>
{prefillLine && (
<Alert severity="info" sx={{ mb: 2 }}>
Vorausgewählt zum Einbuchen: Position{' '}
<strong>{prefillLine.sku ? `SKU ${prefillLine.sku}` : prefillLine.name}</strong> ({amountsToBook[prefillLine.id] || 0} Stk.). Andere Positionen stehen auf 0.
</Alert>
)}
{this.renderLinesTable()}
{!isFullyBooked && lines.length > 0 && (
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<Button
size="small"
variant="text"
onClick={this.handleBookAll}
disabled={isBooking}
>
Alle offenen Mengen übernehmen
</Button>
<Button
size="small"
variant="text"
color="inherit"
onClick={this.handleResetAmounts}
disabled={isBooking}
>
Zurücksetzen
</Button>
</Box>
)}
{warehouses.length === 0 && !isFullyBooked && (
<Alert severity="error" sx={{ mb: 2 }}>
Es ist noch kein Lager eingerichtet. Bitte legen Sie unter „Artikel → Lager“ zuerst ein Lager an.
</Alert>
)}
<Box
sx={{
display: 'flex',
gap: 2,
mb: 2,
flexDirection: { xs: 'column', sm: 'row' },
}}
>
<FormControl
size="small"
required
error={Boolean(warehouseError)}
disabled={isBooking || isFullyBooked}
sx={{ minWidth: { sm: 240 }, flex: { sm: '0 0 auto' } }}
>
<InputLabel id="warehouse-select-label">Ziellager *</InputLabel>
<Select
labelId="warehouse-select-label"
id="warehouse-select"
value={selectedWarehouseId ?? ''}
label="Ziellager *"
onChange={this.handleWarehouseChange}
startAdornment={
<WarehouseIcon
sx={{ mr: 1, color: 'text.secondary', fontSize: 20 }}
/>
}
>
{warehouses.length === 0 ? (
<MenuItem value="" disabled>
Keine Lager vorhanden
</MenuItem>
) : (
warehouses.map((wh) => (
<MenuItem key={wh.id} value={wh.id}>
{wh.name}
</MenuItem>
))
)}
</Select>
{warehouseError ? (
<FormHelperText>{warehouseError}</FormHelperText>
) : (
<FormHelperText>Pflichtfeld: Ziellager für die Buchung</FormHelperText>
)}
</FormControl>
<TextField
fullWidth
size="small"
label="Buchungstext (Ledger-Referenz)"
value={transactionText}
onChange={this.handleTransactionTextChange}
disabled={isBooking || isFullyBooked}
helperText="Wird als Transaktionstext in das Ledger übernommen"
/>
</Box>
{isFullyBooked ? (
<Alert severity="success" icon={<CheckCircleOutlineOutlinedIcon />}>
Alle Artikel dieser Lieferung sind bereits vollständig im Lager verbucht.
</Alert>
) : totalToBook > 0 ? (
<Alert severity={selectedWarehouseId ? 'info' : 'warning'}>
{selectedWarehouseId ? (
<>
Es werden <strong>{totalToBook} Stück</strong> für{' '}
<strong>{countLinesToBook} Position{countLinesToBook === 1 ? '' : 'en'}</strong> als Lagerzugang (DELIVERY) in das Lager{' '}
<strong>„{selectedWarehouse?.name || '–'}“</strong> gebucht.
</>
) : (
<>
Bitte wählen Sie ein <strong>Ziellager</strong> aus, um die Buchung von{' '}
<strong>{totalToBook} Stück</strong> durchzuführen.
</>
)}
</Alert>
) : (
<Alert severity="warning">
Wählen Sie mindestens eine Menge größer als 0 aus, um den Lagerzugang zu buchen.
</Alert>
)}
</>
)}
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={onClose} disabled={isBooking} color="inherit">
{isFullyBooked ? 'Schließen' : 'Abbrechen'}
</Button>
{!isFullyBooked && lines.length > 0 && (
<Button
variant="contained"
color="primary"
onClick={this.handleConfirmBook}
disabled={isBooking || totalToBook <= 0 || loading || isWarehouseMissing}
startIcon={
isBooking ? (
<CircularProgress size={16} color="inherit" />
) : (
<Inventory2OutlinedIcon />
)
}
sx={{ minWidth: 160, fontWeight: 600 }}
>
{isBooking ? 'Wird gebucht...' : 'Jetzt ins Lager buchen'}
</Button>
)}
</DialogActions>
</Dialog>
);
}
}