u
This commit is contained in:
@@ -9,6 +9,7 @@ import SyncIcon from '@mui/icons-material/Sync';
|
||||
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
|
||||
import { tileBaseSx, TILE_SIZE } from 'dashgui/tiles';
|
||||
import { generateTextThumbnail } from '../../utils/textThumbnail';
|
||||
import { notifyErrorMessage } from '../../utils/notify';
|
||||
|
||||
export class Attachment extends PureComponent {
|
||||
state = {
|
||||
@@ -47,6 +48,12 @@ export class Attachment extends PureComponent {
|
||||
|
||||
handleRescan = (e) => {
|
||||
e?.stopPropagation?.();
|
||||
if (this.props.hasArticles) {
|
||||
notifyErrorMessage(
|
||||
'Neu analysieren nicht möglich, solange Artikel vorhanden sind. Bitte entfernen Sie zuerst alle Zeilen.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { attachment, onRescan } = this.props;
|
||||
if (attachment && onRescan) {
|
||||
onRescan(attachment);
|
||||
@@ -90,7 +97,7 @@ export class Attachment extends PureComponent {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { attachment, disabled, sx } = this.props;
|
||||
const { attachment, disabled, sx, hasArticles } = this.props;
|
||||
const thumbnail = attachment?.thumbnail || this.state.generatedThumbnail;
|
||||
const name = attachment?.name || 'Dokument';
|
||||
const size = attachment?.size;
|
||||
@@ -211,18 +218,39 @@ export class Attachment extends PureComponent {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{isPdf || isText ? (
|
||||
<Tooltip title="Neu analysieren">
|
||||
<span>
|
||||
<Tooltip
|
||||
title={
|
||||
hasArticles
|
||||
? 'Neu analysieren nicht möglich, solange Artikel vorhanden sind'
|
||||
: 'Neu analysieren'
|
||||
}
|
||||
>
|
||||
<span
|
||||
style={{ display: 'inline-block' }}
|
||||
onClick={
|
||||
hasArticles
|
||||
? (e) => {
|
||||
e.stopPropagation();
|
||||
notifyErrorMessage(
|
||||
'Neu analysieren nicht möglich, solange Artikel vorhanden sind. Bitte entfernen Sie zuerst alle Zeilen.',
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
disabled={disabled || hasArticles}
|
||||
onClick={this.handleRescan}
|
||||
sx={{
|
||||
bgcolor: 'rgba(0, 0, 0, 0.6)',
|
||||
color: '#fff',
|
||||
color: hasArticles ? 'rgba(255, 255, 255, 0.3)' : '#fff',
|
||||
p: 0.5,
|
||||
backdropFilter: 'blur(2px)',
|
||||
'&:hover': { bgcolor: 'rgba(0, 0, 0, 0.85)' },
|
||||
cursor: hasArticles ? 'not-allowed' : 'pointer',
|
||||
'&:hover': {
|
||||
bgcolor: hasArticles ? 'rgba(0, 0, 0, 0.6)' : 'rgba(0, 0, 0, 0.85)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SyncIcon sx={{ fontSize: 16 }} />
|
||||
|
||||
@@ -6,7 +6,7 @@ import Attachment from './Attachment';
|
||||
|
||||
export class AttachmentList extends PureComponent {
|
||||
render() {
|
||||
const { attachments, onRescan, onDelete, onClick, disabled } = this.props;
|
||||
const { attachments, onRescan, onDelete, onClick, disabled, hasArticles } = this.props;
|
||||
if (!Array.isArray(attachments) || attachments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export class AttachmentList extends PureComponent {
|
||||
fontWeight={600}
|
||||
sx={{ mb: 1, display: 'block', textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
||||
>
|
||||
Dateianhänge ({attachments.length})
|
||||
Dateianhänge
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1.5} sx={{ flexWrap: 'wrap', gap: 1.5 }}>
|
||||
{attachments.map((attachment, index) => (
|
||||
@@ -30,6 +30,7 @@ export class AttachmentList extends PureComponent {
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
hasArticles={hasArticles}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,7 @@ class ManifestForm extends PureComponent {
|
||||
this.handleLineChange = this.handleLineChange.bind(this);
|
||||
this.handleAddLine = this.handleAddLine.bind(this);
|
||||
this.handleRemoveLine = this.handleRemoveLine.bind(this);
|
||||
this.handleRemoveAllLines = this.handleRemoveAllLines.bind(this);
|
||||
this.handleSave = this.handleSave.bind(this);
|
||||
this.handlePdfUpload = this.handlePdfUpload.bind(this);
|
||||
this.handleClosePdfDialog = this.handleClosePdfDialog.bind(this);
|
||||
@@ -215,6 +216,10 @@ class ManifestForm extends PureComponent {
|
||||
});
|
||||
}
|
||||
|
||||
handleRemoveAllLines() {
|
||||
this.setState({ lines: [] });
|
||||
}
|
||||
|
||||
async handlePdfUpload(file) {
|
||||
if (!file || this.state.pdfUploading) return;
|
||||
if (this._parseAbortController) {
|
||||
@@ -410,33 +415,20 @@ class ManifestForm extends PureComponent {
|
||||
}
|
||||
|
||||
renderArticles() {
|
||||
const { mode } = this.props;
|
||||
const cfg = this.cfg;
|
||||
const { partyId, lines, saving } = this.state;
|
||||
|
||||
if (
|
||||
cfg.requirePartyBeforeLines &&
|
||||
mode === 'create' &&
|
||||
partyId == null
|
||||
) {
|
||||
return (
|
||||
<PanelTile wide={3} minHeight={160} color="#e2e8f0">
|
||||
<Typography color="text.secondary">
|
||||
{cfg.partyGateMessage}
|
||||
</Typography>
|
||||
</PanelTile>
|
||||
);
|
||||
}
|
||||
const { lines, saving } = this.state;
|
||||
|
||||
return (
|
||||
<ManifestLines
|
||||
columns={cfg.columns}
|
||||
lines={lines}
|
||||
saving={saving}
|
||||
wide={4}
|
||||
linesColor={cfg.linesColor}
|
||||
onLineChange={this.handleLineChange}
|
||||
onAddLine={this.handleAddLine}
|
||||
onRemoveLine={this.handleRemoveLine}
|
||||
onRemoveAllLines={this.handleRemoveAllLines}
|
||||
onSave={() => void this.handleSave()}
|
||||
/>
|
||||
);
|
||||
@@ -475,18 +467,6 @@ class ManifestForm extends PureComponent {
|
||||
|
||||
return (
|
||||
<>
|
||||
{status === 'ready' && (
|
||||
<PageTitleRight>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={saving}
|
||||
onClick={this.handleSave}
|
||||
>
|
||||
{saving ? 'Speichern…' : 'Speichern'}
|
||||
</Button>
|
||||
</PageTitleRight>
|
||||
)}
|
||||
<PanelTileGrid>
|
||||
<ManifestHeader
|
||||
kind={kind}
|
||||
@@ -501,7 +481,9 @@ class ManifestForm extends PureComponent {
|
||||
externalNumber={externalNumber}
|
||||
saving={saving}
|
||||
pdfUploading={pdfUploading}
|
||||
lines={lines}
|
||||
onPdfUpload={kind === 'delivery' ? this.handlePdfUpload : undefined}
|
||||
onSave={this.handleSave}
|
||||
onPartyChange={this.handlePartyChange}
|
||||
onDateChange={this.handleDateChange}
|
||||
onNumberChange={this.handleNumberChange}
|
||||
|
||||
@@ -13,18 +13,24 @@ import Typography from '@mui/material/Typography';
|
||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutlineOutlined';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined';
|
||||
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import LinkOffOutlinedIcon from '@mui/icons-material/LinkOffOutlined';
|
||||
import { LINE_COLUMNS } from './manifestConfig';
|
||||
import { getFirstVal, parseNum } from './manifestConfig';
|
||||
import ArticlePhoto from '../article/ArticlePhoto';
|
||||
import { formatPrice } from '../../utils/price';
|
||||
import { primaryPrice, priceBrutto } from '../../utils/articleDetail';
|
||||
import artikelAutomat, { createArticleSelector, getArticle } from '../../automats/artikelAutomat';
|
||||
import { artikelDetailPath } from '../../pages/artikel/articleRoute';
|
||||
|
||||
class ManifestFormLine extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const sku = props.supplierArticle?.article?.sku;
|
||||
this.state = {
|
||||
cachedArticle: sku ? getArticle(sku) : null,
|
||||
};
|
||||
this.handleRemove = this.handleRemove.bind(this);
|
||||
this.handleBlur = this.handleBlur.bind(this);
|
||||
this.handleCreateArticle = this.handleCreateArticle.bind(this);
|
||||
@@ -32,6 +38,41 @@ class ManifestFormLine extends PureComponent {
|
||||
this.handleRemoveSupplierArticle = this.handleRemoveSupplierArticle.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.subscribeArticle(this.props.supplierArticle?.article?.sku);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const prevSku = prevProps.supplierArticle?.article?.sku;
|
||||
const nextSku = this.props.supplierArticle?.article?.sku;
|
||||
if (prevSku !== nextSku) {
|
||||
this.subscribeArticle(nextSku);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubArticle?.();
|
||||
}
|
||||
|
||||
subscribeArticle(sku) {
|
||||
this.unsubArticle?.();
|
||||
this.unsubArticle = null;
|
||||
if (!sku) {
|
||||
if (this.state.cachedArticle) {
|
||||
this.setState({ cachedArticle: null });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const current = getArticle(sku);
|
||||
this.setState({ cachedArticle: current });
|
||||
if (!current) {
|
||||
artikelAutomat.actions.loadArticle(sku).catch(() => {});
|
||||
}
|
||||
this.unsubArticle = artikelAutomat.subscribe(this, (state) => ({
|
||||
cachedArticle: createArticleSelector(sku)(state),
|
||||
}));
|
||||
}
|
||||
|
||||
handleRemove() {
|
||||
this.props.onRemove(this.props.line.key);
|
||||
}
|
||||
@@ -63,15 +104,112 @@ class ManifestFormLine extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
renderField(column) {
|
||||
renderLedgerChip() {
|
||||
const { ledgerLine, onBookLine } = this.props;
|
||||
if (!ledgerLine) return null;
|
||||
const isBooked = ledgerLine.isBooked;
|
||||
const isPartial = !isBooked && ledgerLine.bookedAmount > 0;
|
||||
const isClickable = Boolean(onBookLine);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
isBooked
|
||||
? `Vollständig im Lager gebucht: ${ledgerLine.bookedAmount} Stück (Klicken für Details)`
|
||||
: isPartial
|
||||
? `Teilweise im Lager gebucht: ${ledgerLine.bookedAmount} von ${ledgerLine.targetAmount} Stück (Klicken zum Einbuchen dieser Position)`
|
||||
: `Noch nicht im Lager gebucht (${ledgerLine.targetAmount} Stück – Klicken zum Einbuchen dieser Position)`
|
||||
}
|
||||
>
|
||||
<Chip
|
||||
size="small"
|
||||
clickable={isClickable}
|
||||
onClick={
|
||||
isClickable
|
||||
? (e) => {
|
||||
e.stopPropagation();
|
||||
onBookLine(ledgerLine);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
icon={
|
||||
isBooked ? (
|
||||
<CheckCircleOutlineOutlinedIcon sx={{ '&&': { fontSize: 13 } }} />
|
||||
) : (
|
||||
<Inventory2OutlinedIcon sx={{ '&&': { fontSize: 13 } }} />
|
||||
)
|
||||
}
|
||||
label={
|
||||
isBooked
|
||||
? `Lager: ${ledgerLine.bookedAmount} Stk. gebucht`
|
||||
: isPartial
|
||||
? `Lager: ${ledgerLine.bookedAmount}/${ledgerLine.targetAmount} Stk.`
|
||||
: 'Lager: offen'
|
||||
}
|
||||
color={isBooked ? 'success' : isPartial ? 'warning' : 'default'}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: '0.68rem',
|
||||
fontWeight: 600,
|
||||
cursor: isClickable ? 'pointer' : 'default',
|
||||
bgcolor: isBooked ? 'success.50' : undefined,
|
||||
'&:hover': isClickable
|
||||
? {
|
||||
bgcolor: isBooked
|
||||
? 'success.100'
|
||||
: isPartial
|
||||
? 'warning.100'
|
||||
: 'action.hover',
|
||||
}
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
renderField(column, colIdx, totalCols) {
|
||||
const { id, align, type } = column;
|
||||
const { line, saving, onChange } = this.props;
|
||||
let val = line[id];
|
||||
if (val == null || val === '') {
|
||||
if (id === 'tax' || id === 'taxrate') {
|
||||
val = line.taxrate ?? line.tax ?? '';
|
||||
} else if (id === 'net' || id === 'netprice') {
|
||||
val = line.netprice ?? line.net ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
let isError = false;
|
||||
if (id === 'name') {
|
||||
isError = !String(line.name ?? '').trim();
|
||||
} else if (id === 'amount') {
|
||||
const raw = getFirstVal(line.amount, line.count);
|
||||
isError = raw == null || parseNum(raw) == null;
|
||||
} else if (id === 'pu') {
|
||||
const raw = getFirstVal(line.pu, line.vpe);
|
||||
isError = raw == null || parseNum(raw) == null;
|
||||
} else if (id === 'tax' || id === 'taxrate') {
|
||||
const raw = getFirstVal(line.tax, line.taxrate, line.taxRate, line.mwst);
|
||||
isError = raw == null || parseNum(raw) == null;
|
||||
} else if (id === 'net' || id === 'netprice') {
|
||||
const raw = getFirstVal(line.net, line.netprice, line.netPrice, line.eknetto);
|
||||
isError = raw == null || parseNum(raw) == null;
|
||||
} else if (id === 'sku') {
|
||||
isError = !String(line.sku ?? '').trim() && !String(line.name ?? '').trim();
|
||||
}
|
||||
|
||||
const isFirst = colIdx === 0;
|
||||
const isLast = colIdx === totalCols - 1;
|
||||
|
||||
return (
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
type={type}
|
||||
value={line[id] ?? ''}
|
||||
value={val ?? ''}
|
||||
error={isError}
|
||||
disabled={saving}
|
||||
autoComplete="off"
|
||||
onChange={(e) => onChange(line.key, id, e.target.value)}
|
||||
@@ -86,6 +224,16 @@ class ManifestFormLine extends PureComponent {
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: isFirst ? '4px 0 0 4px' : isLast ? '0 4px 4px 0' : 0,
|
||||
ml: isFirst ? 0 : '-1px',
|
||||
'&:hover': {
|
||||
zIndex: 1,
|
||||
},
|
||||
'&.Mui-focused': {
|
||||
zIndex: 2,
|
||||
},
|
||||
},
|
||||
'& input[type=number]': {
|
||||
MozAppearance: 'textfield',
|
||||
},
|
||||
@@ -116,18 +264,28 @@ class ManifestFormLine extends PureComponent {
|
||||
|
||||
if (supplierArticle) {
|
||||
const article = supplierArticle.article || {};
|
||||
const photo = article.photos?.[0] || article.articlepic?.[0];
|
||||
const fullArticle = this.state.cachedArticle || article;
|
||||
const photo = fullArticle.photos?.[0] || fullArticle.articlepic?.[0] || article.photos?.[0] || article.articlepic?.[0];
|
||||
const fullHash = photo?.hash || photo?.fullHash;
|
||||
const thumbHash = photo?.thumbhash || photo?.thumbHash;
|
||||
const thumbW = photo?.thumbw || photo?.thumbW;
|
||||
const thumbH = photo?.thumbh || photo?.thumbH;
|
||||
const hasPhoto = fullHash != null || thumbHash != null;
|
||||
|
||||
const eans = article.eans ?? article.articleean ?? [];
|
||||
const eans = fullArticle.eans ?? fullArticle.articleean ?? article.eans ?? article.articleean ?? [];
|
||||
const primaryEan = Array.isArray(eans) ? eans[0] : eans;
|
||||
const priceNet = supplierArticle.price?.net;
|
||||
const taxRate = supplierArticle.price?.taxRate;
|
||||
|
||||
const vkPrice = primaryPrice(fullArticle);
|
||||
const vkNetto = vkPrice?.net;
|
||||
const vkBrutto = vkPrice
|
||||
? priceBrutto(vkPrice) ??
|
||||
(vkPrice.net != null && vkPrice.taxrate != null
|
||||
? vkPrice.net * (1 + vkPrice.taxrate / 100)
|
||||
: null)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -138,6 +296,24 @@ class ManifestFormLine extends PureComponent {
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{/* Unlink button at the front */}
|
||||
{this.props.onRemoveSupplierArticle && supplierArticle.id != null && (
|
||||
<Tooltip title="Lieferanten-Verknüpfung aufheben">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
aria-label="Lieferanten-Verknüpfung entfernen"
|
||||
disabled={this.props.saving || this.props.supplierArticleBusy}
|
||||
onClick={this.handleRemoveSupplierArticle}
|
||||
sx={{ p: 0.25, mr: -0.25 }}
|
||||
>
|
||||
<LinkOffOutlinedIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Article Photo Thumbnail */}
|
||||
<Box
|
||||
sx={{
|
||||
@@ -166,9 +342,6 @@ class ManifestFormLine extends PureComponent {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Match Indicator */}
|
||||
<CheckCircleOutlinedIcon sx={{ fontSize: 16, color: 'success.main', flexShrink: 0 }} />
|
||||
|
||||
{/* Internal Article SKU */}
|
||||
{article.sku && (
|
||||
<Chip
|
||||
@@ -195,15 +368,15 @@ class ManifestFormLine extends PureComponent {
|
||||
)}
|
||||
|
||||
{/* Article Name */}
|
||||
{article.name && (
|
||||
{(fullArticle.name || article.name) && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
fontWeight={600}
|
||||
noWrap
|
||||
sx={{ color: 'text.primary', maxWidth: { xs: 200, sm: 300, md: 400 } }}
|
||||
title={article.name}
|
||||
title={fullArticle.name || article.name}
|
||||
>
|
||||
{article.name}
|
||||
{fullArticle.name || article.name}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
@@ -225,32 +398,49 @@ class ManifestFormLine extends PureComponent {
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Stored Purchase Price (Netto) */}
|
||||
{priceNet != null && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
fontWeight={600}
|
||||
sx={{ color: 'success.dark', ml: 'auto', fontSize: '0.72rem', whiteSpace: 'nowrap' }}
|
||||
{/* Stored Purchase Price (Netto), VK Netto & VK Brutto */}
|
||||
{(priceNet != null || vkNetto != null || vkBrutto != null) && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
ml: 'auto',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
EK Netto: {formatPrice(priceNet)}
|
||||
{taxRate != null ? ` (${taxRate}% MwSt)` : ''}
|
||||
</Typography>
|
||||
{priceNet != null && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
fontWeight={600}
|
||||
sx={{ color: 'success.dark', fontSize: '0.72rem' }}
|
||||
>
|
||||
EK Netto: {formatPrice(priceNet)}
|
||||
{taxRate != null ? ` (${taxRate}% MwSt)` : ''}
|
||||
</Typography>
|
||||
)}
|
||||
{vkNetto != null && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
fontWeight={600}
|
||||
sx={{ color: 'text.secondary', fontSize: '0.72rem' }}
|
||||
>
|
||||
VK Netto: {formatPrice(vkNetto)}
|
||||
</Typography>
|
||||
)}
|
||||
{vkBrutto != null && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
fontWeight={600}
|
||||
sx={{ color: 'primary.dark', fontSize: '0.72rem' }}
|
||||
>
|
||||
VK Brutto: {formatPrice(vkBrutto)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Remove supplier-article connection */}
|
||||
{this.props.onRemoveSupplierArticle && supplierArticle.id != null && (
|
||||
<span style={{ marginLeft: priceNet != null ? 0 : 'auto' }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
aria-label="Lieferanten-Verknüpfung entfernen"
|
||||
disabled={this.props.saving || this.props.supplierArticleBusy}
|
||||
onClick={this.handleRemoveSupplierArticle}
|
||||
>
|
||||
<LinkOffOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
)}
|
||||
{this.renderLedgerChip()}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -258,10 +448,6 @@ class ManifestFormLine extends PureComponent {
|
||||
if (rawSku) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, py: 0.25 }}>
|
||||
<HelpOutlineIcon sx={{ fontSize: 15, color: 'warning.main' }} />
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.72rem' }}>
|
||||
Kein Lieferanten-Artikel für SKU „{rawSku}“ gefunden.
|
||||
</Typography>
|
||||
{this.props.onCreateArticle && (
|
||||
<Button
|
||||
size="small"
|
||||
@@ -306,34 +492,44 @@ class ManifestFormLine extends PureComponent {
|
||||
Artikel suchen
|
||||
</Button>
|
||||
)}
|
||||
<HelpOutlineIcon sx={{ fontSize: 15, color: 'warning.main' }} />
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.72rem' }}>
|
||||
Kein Lieferanten-Artikel für SKU „{rawSku}“ gefunden.
|
||||
</Typography>
|
||||
{this.renderLedgerChip()}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography variant="caption" color="text.disabled" sx={{ fontStyle: 'italic', fontSize: '0.7rem' }}>
|
||||
SKU eingeben oder Dokument scannen für automatische Artikelzuordnung
|
||||
</Typography>
|
||||
);
|
||||
if (this.props.ledgerLine) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, py: 0.25 }}>
|
||||
{this.renderLedgerChip()}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { columns = LINE_COLUMNS, saving } = this.props;
|
||||
const secondLine = this.renderSecondLine();
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{/* Row 1: Editable Line Inputs */}
|
||||
<TableRow hover sx={{ '& > td': { borderBottom: 'none', py: 0.75 } }}>
|
||||
{columns.map((col) => (
|
||||
<TableRow hover sx={{ '& > td': { borderBottom: 'none', p: 0 } }}>
|
||||
{columns.map((col, colIdx) => (
|
||||
<TableCell
|
||||
key={col.id}
|
||||
align={col.align}
|
||||
sx={{ py: 0.75, verticalAlign: 'middle' }}
|
||||
sx={{ p: 0, width: col.width, verticalAlign: 'middle' }}
|
||||
>
|
||||
{this.renderField(col)}
|
||||
{this.renderField(col, colIdx, columns.length)}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell padding="checkbox" sx={{ py: 0.75 }}>
|
||||
<TableCell padding="none" sx={{ py: 0.25, pl: 0.75, pr: 0, verticalAlign: 'middle', width: 40 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Zeile entfernen"
|
||||
@@ -346,18 +542,20 @@ class ManifestFormLine extends PureComponent {
|
||||
</TableRow>
|
||||
|
||||
{/* Row 2: Matched Supplier Article Details */}
|
||||
<TableRow sx={{ '& > td': { pt: 0, pb: 0.75, borderBottom: 'none' } }}>
|
||||
<TableCell
|
||||
colSpan={columns.length + 1}
|
||||
sx={{
|
||||
py: 0.4,
|
||||
px: 2,
|
||||
bgcolor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{this.renderSecondLine()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{secondLine && (
|
||||
<TableRow sx={{ '& > td': { pt: 0.25, pb: 0.75, borderBottom: 'none' } }}>
|
||||
<TableCell
|
||||
colSpan={columns.length + 1}
|
||||
sx={{
|
||||
py: 0.4,
|
||||
px: 1,
|
||||
bgcolor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{secondLine}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { PureComponent, createRef } from 'react';
|
||||
import Autocomplete from '@mui/material/Autocomplete';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||
import { PanelTile } from 'dashgui/panels';
|
||||
import FileDropzone from '../FileDropzone';
|
||||
import AttachmentList from './AttachmentList';
|
||||
import { manifestConfig, partyDisplayName } from './manifestConfig';
|
||||
import { hasArticleLines, manifestConfig, partyDisplayName } from './manifestConfig';
|
||||
import { notifyErrorMessage } from '../../utils/notify';
|
||||
|
||||
class ManifestHeader extends PureComponent {
|
||||
constructor(props) {
|
||||
@@ -18,20 +23,50 @@ class ManifestHeader extends PureComponent {
|
||||
this.handlePartyChange = this.handlePartyChange.bind(this);
|
||||
this.handleUploadClick = this.handleUploadClick.bind(this);
|
||||
this.handleFileChange = this.handleFileChange.bind(this);
|
||||
this.handleFileDrop = this.handleFileDrop.bind(this);
|
||||
}
|
||||
|
||||
handlePartyChange(_event, option) {
|
||||
this.props.onPartyChange(option?.id ?? null);
|
||||
}
|
||||
|
||||
hasArticles() {
|
||||
return Boolean(
|
||||
this.props.hasArticles ??
|
||||
(Array.isArray(this.props.lines) && hasArticleLines(this.props.lines)),
|
||||
);
|
||||
}
|
||||
|
||||
handleUploadClick() {
|
||||
if (this.hasArticles()) {
|
||||
notifyErrorMessage(
|
||||
'Datei-Upload nicht möglich, solange Artikel vorhanden sind. Bitte entfernen Sie zuerst alle Zeilen.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.fileInputRef.current?.click();
|
||||
}
|
||||
|
||||
handleFileDrop(file) {
|
||||
if (this.hasArticles()) {
|
||||
notifyErrorMessage(
|
||||
'Datei-Upload nicht möglich, solange Artikel vorhanden sind. Bitte entfernen Sie zuerst alle Zeilen.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.props.onPdfUpload?.(file);
|
||||
}
|
||||
|
||||
handleFileChange(event) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
if (this.hasArticles()) {
|
||||
notifyErrorMessage(
|
||||
'Datei-Upload nicht möglich, solange Artikel vorhanden sind. Bitte entfernen Sie zuerst alle Zeilen.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.props.onPdfUpload?.(file);
|
||||
}
|
||||
|
||||
@@ -50,6 +85,7 @@ class ManifestHeader extends PureComponent {
|
||||
saving,
|
||||
pdfUploading,
|
||||
attachments,
|
||||
lines,
|
||||
onPdfUpload,
|
||||
onPdfRescan,
|
||||
onPdfDelete,
|
||||
@@ -58,8 +94,15 @@ class ManifestHeader extends PureComponent {
|
||||
onDateChange,
|
||||
onNumberChange,
|
||||
onExternalNumberChange,
|
||||
onSave,
|
||||
actionsColor,
|
||||
docId,
|
||||
ledgerSummary,
|
||||
onOpenBookDialog,
|
||||
} = this.props;
|
||||
|
||||
const hasArticles = this.hasArticles();
|
||||
|
||||
// Delivery: number is server-allocated/read-only; the caller reference
|
||||
// lives in the editable externalNumber field.
|
||||
const cfg = manifestConfig(kind);
|
||||
@@ -68,117 +111,243 @@ class ManifestHeader extends PureComponent {
|
||||
const selected =
|
||||
parties.find((row) => row.id === partyId) ?? null;
|
||||
|
||||
const isDateEmpty = !date || String(date).trim() === '';
|
||||
const isPartyEmpty = partyId == null || partyId === '';
|
||||
|
||||
return (
|
||||
<PanelTile wide={3} minHeight={160} color={headerColor} title="Kopfdaten">
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
spacing={2}
|
||||
sx={{ alignItems: { xs: 'stretch', sm: 'center' } }}
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
type="date"
|
||||
label={dateLabel}
|
||||
value={date}
|
||||
disabled={saving || pdfUploading}
|
||||
onChange={(e) => onDateChange(e.target.value)}
|
||||
autoComplete="off"
|
||||
slotProps={{
|
||||
inputLabel: { shrink: true },
|
||||
htmlInput: { spellCheck: false },
|
||||
<>
|
||||
{/* Tile 1: 3-wide Kopfdaten Tile */}
|
||||
<PanelTile wide={3} minHeight={160} color={headerColor} title="Kopfdaten">
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 1.5,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
sx={{ minWidth: 220 }}
|
||||
/>
|
||||
<Autocomplete
|
||||
size="small"
|
||||
options={parties}
|
||||
value={selected}
|
||||
loading={Boolean(partiesLoading)}
|
||||
disabled={saving || pdfUploading}
|
||||
getOptionLabel={(option) => partyDisplayName(kind, option)}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
onChange={this.handlePartyChange}
|
||||
sx={{ flex: 1, minWidth: 220 }}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={partyLabel}
|
||||
slotProps={{
|
||||
...params.slotProps,
|
||||
htmlInput: {
|
||||
...params.slotProps?.htmlInput,
|
||||
spellCheck: false,
|
||||
autoComplete: 'off',
|
||||
},
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
type="date"
|
||||
label={dateLabel}
|
||||
value={date}
|
||||
error={isDateEmpty}
|
||||
disabled={saving || pdfUploading}
|
||||
onChange={(e) => onDateChange(e.target.value)}
|
||||
autoComplete="off"
|
||||
slotProps={{
|
||||
inputLabel: { shrink: true },
|
||||
htmlInput: { spellCheck: false },
|
||||
}}
|
||||
sx={{
|
||||
flex: { xs: '1 1 100%', sm: '1 1 150px' },
|
||||
minWidth: { sm: 130 },
|
||||
}}
|
||||
/>
|
||||
<Autocomplete
|
||||
size="small"
|
||||
options={parties}
|
||||
value={selected}
|
||||
loading={Boolean(partiesLoading)}
|
||||
disabled={saving || pdfUploading}
|
||||
getOptionLabel={(option) => partyDisplayName(kind, option)}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
onChange={this.handlePartyChange}
|
||||
sx={{
|
||||
flex: { xs: '1 1 100%', sm: '2 1 200px' },
|
||||
minWidth: { sm: 160 },
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={partyLabel}
|
||||
error={isPartyEmpty}
|
||||
slotProps={{
|
||||
...params.slotProps,
|
||||
htmlInput: {
|
||||
...params.slotProps?.htmlInput,
|
||||
spellCheck: false,
|
||||
autoComplete: 'off',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label="Nummer"
|
||||
value={number}
|
||||
disabled={saving || pdfUploading || useExternalNumber}
|
||||
onChange={onNumberChange}
|
||||
sx={{
|
||||
flex: { xs: '1 1 100%', sm: '1 1 120px' },
|
||||
minWidth: { sm: 100 },
|
||||
}}
|
||||
/>
|
||||
{useExternalNumber ? (
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label="Referenz"
|
||||
value={externalNumber ?? ''}
|
||||
disabled={saving || pdfUploading}
|
||||
onChange={onExternalNumberChange}
|
||||
sx={{
|
||||
flex: { xs: '1 1 100%', sm: '1 1 120px' },
|
||||
minWidth: { sm: 100 },
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{docId != null && kind === 'delivery' && ledgerSummary && (
|
||||
<Chip
|
||||
size="small"
|
||||
icon={
|
||||
ledgerSummary.status === 'booked' ? (
|
||||
<CheckCircleOutlineOutlinedIcon sx={{ fontSize: 16 }} />
|
||||
) : (
|
||||
<Inventory2OutlinedIcon sx={{ fontSize: 16 }} />
|
||||
)
|
||||
}
|
||||
label={
|
||||
ledgerSummary.status === 'booked'
|
||||
? `Lager: Gebucht (${ledgerSummary.bookedPositions}/${ledgerSummary.totalPositions})`
|
||||
: ledgerSummary.status === 'partial'
|
||||
? `Lager: Teilweise (${ledgerSummary.bookedPositions}/${ledgerSummary.totalPositions})`
|
||||
: ledgerSummary.status === 'empty'
|
||||
? 'Lager: Keine Pos.'
|
||||
: `Lager: Offen (${ledgerSummary.totalPositions} Pos.)`
|
||||
}
|
||||
color={
|
||||
ledgerSummary.status === 'booked'
|
||||
? 'success'
|
||||
: ledgerSummary.status === 'partial'
|
||||
? 'warning'
|
||||
: 'default'
|
||||
}
|
||||
variant="outlined"
|
||||
onClick={onOpenBookDialog}
|
||||
sx={{
|
||||
cursor: onOpenBookDialog ? 'pointer' : 'default',
|
||||
fontWeight: 600,
|
||||
height: 32,
|
||||
px: 0.5,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<AttachmentList
|
||||
attachments={attachments}
|
||||
onRescan={onPdfRescan}
|
||||
onDelete={onPdfDelete}
|
||||
onClick={onAttachmentClick ?? onPdfClick}
|
||||
disabled={saving || pdfUploading}
|
||||
hasArticles={hasArticles}
|
||||
/>
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label="Nummer"
|
||||
value={number}
|
||||
disabled={saving || pdfUploading || useExternalNumber}
|
||||
onChange={onNumberChange}
|
||||
sx={{ minWidth: 180, flex: 1 }}
|
||||
/>
|
||||
{useExternalNumber ? (
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label="Referenz"
|
||||
value={externalNumber ?? ''}
|
||||
disabled={saving || pdfUploading}
|
||||
onChange={onExternalNumberChange}
|
||||
sx={{ minWidth: 180, flex: 1 }}
|
||||
/>
|
||||
) : null}
|
||||
{onPdfUpload ? (
|
||||
<FileDropzone
|
||||
accept="application/pdf,.pdf,text/plain,.txt,text/csv,.csv"
|
||||
disabled={saving || pdfUploading}
|
||||
onFileDrop={onPdfUpload}
|
||||
</PanelTile>
|
||||
|
||||
{/* Tile 2: 1-wide Action Tile (4th tile in row) with Speichern & Datei hochladen */}
|
||||
<PanelTile
|
||||
wide={1}
|
||||
minHeight={160}
|
||||
color={actionsColor ?? '#f1f5f9'}
|
||||
sx={{
|
||||
gridColumn: { xs: '1 / -1', sm: '1 / -1', md: 'span 1' },
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1.5} sx={{ pt: 0.5 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={saving}
|
||||
onClick={onSave}
|
||||
sx={{
|
||||
alignSelf: { xs: 'stretch', sm: 'center' },
|
||||
flexShrink: 0,
|
||||
width: { xs: '100%', sm: 175 },
|
||||
height: 40,
|
||||
fontWeight: 700,
|
||||
fontSize: '0.875rem',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{({ isDragging }) => (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={pdfUploading ? <CircularProgress size={16} color="inherit" /> : <UploadFileIcon />}
|
||||
disabled={saving || pdfUploading}
|
||||
onClick={this.handleUploadClick}
|
||||
title="PDF, TXT oder CSV hochladen"
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
height: 40,
|
||||
width: '100%',
|
||||
px: 2,
|
||||
boxSizing: 'border-box',
|
||||
bgcolor: isDragging ? 'action.hover' : 'transparent',
|
||||
borderColor: isDragging ? 'primary.main' : undefined,
|
||||
transition: 'background-color 0.15s, border-color 0.15s',
|
||||
}}
|
||||
>
|
||||
{pdfUploading
|
||||
? 'Wird analysiert…'
|
||||
: isDragging
|
||||
? 'Datei hier ablegen'
|
||||
: 'Datei hochladen'}
|
||||
</Button>
|
||||
)}
|
||||
</FileDropzone>
|
||||
) : null}
|
||||
</Stack>
|
||||
<AttachmentList
|
||||
attachments={attachments}
|
||||
onRescan={onPdfRescan}
|
||||
onDelete={onPdfDelete}
|
||||
onClick={onAttachmentClick ?? onPdfClick}
|
||||
disabled={saving || pdfUploading}
|
||||
/>
|
||||
{saving ? 'Speichern…' : 'Speichern'}
|
||||
</Button>
|
||||
{docId != null && kind === 'delivery' && onOpenBookDialog && (
|
||||
<Button
|
||||
variant={ledgerSummary?.status === 'booked' ? 'outlined' : 'contained'}
|
||||
color={
|
||||
ledgerSummary?.status === 'booked'
|
||||
? 'success'
|
||||
: ledgerSummary?.status === 'partial'
|
||||
? 'warning'
|
||||
: 'primary'
|
||||
}
|
||||
startIcon={
|
||||
ledgerSummary?.status === 'booked' ? (
|
||||
<CheckCircleOutlineOutlinedIcon />
|
||||
) : (
|
||||
<Inventory2OutlinedIcon />
|
||||
)
|
||||
}
|
||||
disabled={saving || pdfUploading}
|
||||
onClick={onOpenBookDialog}
|
||||
sx={{
|
||||
height: 40,
|
||||
fontWeight: 700,
|
||||
fontSize: '0.8125rem',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{ledgerSummary?.status === 'booked'
|
||||
? 'Lager gebucht'
|
||||
: ledgerSummary?.status === 'partial'
|
||||
? 'Rest einbuchen'
|
||||
: 'Ins Lager buchen'}
|
||||
</Button>
|
||||
)}
|
||||
{onPdfUpload ? (
|
||||
<FileDropzone
|
||||
accept="application/pdf,.pdf,text/plain,.txt,text/csv,.csv"
|
||||
disabled={saving || pdfUploading}
|
||||
onFileDrop={this.handleFileDrop}
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{({ isDragging }) => (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={pdfUploading ? <CircularProgress size={16} color="inherit" /> : <UploadFileIcon />}
|
||||
disabled={saving || pdfUploading}
|
||||
onClick={this.handleUploadClick}
|
||||
title={
|
||||
hasArticles
|
||||
? 'Datei-Upload nicht möglich, solange Artikel vorhanden sind'
|
||||
: 'PDF, TXT oder CSV hochladen'
|
||||
}
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
height: 40,
|
||||
width: '100%',
|
||||
px: 1.5,
|
||||
boxSizing: 'border-box',
|
||||
bgcolor: isDragging ? 'action.hover' : 'transparent',
|
||||
borderColor: isDragging
|
||||
? 'primary.main'
|
||||
: hasArticles
|
||||
? 'action.disabled'
|
||||
: undefined,
|
||||
color: hasArticles ? 'text.disabled' : undefined,
|
||||
opacity: hasArticles ? 0.6 : 1,
|
||||
cursor: hasArticles ? 'not-allowed' : 'pointer',
|
||||
transition: 'background-color 0.15s, border-color 0.15s, opacity 0.15s',
|
||||
}}
|
||||
>
|
||||
{pdfUploading
|
||||
? 'Wird analysiert…'
|
||||
: isDragging
|
||||
? 'Datei hier ablegen'
|
||||
: 'Datei hochladen'}
|
||||
</Button>
|
||||
)}
|
||||
</FileDropzone>
|
||||
) : null}
|
||||
</Stack>
|
||||
</PanelTile>
|
||||
{onPdfUpload ? (
|
||||
<input
|
||||
ref={this.fileInputRef}
|
||||
@@ -188,7 +357,7 @@ class ManifestHeader extends PureComponent {
|
||||
onChange={this.handleFileChange}
|
||||
/>
|
||||
) : null}
|
||||
</PanelTile>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@ import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteSweepOutlinedIcon from '@mui/icons-material/DeleteSweepOutlined';
|
||||
import { PanelTile } from 'dashgui/panels';
|
||||
import { LINE_COLUMNS } from './manifestConfig';
|
||||
import { LINE_COLUMNS, hasArticleLines } from './manifestConfig';
|
||||
import ManifestFormLine from './ManifestFormLine';
|
||||
|
||||
class ManifestLines extends PureComponent {
|
||||
@@ -22,6 +23,7 @@ class ManifestLines extends PureComponent {
|
||||
onLineChange,
|
||||
onAddLine,
|
||||
onRemoveLine,
|
||||
onRemoveAllLines,
|
||||
onSave,
|
||||
columns = LINE_COLUMNS,
|
||||
supplierArticlesMap = {},
|
||||
@@ -33,11 +35,29 @@ class ManifestLines extends PureComponent {
|
||||
onSearchArticle,
|
||||
onRemoveSupplierArticle,
|
||||
busySupplierArticleLineKeys = {},
|
||||
wide = 3,
|
||||
wide = 4,
|
||||
ledgerLinesById = {},
|
||||
onBookLine,
|
||||
} = this.props;
|
||||
|
||||
const hasArticles = hasArticleLines(lines);
|
||||
|
||||
return (
|
||||
<PanelTile wide={wide} minHeight={280} color={linesColor} title="Artikel">
|
||||
<PanelTile
|
||||
wide={wide}
|
||||
minHeight={280}
|
||||
color={linesColor}
|
||||
title="Artikel"
|
||||
sx={{
|
||||
...(wide >= 4 ? { gridColumn: { xs: '1 / -1', sm: '1 / -1', md: 'span 4' } } : {}),
|
||||
...(!hasArticles
|
||||
? {
|
||||
border: '1.5px solid',
|
||||
borderColor: 'error.main',
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1.5}>
|
||||
<TableContainer>
|
||||
<Table size="small" stickyHeader>
|
||||
@@ -52,47 +72,53 @@ class ManifestLines extends PureComponent {
|
||||
whiteSpace: 'nowrap',
|
||||
bgcolor: linesColor,
|
||||
fontWeight: 700,
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
}}
|
||||
>
|
||||
{col.label}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell padding="checkbox" sx={{ bgcolor: linesColor, borderBottom: 'none' }} />
|
||||
<TableCell padding="none" sx={{ bgcolor: linesColor, borderBottom: 'none', width: 40 }} />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{lines.map((line, index) => {
|
||||
const lineKey = line.key ?? index;
|
||||
const rawSku = line.sku != null ? String(line.sku).trim() : '';
|
||||
const matched =
|
||||
supplierArticlesByLineKey[lineKey] ||
|
||||
(rawSku ? supplierArticlesMap[rawSku.toLowerCase()] || supplierArticlesMap[rawSku] : null);
|
||||
const isLineLoading =
|
||||
loadingLineKeys[lineKey] ||
|
||||
(supplierArticlesLoading && !matched);
|
||||
{Array.isArray(lines) &&
|
||||
lines.map((line, index) => {
|
||||
const lineKey = line.key ?? index;
|
||||
const rawSku = line.sku != null ? String(line.sku).trim() : '';
|
||||
const matched =
|
||||
supplierArticlesByLineKey[lineKey] ||
|
||||
(rawSku ? supplierArticlesMap[rawSku.toLowerCase()] || supplierArticlesMap[rawSku] : null);
|
||||
const isLineLoading =
|
||||
loadingLineKeys[lineKey] ||
|
||||
(supplierArticlesLoading && !matched);
|
||||
const ledgerLine = line.id != null ? ledgerLinesById[line.id] : null;
|
||||
|
||||
return (
|
||||
<ManifestFormLine
|
||||
key={lineKey}
|
||||
line={line.key != null ? line : { ...line, key: index }}
|
||||
columns={columns}
|
||||
saving={saving}
|
||||
supplierArticle={matched}
|
||||
loading={Boolean(isLineLoading)}
|
||||
supplierArticleBusy={Boolean(busySupplierArticleLineKeys[lineKey])}
|
||||
onChange={onLineChange}
|
||||
onRemove={onRemoveLine}
|
||||
onSkuBlur={onSkuBlur}
|
||||
onCreateArticle={onCreateArticle}
|
||||
onSearchArticle={onSearchArticle}
|
||||
onRemoveSupplierArticle={onRemoveSupplierArticle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<ManifestFormLine
|
||||
key={lineKey}
|
||||
line={line.key != null ? line : { ...line, key: index }}
|
||||
columns={columns}
|
||||
saving={saving}
|
||||
supplierArticle={matched}
|
||||
loading={Boolean(isLineLoading)}
|
||||
supplierArticleBusy={Boolean(busySupplierArticleLineKeys[lineKey])}
|
||||
onChange={onLineChange}
|
||||
onRemove={onRemoveLine}
|
||||
onSkuBlur={onSkuBlur}
|
||||
onCreateArticle={onCreateArticle}
|
||||
onSearchArticle={onSearchArticle}
|
||||
onRemoveSupplierArticle={onRemoveSupplierArticle}
|
||||
ledgerLine={ledgerLine}
|
||||
onBookLine={onBookLine}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center' }}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<AddIcon />}
|
||||
@@ -101,6 +127,15 @@ class ManifestLines extends PureComponent {
|
||||
>
|
||||
Zeile hinzufügen
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
startIcon={<DeleteSweepOutlinedIcon />}
|
||||
disabled={saving || !lines || lines.length === 0}
|
||||
onClick={onRemoveAllLines}
|
||||
>
|
||||
Alle Zeilen entfernen
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</PanelTile>
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { PureComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import BackendError from '../BackendError';
|
||||
import { RecordList } from 'dashgui/lists';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { ledger, delivery } from '../../api';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import { fetchDeliveryLedgerSummary } from '../../utils/deliveryLedger';
|
||||
import BookDeliveryDialog from './BookDeliveryDialog';
|
||||
import withNavigate from '../withNavigate';
|
||||
import {
|
||||
formatDate,
|
||||
@@ -27,12 +38,18 @@ class ManifestList extends PureComponent {
|
||||
docs: [],
|
||||
partiesById: {},
|
||||
busyId: null,
|
||||
ledgerSummaries: {},
|
||||
bookingDoc: null,
|
||||
bookingOpen: false,
|
||||
};
|
||||
this._cancelled = false;
|
||||
this.handleRetry = this.handleRetry.bind(this);
|
||||
this.handleCreate = this.handleCreate.bind(this);
|
||||
this.handleEdit = this.handleEdit.bind(this);
|
||||
this.handleDelete = this.handleDelete.bind(this);
|
||||
this.handleOpenBookDialog = this.handleOpenBookDialog.bind(this);
|
||||
this.handleCloseBookDialog = this.handleCloseBookDialog.bind(this);
|
||||
this.handleBookSuccess = this.handleBookSuccess.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -53,17 +70,59 @@ class ManifestList extends PureComponent {
|
||||
loadParties(this.props.kind, this.context),
|
||||
]);
|
||||
if (this._cancelled) return;
|
||||
const sortedDocs = sortDocs(docs);
|
||||
this.setState({
|
||||
status: 'ready',
|
||||
docs: sortDocs(docs),
|
||||
docs: sortedDocs,
|
||||
partiesById: cfg.partiesByIdFromList(parties),
|
||||
});
|
||||
|
||||
if (this.props.kind === 'delivery') {
|
||||
void this.loadLedgerSummaries(sortedDocs);
|
||||
}
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
this.setState({ status: 'failed', error: backendErrorFrom(err) });
|
||||
}
|
||||
}
|
||||
|
||||
async loadLedgerSummaries(docs) {
|
||||
if (!Array.isArray(docs) || docs.length === 0) return;
|
||||
const ledgerApi = this.context?.ledger || ledger;
|
||||
const deliveryApi = this.context?.delivery || delivery;
|
||||
|
||||
for (const doc of docs) {
|
||||
if (this._cancelled) return;
|
||||
this.setState((prev) => ({
|
||||
ledgerSummaries: {
|
||||
...prev.ledgerSummaries,
|
||||
[doc.id]: prev.ledgerSummaries[doc.id] || { status: 'loading' },
|
||||
},
|
||||
}));
|
||||
|
||||
fetchDeliveryLedgerSummary(doc.id, { ledgerApi, deliveryApi })
|
||||
.then((summary) => {
|
||||
if (this._cancelled) return;
|
||||
this.setState((prev) => ({
|
||||
ledgerSummaries: {
|
||||
...prev.ledgerSummaries,
|
||||
[doc.id]: summary,
|
||||
},
|
||||
}));
|
||||
})
|
||||
.catch((err) => {
|
||||
if (this._cancelled) return;
|
||||
console.error(`[ManifestList] Ledger summary failed for delivery ${doc.id}:`, err);
|
||||
this.setState((prev) => ({
|
||||
ledgerSummaries: {
|
||||
...prev.ledgerSummaries,
|
||||
[doc.id]: { status: 'error', error: err.message },
|
||||
},
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleRetry() {
|
||||
void this.load();
|
||||
}
|
||||
@@ -86,6 +145,24 @@ class ManifestList extends PureComponent {
|
||||
this.go(this.cfg.editPath(doc.id));
|
||||
}
|
||||
|
||||
handleOpenBookDialog(doc) {
|
||||
this.setState({ bookingDoc: doc, bookingOpen: true });
|
||||
}
|
||||
|
||||
handleCloseBookDialog() {
|
||||
this.setState({ bookingDoc: null, bookingOpen: false });
|
||||
}
|
||||
|
||||
handleBookSuccess(updatedSummary) {
|
||||
if (!updatedSummary?.deliveryId) return;
|
||||
this.setState((prev) => ({
|
||||
ledgerSummaries: {
|
||||
...prev.ledgerSummaries,
|
||||
[updatedSummary.deliveryId]: updatedSummary,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async handleDelete(doc) {
|
||||
if (this.state.busyId != null) return;
|
||||
if (!window.confirm(this.cfg.confirmDelete(doc.number))) return;
|
||||
@@ -96,9 +173,14 @@ class ManifestList extends PureComponent {
|
||||
await this.context[cfg.api].delete(doc.id);
|
||||
const list = await this.context[cfg.updateCache]();
|
||||
if (this._cancelled) return;
|
||||
this.setState({
|
||||
docs: sortDocs(list),
|
||||
busyId: null,
|
||||
this.setState((prev) => {
|
||||
const nextSummaries = { ...prev.ledgerSummaries };
|
||||
delete nextSummaries[doc.id];
|
||||
return {
|
||||
docs: sortDocs(list),
|
||||
busyId: null,
|
||||
ledgerSummaries: nextSummaries,
|
||||
};
|
||||
});
|
||||
enqueueSnackbar(cfg.deletedMsg, { variant: 'success' });
|
||||
} catch (err) {
|
||||
@@ -116,8 +198,141 @@ class ManifestList extends PureComponent {
|
||||
);
|
||||
}
|
||||
|
||||
renderLedgerSummary(row) {
|
||||
const summary = this.state.ledgerSummaries[row.id];
|
||||
|
||||
if (!summary || summary.status === 'loading') {
|
||||
return (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<CircularProgress size={14} color="inherit" sx={{ opacity: 0.6 }} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Prüfe...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (summary.status === 'error') {
|
||||
return (
|
||||
<Tooltip title={summary.error || 'Fehler beim Laden des Lagerstatus'}>
|
||||
<Chip size="small" label="Statusfehler" color="error" variant="outlined" />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (summary.status === 'empty') {
|
||||
return (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Keine Positionen
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
if (summary.status === 'booked') {
|
||||
return (
|
||||
<Tooltip
|
||||
title={`${summary.totalPositions} Positionen (${summary.bookedQuantity} Stk.) vollständig ins Lager gebucht`}
|
||||
>
|
||||
<Chip
|
||||
size="small"
|
||||
icon={<CheckCircleOutlineOutlinedIcon sx={{ '&&': { fontSize: 16 } }} />}
|
||||
label={`Gebucht (${summary.bookedPositions}/${summary.totalPositions})`}
|
||||
color="success"
|
||||
variant="outlined"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
this.handleOpenBookDialog(row);
|
||||
}}
|
||||
sx={{ fontWeight: 600, cursor: 'pointer' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (summary.status === 'partial') {
|
||||
return (
|
||||
<Box
|
||||
sx={{ display: 'inline-flex', alignItems: 'center', gap: 1 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Tooltip
|
||||
title={`${summary.bookedPositions} von ${summary.totalPositions} Positionen gebucht (${summary.bookedQuantity} / ${summary.totalQuantity} Stk.)`}
|
||||
>
|
||||
<Chip
|
||||
size="small"
|
||||
icon={<Inventory2OutlinedIcon sx={{ '&&': { fontSize: 16 } }} />}
|
||||
label={`Teilweise (${summary.bookedPositions}/${summary.totalPositions})`}
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
onClick={() => this.handleOpenBookDialog(row)}
|
||||
sx={{ fontWeight: 600, cursor: 'pointer' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="warning"
|
||||
onClick={() => this.handleOpenBookDialog(row)}
|
||||
sx={{
|
||||
py: 0.2,
|
||||
px: 1,
|
||||
minHeight: 26,
|
||||
textTransform: 'none',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Rest buchen
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: 'unbooked'
|
||||
return (
|
||||
<Box
|
||||
sx={{ display: 'inline-flex', alignItems: 'center', gap: 1 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Tooltip
|
||||
title={`Noch nicht ins Lager gebucht (${summary.totalPositions} Positionen, ${summary.totalQuantity} Stk.)`}
|
||||
>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`Offen (${summary.totalPositions} Pos.)`}
|
||||
variant="outlined"
|
||||
onClick={() => this.handleOpenBookDialog(row)}
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
borderColor: 'divider',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<Inventory2OutlinedIcon sx={{ fontSize: 16 }} />}
|
||||
onClick={() => this.handleOpenBookDialog(row)}
|
||||
sx={{
|
||||
py: 0.2,
|
||||
px: 1.2,
|
||||
minHeight: 26,
|
||||
textTransform: 'none',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Ins Lager buchen
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { status, error, docs, busyId } = this.state;
|
||||
const { status, error, docs, busyId, bookingDoc, bookingOpen, ledgerSummaries } = this.state;
|
||||
const cfg = this.cfg;
|
||||
const partyField = cfg.partyField;
|
||||
|
||||
@@ -142,22 +357,43 @@ class ManifestList extends PureComponent {
|
||||
},
|
||||
);
|
||||
|
||||
if (this.props.kind === 'delivery') {
|
||||
columns.push({
|
||||
key: 'ledgerStatus',
|
||||
label: 'Lagerbuchung',
|
||||
render: (row) => this.renderLedgerSummary(row),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<RecordList
|
||||
status={status}
|
||||
error={<BackendError error={error} onRetry={this.handleRetry} />}
|
||||
rows={docs}
|
||||
columns={columns}
|
||||
createLabel={cfg.createButton}
|
||||
onCreate={this.handleCreate}
|
||||
onEdit={this.handleEdit}
|
||||
onDelete={this.handleDelete}
|
||||
deleteDisabled={busyId != null}
|
||||
emptyMessage={cfg.emptyList}
|
||||
color={cfg.listColor}
|
||||
getEditLabel={(row) => `„${row.number}“ bearbeiten`}
|
||||
getDeleteLabel={(row) => `„${row.number}“ löschen`}
|
||||
/>
|
||||
<>
|
||||
<RecordList
|
||||
status={status}
|
||||
error={<BackendError error={error} onRetry={this.handleRetry} />}
|
||||
rows={docs}
|
||||
columns={columns}
|
||||
createLabel={cfg.createButton}
|
||||
onCreate={this.handleCreate}
|
||||
onEdit={this.handleEdit}
|
||||
onDelete={this.handleDelete}
|
||||
deleteDisabled={busyId != null}
|
||||
emptyMessage={cfg.emptyList}
|
||||
color={cfg.listColor}
|
||||
getEditLabel={(row) => `„${row.number}“ bearbeiten`}
|
||||
getDeleteLabel={(row) => `„${row.number}“ löschen`}
|
||||
/>
|
||||
|
||||
{this.props.kind === 'delivery' && (
|
||||
<BookDeliveryDialog
|
||||
open={bookingOpen}
|
||||
delivery={bookingDoc}
|
||||
summary={bookingDoc ? ledgerSummaries[bookingDoc.id] : null}
|
||||
partyName={bookingDoc ? this.partyName(bookingDoc[partyField]) : null}
|
||||
onClose={this.handleCloseBookDialog}
|
||||
onBookSuccess={this.handleBookSuccess}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export { default as AttachmentList } from './AttachmentList';
|
||||
export { default as TextEditorPanel } from './TextEditorPanel';
|
||||
export { default as PdfViewerPanel } from './PdfViewerPanel';
|
||||
export { default as ArticleSearchDialog } from './ArticleSearchDialog';
|
||||
export { default as BookDeliveryDialog } from './BookDeliveryDialog';
|
||||
export { withManifestId } from './withManifestId';
|
||||
export {
|
||||
manifestConfig,
|
||||
|
||||
@@ -80,44 +80,101 @@ export function emptyLine() {
|
||||
}
|
||||
|
||||
export function lineFromApi(row) {
|
||||
const netVal = row.netprice != null ? String(row.netprice) : row.net != null ? String(row.net) : '';
|
||||
const taxVal = row.taxrate != null ? String(row.taxrate) : row.tax != null ? String(row.tax) : '';
|
||||
return {
|
||||
id: row.id,
|
||||
key: `line-${row.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`}`,
|
||||
amount: row.amount == null ? '' : String(row.amount),
|
||||
sku: row.sku ?? '',
|
||||
tax: row.tax == null ? '' : String(row.tax),
|
||||
tax: taxVal,
|
||||
rebate: row.rebate == null ? '' : String(row.rebate),
|
||||
net: row.net == null ? '' : String(row.net),
|
||||
net: netVal,
|
||||
name: row.name ?? '',
|
||||
pu: row.pu == null ? '' : String(row.pu),
|
||||
unit: row.unit ?? '',
|
||||
netprice: row.netprice != null ? String(row.netprice) : row.net != null ? String(row.net) : '',
|
||||
taxrate: row.taxrate != null ? String(row.taxrate) : row.tax != null ? String(row.tax) : '',
|
||||
netprice: netVal,
|
||||
taxrate: taxVal,
|
||||
};
|
||||
}
|
||||
|
||||
function parseNum(value) {
|
||||
export function parseNum(value) {
|
||||
if (value == null || value === '') return null;
|
||||
const n = Number(String(value).trim().replace(',', '.'));
|
||||
const cleaned = String(value).trim().replace('%', '').trim().replace(',', '.');
|
||||
if (cleaned === '') return null;
|
||||
const n = Number(cleaned);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export function getFirstVal(...vals) {
|
||||
for (const v of vals) {
|
||||
if (v != null && String(v).trim() !== '') return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasArticleLines(lines) {
|
||||
if (!Array.isArray(lines) || lines.length === 0) return false;
|
||||
return lines.some((l) => {
|
||||
if (!l) return false;
|
||||
const sku = String(l.sku ?? '').trim();
|
||||
const name = String(l.name ?? '').trim();
|
||||
const amount = String(l.amount ?? '').trim();
|
||||
const net = String(l.net ?? l.netprice ?? '').trim();
|
||||
return Boolean(sku || name || amount || net);
|
||||
});
|
||||
}
|
||||
|
||||
/** Build create/update line array. Throws Error on client validation. */
|
||||
export function buildLines(lines, { requireAtLeastOne }) {
|
||||
const outLines = [];
|
||||
for (const line of lines) {
|
||||
const errors = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const sku = String(line.sku ?? '').trim();
|
||||
const name = String(line.name ?? '').trim();
|
||||
const amount = parseNum(line.amount);
|
||||
const net = parseNum(line.net ?? line.netprice);
|
||||
const tax = parseNum(line.tax ?? line.taxrate);
|
||||
const rebate = parseNum(line.rebate);
|
||||
const pu = parseNum(line.pu);
|
||||
const rawAmount = getFirstVal(line.amount, line.count);
|
||||
const amount = parseNum(rawAmount);
|
||||
const rawNet = getFirstVal(line.net, line.netprice, line.netPrice, line.eknetto);
|
||||
const net = parseNum(rawNet);
|
||||
const rawTax = getFirstVal(line.tax, line.taxrate, line.taxRate, line.mwst);
|
||||
const tax = parseNum(rawTax);
|
||||
const rebate = parseNum(getFirstVal(line.rebate));
|
||||
const rawPu = getFirstVal(line.pu, line.vpe);
|
||||
const pu = parseNum(rawPu);
|
||||
const blank =
|
||||
!sku && !name && amount == null && net == null && tax == null && rebate == null;
|
||||
if (blank) continue;
|
||||
if ((!sku && !name) || amount == null || net == null || tax == null) {
|
||||
throw new Error('Jede Zeile braucht SKU oder Name, Amount, Net und Taxrate.');
|
||||
|
||||
const missing = [];
|
||||
if (!sku && !name) {
|
||||
missing.push('Artikel (SKU oder Name)');
|
||||
}
|
||||
if (amount == null) {
|
||||
missing.push(rawAmount != null ? 'gültige Menge' : 'Menge');
|
||||
}
|
||||
if ('pu' in line && pu == null) {
|
||||
missing.push(rawPu != null ? 'gültige VPE (PU)' : 'VPE (PU)');
|
||||
}
|
||||
if (net == null) {
|
||||
missing.push(rawNet != null ? 'gültiger Nettopreis' : 'Nettopreis');
|
||||
}
|
||||
if (tax == null) {
|
||||
missing.push(rawTax != null ? 'gültiger Steuersatz' : 'Steuersatz');
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
const identifier = name || sku;
|
||||
const rowLabel = `Zeile ${i + 1}${identifier ? ` („${identifier}“)` : ''}`;
|
||||
const fieldsText =
|
||||
missing.length === 1
|
||||
? `${missing[0]} fehlt`
|
||||
: `${missing.slice(0, -1).join(', ')} und ${missing[missing.length - 1]} fehlen`;
|
||||
errors.push(`${rowLabel}: ${fieldsText}.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const row = { amount, net, taxrate: tax };
|
||||
if (sku) row.sku = sku;
|
||||
if (name) row.name = name;
|
||||
@@ -125,6 +182,18 @@ export function buildLines(lines, { requireAtLeastOne }) {
|
||||
if (pu != null) row.pu = pu;
|
||||
outLines.push(row);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
if (errors.length <= 3) {
|
||||
throw new Error(errors.join(' '));
|
||||
}
|
||||
const visibleErrors = errors.slice(0, 3).join(' ');
|
||||
const remainingCount = errors.length - 3;
|
||||
throw new Error(
|
||||
`${visibleErrors} (und ${remainingCount} weitere fehlerhafte Zeile${remainingCount === 1 ? '' : 'n'})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (requireAtLeastOne && outLines.length === 0) {
|
||||
throw new Error('Mindestens eine Artikelzeile nötig.');
|
||||
}
|
||||
@@ -480,14 +549,14 @@ export function manifestConfig(kind) {
|
||||
deletedMsg: 'Lieferung gelöscht.',
|
||||
confirmDelete: (number) => `Lieferung „${number}“ wirklich löschen?`,
|
||||
invalidId: 'Ungültige Lieferungs-ID.',
|
||||
partyGateMessage: 'Lieferant wählen, um Artikel zu erfassen.',
|
||||
partyGateMessage: null,
|
||||
getPathLabel: 'GET /api/delivery/:id',
|
||||
postPathLabel: 'POST /api/delivery',
|
||||
patchPathLabel: 'PATCH /api/delivery/:id',
|
||||
listColor: '#fed7aa',
|
||||
headerColor: '#fed7aa',
|
||||
linesColor: '#bbf7d0',
|
||||
requirePartyBeforeLines: true,
|
||||
requirePartyBeforeLines: false,
|
||||
updateCache: 'updateDelivery',
|
||||
listPromise: (ctx) => ctx.deliveries ?? ctx.deliveryPromise,
|
||||
partiesByIdFromList: (parties) =>
|
||||
|
||||
Reference in New Issue
Block a user