This commit is contained in:
seb
2026-09-25 07:27:37 +02:00
parent b331f8f535
commit a3db1742e7
17 changed files with 2273 additions and 371 deletions

View File

@@ -0,0 +1,589 @@
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>
);
}
}