Files
dashfront/src/components/manifest/BookDeliveryDialog.jsx
2026-09-25 07:27:37 +02:00

590 lines
19 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 CloseIcon from '@mui/icons-material/Close';
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { enqueueSnackbar } from 'notistack';
import { notifyError } from '../../utils/notify';
import { formatDate } from './manifestConfig';
import {
fetchDeliveryLedgerSummary,
bookDeliveryLines,
} from '../../utils/deliveryLedger';
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;
}
}
}
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,
};
this.handleAmountChange = this.handleAmountChange.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() {
if (this.props.open) {
void this.loadSummary();
}
}
componentDidUpdate(prevProps) {
if (this.props.open && !prevProps.open) {
this.setState(
{
summary: this.props.summary ?? null,
loading: !this.props.summary,
error: null,
transactionText: this.props.delivery?.number
? `Lieferung ${this.props.delivery.number}`
: 'Lieferung Wareneingang',
isBooking: false,
},
() => {
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);
}
}
}
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;
}
this.setState({ isBooking: true, error: null });
try {
const ledgerApi = this.context?.ledger;
const deliveryApi = this.context?.delivery;
await bookDeliveryLines({
delivery: summary.delivery || delivery,
linesToBook,
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,
} = 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';
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>
)}
<Box sx={{ mb: 2 }}>
<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="info">
Es werden <strong>{totalToBook} Stück</strong> für{' '}
<strong>{countLinesToBook} Position{countLinesToBook === 1 ? '' : 'en'}</strong> als Lagerzugang (DELIVERY) im Ledger gebucht.
</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}
startIcon={
isBooking ? (
<CircularProgress size={16} color="inherit" />
) : (
<Inventory2OutlinedIcon />
)
}
sx={{ minWidth: 160, fontWeight: 600 }}
>
{isBooking ? 'Wird gebucht...' : 'Jetzt ins Lager buchen'}
</Button>
)}
</DialogActions>
</Dialog>
);
}
}