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