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

View File

@@ -11,6 +11,7 @@
"cert": "./scripts/generate-cert.sh",
"build": "vite build",
"preview": "vite preview",
"preview:https": "VITE_HTTPS=1 vite preview",
"lint": "eslint ."
},
"dependencies": {

View File

@@ -192,6 +192,22 @@ export class ApiClient {
},
};
this.ledger = {
/** POST /api/ledger → 201 { ledger: LedgerEntry } */
add: (body) => this.#json('POST', '/api/ledger', { body }),
/** GET /api/ledger/manifestarticle/{id} → { ledger: LedgerEntry[] } */
listByManifestarticleId: (manifestarticleId, transactionType) =>
this.#json('GET', `/api/ledger/manifestarticle/${enc(manifestarticleId)}`, {
query: transactionType ? { transaction_type: transactionType } : undefined,
}),
/** GET /api/ledger/sku/{sku} → { ledger: LedgerEntry[] } */
listBySku: (sku, transactionType) =>
this.#json('GET', `/api/ledger/sku/${enc(sku)}`, {
query: transactionType ? { transaction_type: transactionType } : undefined,
}),
/** GET /api/ledger → { ledger: LedgerEntry[] } */
list: (query) => this.#json('GET', '/api/ledger', { query }),
};
this.quote = {
/** GET /api/quote → { quotes } */

View File

@@ -782,6 +782,58 @@ export const delivery = {
},
};
/**
* Append-only stock and payment ledger.
* Entry = { id, manifestarticle_id, sku, transaction_type, amount, transaction_text, hash_version, prev_hash, created_at }
* Input = { manifestarticle_id, transaction_type, amount, transaction_text }
*/
export const ledger = {
/**
* POST /api/ledger
* Body: { manifestarticle_id, transaction_type, amount, transaction_text }
* → 201 { ledger: LedgerEntry }
*/
add(body) {
return http('/api/ledger', { method: 'POST', body });
},
/**
* GET /api/ledger/manifestarticle/:manifestarticleId
* Query: ?transaction_type=...
* → 200 { ledger: LedgerEntry[] }
*/
listByManifestarticleId(manifestarticleId, transactionType) {
const query = transactionType ? `?transaction_type=${encodeURIComponent(transactionType)}` : '';
return http(`/api/ledger/manifestarticle/${encodeURIComponent(manifestarticleId)}${query}`);
},
/**
* GET /api/ledger/sku/:sku
* Query: ?transaction_type=...
* → 200 { ledger: LedgerEntry[] }
*/
listBySku(sku, transactionType) {
const query = transactionType ? `?transaction_type=${encodeURIComponent(transactionType)}` : '';
return http(`/api/ledger/sku/${encodeURIComponent(sku)}${query}`);
},
/**
* GET /api/ledger
* Query: { manifestarticle_id?, sku?, transaction_type? }
* → 200 { ledger: LedgerEntry[] }
*/
list(query) {
const params = new URLSearchParams();
if (query?.manifestarticle_id != null) params.set('manifestarticle_id', query.manifestarticle_id);
if (query?.manifestarticleId != null) params.set('manifestarticle_id', query.manifestarticleId);
if (query?.sku != null) params.set('sku', query.sku);
if (query?.transaction_type != null) params.set('transaction_type', query.transaction_type);
if (query?.transactionType != null) params.set('transaction_type', query.transactionType);
const qs = params.toString();
return http(`/api/ledger${qs ? `?${qs}` : ''}`);
},
};
/**
* Sales quotes (manifest type = quote).
* Summary = { id, number, type: 'quote', entityId, date }
@@ -1204,6 +1256,7 @@ export default {
customergroup,
delivery,
entity,
ledger,
number,
order,
posterminal,

View File

@@ -55,6 +55,8 @@ import { article as articleApi, supplier as supplierApi } from '../api';
* @returns {DeliveryLine} Normalized line.
*/
export function createEmptyLineitem(line = {}) {
const netVal = line.netprice ?? line.net ?? null;
const taxVal = line.taxrate ?? line.tax ?? null;
return {
key: line.key ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: line.name ?? null,
@@ -62,8 +64,10 @@ export function createEmptyLineitem(line = {}) {
amount: line.amount ?? null,
pu: line.pu ?? null,
unit: line.unit ?? null,
netprice: line.netprice ?? null,
taxrate: line.taxrate ?? null,
netprice: netVal,
net: netVal,
taxrate: taxVal,
tax: taxVal,
};
}
@@ -357,6 +361,20 @@ export function createDeliveryEditorAutomat(options = {}) {
automat.actions.removeLineitem(payload);
},
/** Clears all lines from the editor. @returns {void} */
clearLineitems() {
automat.setState({
lines: [],
lineitems: [],
supplierArticlesByLineKey: {},
});
},
/** Clears all lines from the editor. @returns {void} */
clearLines() {
automat.actions.clearLineitems();
},
/**
* Loads supplier articles for all current SKUs and rebuilds line matches.
* @param {string|number|null} [targetEntityId] Supplier to query.
@@ -658,6 +676,8 @@ export const {
updateLine,
removeLineitem,
removeLine,
clearLineitems,
clearLines,
fetchSupplierArticles,
fetchSupplierArticleForSku,
unlinkSupplierArticle,

View File

@@ -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 }} />

View File

@@ -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>

View File

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

View File

@@ -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}

View File

@@ -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 &bdquo;{rawSku}&ldquo; 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 &bdquo;{rawSku}&ldquo; 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>
);
}

View File

@@ -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>
</>
);
}
}

View File

@@ -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>

View File

@@ -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}
/>
)}
</>
);
}
}

View File

@@ -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,

View File

@@ -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) =>

View File

@@ -6,6 +6,17 @@ import CircularProgress from '@mui/material/CircularProgress';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import Tooltip from '@mui/material/Tooltip';
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 Paper from '@mui/material/Paper';
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import { enqueueSnackbar } from 'notistack';
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import BackendError from '../../components/BackendError';
@@ -24,6 +35,7 @@ import {
TextEditorPanel,
PdfViewerPanel,
ArticleSearchDialog,
BookDeliveryDialog,
} from '../../components/manifest';
import ManifestPdfDialog from '../../components/manifest/ManifestPdfDialog';
import { loadParties } from '../../components/manifest/manifestConfig';
@@ -36,7 +48,8 @@ import { generateTextThumbnail } from '../../utils/textThumbnail';
import { extractPdfText } from '../../utils/pdfText';
import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat';
import { setLastDeliveryPath, userSettingsAutomat, setViewerPosition } from '../../automats';
import { delivery as deliveryApi } from '../../api';
import { delivery as deliveryApi, ledger as defaultLedgerApi } from '../../api';
import { fetchDeliveryLedgerSummary } from '../../utils/deliveryLedger';
class LieferungNeuPage extends PureComponent {
static contextType = StorageManagerContext;
@@ -63,18 +76,26 @@ class LieferungNeuPage extends PureComponent {
articleSearchDialogOpen: false,
articleSearchLine: null,
unmatchedSupplierName: null,
ledgerSummary: null,
bookDeliveryDialogOpen: false,
bookDeliveryPrefillLineId: null,
};
this._cancelled = false;
this.handleRetry = this.handleRetry.bind(this);
this.handlePartyChange = this.handlePartyChange.bind(this);
this.handleCreateSupplierFromPdf = this.handleCreateSupplierFromPdf.bind(this);
this.handleDateChange = this.handleDateChange.bind(this);
this.handleOpenBookDialog = this.handleOpenBookDialog.bind(this);
this.handleBookLine = this.handleBookLine.bind(this);
this.handleCloseBookDialog = this.handleCloseBookDialog.bind(this);
this.handleBookSuccess = this.handleBookSuccess.bind(this);
this.handleNumberChange = this.handleNumberChange.bind(this);
this.handleExternalNumberChange = this.handleExternalNumberChange.bind(this);
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.handlePdfRescan = this.handlePdfRescan.bind(this);
@@ -206,9 +227,22 @@ class LieferungNeuPage extends PureComponent {
const attachments = await restoreAttachmentsFromApi(apiFiles, this.state.attachments);
this.automat.actions.setAttachments(attachments);
let ledgerSummary = null;
try {
const ledgerClient = this.context?.ledger || defaultLedgerApi;
const deliveryClient = this.context?.[cfg.api] || deliveryApi;
ledgerSummary = await fetchDeliveryLedgerSummary(docId, {
ledgerApi: ledgerClient,
deliveryApi: deliveryClient,
});
} catch (lErr) {
console.error('[neu] Failed to load ledger summary:', lErr);
}
this.setState({
status: 'ready',
partiesLoading: false,
ledgerSummary,
});
} catch (err) {
if (this._cancelled) return;
@@ -220,6 +254,43 @@ class LieferungNeuPage extends PureComponent {
}
}
handleOpenBookDialog() {
this.setState({ bookDeliveryDialogOpen: true, bookDeliveryPrefillLineId: null });
}
handleBookLine(ledgerLine) {
this.setState({
bookDeliveryDialogOpen: true,
bookDeliveryPrefillLineId: ledgerLine?.id ?? null,
});
}
handleCloseBookDialog() {
this.setState({ bookDeliveryDialogOpen: false, bookDeliveryPrefillLineId: null });
}
async reloadLedgerSummary() {
const { docId } = this.props;
if (docId == null) return;
try {
const ledgerClient = this.context?.ledger || defaultLedgerApi;
const deliveryClient = this.context?.[this.cfg.api] || deliveryApi;
const ledgerSummary = await fetchDeliveryLedgerSummary(docId, {
ledgerApi: ledgerClient,
deliveryApi: deliveryClient,
});
if (this._cancelled) return;
this.setState({ ledgerSummary });
} catch (err) {
console.error('[neu] Failed to reload ledger summary:', err);
}
}
handleBookSuccess(updatedSummary) {
this.setState({ ledgerSummary: updatedSummary });
void this.reloadLedgerSummary();
}
handleRetry() {
if (this.props.docId != null) void this.loadEdit();
else void this.loadPartiesOnly();
@@ -271,7 +342,9 @@ class LieferungNeuPage extends PureComponent {
key,
[field]: value,
...(field === 'net' ? { netprice: value } : {}),
...(field === 'netprice' ? { net: value } : {}),
...(field === 'tax' ? { taxrate: value } : {}),
...(field === 'taxrate' ? { tax: value } : {}),
});
if (field === 'sku') {
@@ -339,7 +412,7 @@ class LieferungNeuPage extends PureComponent {
entityId: partyId,
sku: supplierSku || undefined,
pu: line.pu ? Number(line.pu) : undefined,
stock: line.amount ? Number(line.amount) : undefined,
stock: 0,
netPrice: line.netprice ? Number(line.netprice) : undefined,
taxRate: line.taxrate ? Number(line.taxrate) : undefined,
});
@@ -389,7 +462,7 @@ class LieferungNeuPage extends PureComponent {
entityId: partyId,
sku: supplierSku || undefined,
pu: line?.pu ? Number(line.pu) : undefined,
stock: line?.amount ? Number(line.amount) : undefined,
stock: 0,
netPrice: line?.netprice ? Number(line.netprice) : undefined,
taxRate: line?.taxrate ? Number(line.taxrate) : undefined,
});
@@ -445,6 +518,10 @@ class LieferungNeuPage extends PureComponent {
this.automat.actions.removeLineitem(index >= 0 ? index : key);
}
handleRemoveAllLines() {
this.automat.actions.clearLineitems();
}
async handlePdfUpload(file) {
if (!file || this.state.pdfUploading) return;
@@ -661,18 +738,26 @@ class LieferungNeuPage extends PureComponent {
? result.items.items
: null;
const newLines = Array.isArray(rawItems)
? rawItems.map((l, idx) => ({
key: l.key ?? `${Date.now()}-${idx}-${Math.random().toString(36).slice(2, 8)}`,
name: l.name ?? l.articleName ?? null,
sku: l.sku ?? l.liefArtNr ?? '',
amount: (l.amount ?? l.count) != null ? String(l.amount ?? l.count) : '',
pu: (l.pu ?? l.vpe) != null ? String(l.pu ?? l.vpe) : '',
unit: l.unit ?? '',
taxrate: (l.taxrate ?? l.tax ?? l.mwst) != null ? String(l.taxrate ?? l.tax ?? l.mwst) : '',
netprice: (l.netprice ?? l.net ?? (l.eknetto != null ? (l.eknetto / 100).toFixed(2) : null)) != null
? String(l.netprice ?? l.net ?? (l.eknetto != null ? (l.eknetto / 100).toFixed(2) : ''))
: '',
}))
? rawItems.map((l, idx) => {
const taxVal = (l.taxrate ?? l.tax ?? l.taxRate ?? l.mwst) != null
? String(l.taxrate ?? l.tax ?? l.taxRate ?? l.mwst)
: '';
const netVal = (l.netprice ?? l.net ?? l.netPrice ?? (l.eknetto != null ? (l.eknetto / 100).toFixed(2) : null)) != null
? String(l.netprice ?? l.net ?? l.netPrice ?? (l.eknetto != null ? (l.eknetto / 100).toFixed(2) : ''))
: '';
return {
key: l.key ?? `${Date.now()}-${idx}-${Math.random().toString(36).slice(2, 8)}`,
name: l.name ?? l.articleName ?? null,
sku: l.sku ?? l.liefArtNr ?? '',
amount: (l.amount ?? l.count) != null ? String(l.amount ?? l.count) : '',
pu: (l.pu ?? l.vpe) != null ? String(l.pu ?? l.vpe) : '',
unit: l.unit ?? '',
taxrate: taxVal,
tax: taxVal,
netprice: netVal,
net: netVal,
};
})
: null;
this.setState((prev) => ({
@@ -1108,6 +1193,7 @@ class LieferungNeuPage extends PureComponent {
if (doc.number != null) this.automat.actions.setNumber(doc.number);
if (doc.externalNumber != null) this.automat.actions.setExternalNumber(String(doc.externalNumber));
this.automat.actions.setLineitems(lines);
void this.reloadLedgerSummary();
// Restore attachments with thumbnails
const apiFiles = doc.files ?? [];
@@ -1157,18 +1243,15 @@ class LieferungNeuPage extends PureComponent {
return <BackendError error={error} onRetry={this.handleRetry} />;
}
const ledgerLinesById = {};
if (Array.isArray(this.state.ledgerSummary?.lines)) {
for (const l of this.state.ledgerSummary.lines) {
ledgerLinesById[l.id] = l;
}
}
return (
<>
<PageTitleRight>
<Button
variant="contained"
size="small"
disabled={saving}
onClick={this.handleSave}
>
{saving ? 'Speichern…' : 'Speichern'}
</Button>
</PageTitleRight>
<PanelTileGrid>
<ManifestHeader
kind="delivery"
@@ -1184,95 +1267,73 @@ class LieferungNeuPage extends PureComponent {
saving={saving}
pdfUploading={pdfUploading}
attachments={attachments}
lines={lines}
onPdfUpload={this.handlePdfUpload}
onPdfRescan={this.handlePdfRescan}
onPdfDelete={this.handlePdfDelete}
onAttachmentClick={this.handleAttachmentClick}
onSave={this.handleSave}
onPartyChange={this.handlePartyChange}
onDateChange={this.handleDateChange}
onNumberChange={this.handleNumberChange}
onExternalNumberChange={this.handleExternalNumberChange}
docId={this.props.docId}
ledgerSummary={this.state.ledgerSummary}
onOpenBookDialog={this.handleOpenBookDialog}
/>
{this.state.viewerPosition === 'top' && this.renderViewerPanels(3)}
{partyId == null && (!lines || lines.length === 0) ? (
<PanelTile wide={3} minHeight={160} color="#e2e8f0">
{this.state.unmatchedSupplierName ? (
<Stack spacing={2} sx={{ p: 1 }}>
<Typography color="text.secondary">
Lieferant <strong>„{this.state.unmatchedSupplierName}“</strong> wurde im Dokument erkannt, existiert aber noch nicht in den Stammdaten.
</Typography>
<Box>
<Button
variant="contained"
size="small"
onClick={() => this.handleCreateSupplierFromPdf(this.state.unmatchedSupplierName)}
>
Lieferant „{this.state.unmatchedSupplierName}“ anlegen
</Button>
</Box>
</Stack>
) : (
<Typography color="text.secondary">
{cfg.partyGateMessage}
</Typography>
)}
</PanelTile>
) : (
<>
{partyId == null && (
<PanelTile wide={3} color="#fef3c7">
<Alert
severity="warning"
action={
this.state.unmatchedSupplierName ? (
<Button
color="inherit"
size="small"
variant="outlined"
onClick={() => this.handleCreateSupplierFromPdf(this.state.unmatchedSupplierName)}
>
Lieferant anlegen
</Button>
) : null
}
sx={{ py: 0.5, alignItems: 'center' }}
{this.state.viewerPosition === 'top' && this.renderViewerPanels(4)}
{this.state.unmatchedSupplierName && (
<PanelTile wide={4} color="#fef3c7" sx={{ gridColumn: { xs: '1 / -1', sm: '1 / -1', md: 'span 4' } }}>
<Alert
severity="warning"
action={
<Button
color="inherit"
size="small"
variant="outlined"
onClick={() => this.handleCreateSupplierFromPdf(this.state.unmatchedSupplierName)}
>
{this.state.unmatchedSupplierName
? `Lieferant „${this.state.unmatchedSupplierName}“ wurde im Dokument erkannt, existiert aber noch nicht in den Stammdaten.`
: 'Bitte wählen Sie oben einen Lieferanten aus, um die erfassten Positionen speichern zu können.'}
</Alert>
</PanelTile>
)}
<ManifestLines
columns={DELIVERY_LINE_COLUMNS}
lines={lines}
saving={saving}
wide={
this.state.viewerPosition === 'right' && this.isViewerOpen()
? 2
: 3
Lieferant anlegen
</Button>
}
linesColor={cfg.linesColor}
supplierArticlesMap={this.state.supplierArticlesMap}
supplierArticlesByLineKey={this.state.supplierArticlesByLineKey}
supplierArticlesLoading={this.state.supplierArticlesLoading}
loadingLineKeys={this.state.loadingLineKeys}
onLineChange={this.handleLineChange}
onSkuBlur={this.handleSkuBlur}
onAddLine={this.handleAddLine}
onRemoveLine={this.handleRemoveLine}
onSave={() => void this.handleSave()}
onCreateArticle={this.handleCreateArticleForLine}
onSearchArticle={this.handleOpenArticleSearchForLine}
onRemoveSupplierArticle={this.handleRemoveSupplierArticle}
busySupplierArticleLineKeys={this.state.busySupplierArticleLineKeys}
/>
</>
sx={{ py: 0.5, alignItems: 'center' }}
>
Lieferant „{this.state.unmatchedSupplierName}“ wurde im Dokument erkannt, existiert aber noch nicht in den Stammdaten.
</Alert>
</PanelTile>
)}
<ManifestLines
columns={DELIVERY_LINE_COLUMNS}
lines={lines}
saving={saving}
wide={
this.state.viewerPosition === 'right' && this.isViewerOpen()
? 2
: 4
}
linesColor={cfg.linesColor}
supplierArticlesMap={this.state.supplierArticlesMap}
supplierArticlesByLineKey={this.state.supplierArticlesByLineKey}
supplierArticlesLoading={this.state.supplierArticlesLoading}
loadingLineKeys={this.state.loadingLineKeys}
onLineChange={this.handleLineChange}
onSkuBlur={this.handleSkuBlur}
onAddLine={this.handleAddLine}
onRemoveLine={this.handleRemoveLine}
onRemoveAllLines={this.handleRemoveAllLines}
onSave={() => void this.handleSave()}
onCreateArticle={this.handleCreateArticleForLine}
onSearchArticle={this.handleOpenArticleSearchForLine}
onRemoveSupplierArticle={this.handleRemoveSupplierArticle}
busySupplierArticleLineKeys={this.state.busySupplierArticleLineKeys}
ledgerLinesById={ledgerLinesById}
onBookLine={this.handleBookLine}
/>
{this.state.viewerPosition === 'right' && this.renderViewerPanels(2)}
{this.state.viewerPosition === 'below' && this.renderViewerPanels(3)}
{this.state.viewerPosition === 'below' && this.renderViewerPanels(4)}
{this.props.docId != null && this.renderLedgerPanel()}
{notes && notes.length > 0 && (
<PanelTile wide={3} color="#fffbeb">
<PanelTile wide={4} color="#fffbeb" sx={{ gridColumn: { xs: '1 / -1', sm: '1 / -1', md: 'span 4' } }}>
<Typography variant="subtitle2" fontWeight={700} color="warning.dark" sx={{ mb: 1.5 }}>
Hinweise zur PDF-Analyse ({notes.length})
</Typography>
@@ -1302,9 +1363,240 @@ class LieferungNeuPage extends PureComponent {
onClose={this.handleCloseArticleSearch}
onAssign={this.handleAssignArticleForLine}
/>
{this.props.docId != null && (
<BookDeliveryDialog
open={this.state.bookDeliveryDialogOpen}
delivery={{
id: this.props.docId,
number: this.state.number,
externalNumber: this.state.externalNumber,
date: this.state.date,
supplierId: this.state.partyId,
}}
summary={this.state.ledgerSummary}
prefillLineId={this.state.bookDeliveryPrefillLineId}
partyName={this.state.deliveryEntities?.find((p) => p.id === this.state.partyId)?.name}
onClose={this.handleCloseBookDialog}
onBookSuccess={this.handleBookSuccess}
/>
)}
</>
);
}
renderLedgerPanel() {
const { ledgerSummary } = this.state;
if (!ledgerSummary) return null;
const {
status,
totalPositions,
bookedPositions,
totalQuantity,
bookedQuantity,
lines,
} = ledgerSummary;
const allEntries = [];
if (Array.isArray(lines)) {
for (const line of lines) {
if (Array.isArray(line.ledgerEntries)) {
for (const entry of line.ledgerEntries) {
allEntries.push({
...entry,
lineSku: line.sku,
lineName: line.name,
});
}
}
}
}
allEntries.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
return (
<PanelTile
wide={4}
title="Lagerstatus (Ledger)"
color="#f8fafc"
sx={{
gridColumn: { xs: '1 / -1', sm: '1 / -1', md: 'span 4' },
border: '1px solid',
borderColor:
status === 'booked'
? 'success.light'
: status === 'partial'
? 'warning.light'
: 'divider',
}}
>
<Stack spacing={2}>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 3,
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3, alignItems: 'center' }}>
<Box>
<Typography variant="caption" color="text.secondary" display="block">
Status
</Typography>
{status === 'booked' && (
<Chip
size="small"
icon={<CheckCircleOutlineOutlinedIcon />}
label="Vollständig gebucht"
color="success"
variant="outlined"
sx={{ fontWeight: 600, height: 26 }}
/>
)}
{status === 'partial' && (
<Chip
size="small"
icon={<Inventory2OutlinedIcon />}
label={`Teilweise gebucht (${bookedPositions}/${totalPositions})`}
color="warning"
variant="outlined"
sx={{ fontWeight: 600, height: 26 }}
/>
)}
{status === 'unbooked' && (
<Chip
size="small"
label={`Offen (${totalPositions} Positionen)`}
variant="outlined"
sx={{ color: 'text.secondary', borderColor: 'divider', fontWeight: 500, height: 26 }}
/>
)}
{status === 'empty' && (
<Chip size="small" label="Keine Artikel" variant="outlined" />
)}
</Box>
<Box>
<Typography variant="caption" color="text.secondary" display="block">
Positionen
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{bookedPositions} / {totalPositions} gebucht
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" display="block">
Menge
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{bookedQuantity} / {totalQuantity} Stk. gebucht
</Typography>
</Box>
{totalQuantity > bookedQuantity && (
<Box>
<Typography variant="caption" color="text.secondary" display="block">
Noch offen
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, color: 'warning.dark' }}>
{totalQuantity - bookedQuantity} Stk.
</Typography>
</Box>
)}
</Box>
<Button
variant={status === 'booked' ? 'outlined' : 'contained'}
color={status === 'booked' ? 'success' : status === 'partial' ? 'warning' : 'primary'}
startIcon={
status === 'booked' ? (
<CheckCircleOutlineOutlinedIcon />
) : (
<Inventory2OutlinedIcon />
)
}
onClick={this.handleOpenBookDialog}
sx={{ fontWeight: 600, textTransform: 'none' }}
>
{status === 'booked'
? 'Buchungsdetails anzeigen'
: status === 'partial'
? 'Restliche Mengen einbuchen'
: 'Ins Lager buchen'}
</Button>
</Box>
{allEntries.length > 0 && (
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary', mb: 1, display: 'block' }}>
Bisherige Ledger-Buchungen ({allEntries.length}):
</Typography>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead sx={{ bgcolor: '#f1f5f9' }}>
<TableRow>
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Zeitpunkt</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Typ</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>SKU / Artikel</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, py: 0.5 }}>Menge</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Buchungstext</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5, fontFamily: 'monospace', fontSize: '0.75rem' }}>Prüfsumme (prev_hash)</TableCell>
</TableRow>
</TableHead>
<TableBody>
{allEntries.map((entry) => (
<TableRow key={entry.id} hover>
<TableCell sx={{ py: 0.5, whiteSpace: 'nowrap' }}>
<Typography variant="caption">
{entry.created_at ? new Date(entry.created_at).toLocaleString('de-DE') : '–'}
</Typography>
</TableCell>
<TableCell sx={{ py: 0.5 }}>
<Chip
size="small"
label={entry.transaction_type}
color="success"
variant="outlined"
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 600 }}
/>
</TableCell>
<TableCell sx={{ py: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, fontFamily: 'monospace' }}>
{entry.lineSku || entry.sku || '–'}
</Typography>
{entry.lineName && (
<Typography variant="caption" color="text.secondary" sx={{ ml: 1 }}>
{entry.lineName}
</Typography>
)}
</TableCell>
<TableCell align="right" sx={{ py: 0.5, fontWeight: 700, color: 'success.main' }}>
+{entry.amount}
</TableCell>
<TableCell sx={{ py: 0.5 }}>
<Typography variant="caption">
{entry.transaction_text || '–'}
</Typography>
</TableCell>
<TableCell sx={{ py: 0.5, fontFamily: 'monospace', fontSize: '0.7rem', color: 'text.secondary' }}>
<Tooltip title={entry.prev_hash}>
<span>{entry.prev_hash ? `${entry.prev_hash.slice(0, 16)}…` : '–'}</span>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
)}
</Stack>
</PanelTile>
);
}
}
export default withNavigate(LieferungNeuPage);

162
src/utils/deliveryLedger.js Normal file
View File

@@ -0,0 +1,162 @@
import { delivery as defaultDeliveryApi, ledger as defaultLedgerApi } from '../api';
import artikelAutomat from '../automats/artikelAutomat';
/**
* @typedef {Object} DeliveryLineLedgerStatus
* @property {number} id - Manifestarticle ID
* @property {string} sku - Article SKU
* @property {string} name - Article Name
* @property {number} targetAmount - Expected delivery quantity
* @property {number} bookedAmount - Quantity booked in ledger
* @property {number} remainingAmount - Quantity remaining to be booked
* @property {boolean} isBooked - Whether this position is fully booked
* @property {Array<Object>} ledgerEntries - Ledger entries for this line
*/
/**
* @typedef {Object} DeliveryLedgerSummary
* @property {number} deliveryId
* @property {Object} delivery - Full delivery object with lines
* @property {'empty' | 'booked' | 'partial' | 'unbooked'} status
* @property {number} totalPositions
* @property {number} bookedPositions
* @property {number} totalQuantity
* @property {number} bookedQuantity
* @property {Array<DeliveryLineLedgerStatus>} lines
*/
/**
* Fetches delivery lines and checks ledger entries for each line to compute the booking status.
*
* @param {number|string} deliveryId
* @param {Object} [options]
* @param {Object} [options.deliveryApi]
* @param {Object} [options.ledgerApi]
* @returns {Promise<DeliveryLedgerSummary>}
*/
export async function fetchDeliveryLedgerSummary(deliveryId, { deliveryApi = defaultDeliveryApi, ledgerApi = defaultLedgerApi } = {}) {
const res = await deliveryApi.get(deliveryId);
const delivery = res?.delivery ?? res;
const rawLines = Array.isArray(delivery?.lines) ? delivery.lines : [];
if (rawLines.length === 0) {
return {
deliveryId,
delivery,
status: 'empty',
totalPositions: 0,
bookedPositions: 0,
totalQuantity: 0,
bookedQuantity: 0,
lines: [],
};
}
// Fetch ledger entries for each line in parallel
const linesWithLedger = await Promise.all(
rawLines.map(async (line) => {
let ledgerEntries = [];
try {
const ledgerRes = await ledgerApi.listByManifestarticleId(line.id, 'DELIVERY');
ledgerEntries = Array.isArray(ledgerRes?.ledger) ? ledgerRes.ledger : [];
} catch (err) {
console.error(`[deliveryLedger] Failed to fetch ledger for line ${line.id}:`, err);
}
const deliveryEntries = ledgerEntries.filter(
(e) => String(e.transaction_type).toUpperCase() === 'DELIVERY'
);
const bookedAmount = deliveryEntries.reduce((sum, e) => sum + Number(e.amount || 0), 0);
const targetAmount = Number(line.amount || 0);
const remainingAmount = Math.max(0, targetAmount - bookedAmount);
const isBooked = targetAmount > 0 ? bookedAmount >= targetAmount : bookedAmount > 0;
return {
...line,
targetAmount,
bookedAmount,
remainingAmount,
isBooked,
ledgerEntries: deliveryEntries,
};
})
);
const totalPositions = linesWithLedger.length;
const bookedPositions = linesWithLedger.filter((l) => l.isBooked).length;
const totalQuantity = linesWithLedger.reduce((sum, l) => sum + l.targetAmount, 0);
const bookedQuantity = linesWithLedger.reduce((sum, l) => sum + l.bookedAmount, 0);
let status = 'unbooked';
if (totalPositions === 0) {
status = 'empty';
} else if (bookedPositions === totalPositions && bookedQuantity >= totalQuantity) {
status = 'booked';
} else if (bookedPositions > 0 || bookedQuantity > 0) {
status = 'partial';
} else {
status = 'unbooked';
}
return {
deliveryId,
delivery,
status,
totalPositions,
bookedPositions,
totalQuantity,
bookedQuantity,
lines: linesWithLedger,
};
}
/**
* Books specified delivery lines to the ledger as goods receipts (DELIVERY).
*
* @param {Object} params
* @param {Object} params.delivery - The delivery object (used for transaction memo and fallback)
* @param {Array<{ manifestarticleId?: number, id?: number, amount: number, sku?: string }>} params.linesToBook
* @param {Object} [params.ledgerApi]
* @param {string} [params.transactionText]
* @returns {Promise<Array<Object>>}
*/
export async function bookDeliveryLines({
delivery,
linesToBook,
ledgerApi = defaultLedgerApi,
transactionText,
}) {
if (!Array.isArray(linesToBook) || linesToBook.length === 0) {
return [];
}
const memo = String(
transactionText != null && transactionText !== ''
? transactionText
: `Lieferung ${delivery?.number || delivery?.id || ''}`
).trim();
const results = [];
for (const item of linesToBook) {
const manifestarticleId = Number(item.manifestarticleId ?? item.id);
const amount = Number(item.amount);
if (!manifestarticleId || !Number.isFinite(amount) || amount <= 0) {
continue;
}
const res = await ledgerApi.add({
manifestarticle_id: manifestarticleId,
transaction_type: 'DELIVERY',
amount,
transaction_text: memo,
});
results.push(res?.ledger ?? res);
if (item.sku) {
void artikelAutomat.actions.reloadArticle(item.sku).catch(() => {});
}
}
return results;
}

View File

@@ -158,6 +158,55 @@ const sharedServer = {
},
};
function nginxHeadersPreviewPlugin() {
return {
name: 'nginx-headers-preview',
configurePreviewServer(server) {
server.middlewares.use((req, res, next) => {
const url = (req.url || '').split('?')[0];
// Never touch API requests (proxied to backend)
if (url.startsWith('/api')) {
return next();
}
// --- Security Headers (from deploy/nginx-dashfront.conf lines 72-76) ---
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'camera=(self), microphone=(), geolocation=()');
// --- Caching Rules (from deploy/nginx-dashfront.conf lines 129-166) ---
if (/^\/(sw\.js|registerSW\.js|workbox-[a-f0-9]+\.js)$/.test(url)) {
// Zero caching for Service Worker & registration scripts
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
} else if (/^\/(manifest\.webmanifest|favicon\.svg|icons\/.*)$/.test(url)) {
// 1 hour for web manifest & icons
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('Vary', 'Accept-Encoding');
} else if (url.startsWith('/assets/')) {
// 1 year immutable for hashed assets
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
res.setHeader('Vary', 'Accept-Encoding');
} else if (url.endsWith('.wasm')) {
// 1 year immutable for WASM binaries
res.setHeader('Content-Type', 'application/wasm');
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
} else if (url === '/' || url === '/index.html' || !path.extname(url)) {
// HTML5 SPA Entry / Fallback: Zero caching
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
}
next();
});
},
};
}
export default defineConfig(({ command }) => ({
resolve: sharedResolve,
build: {
@@ -231,6 +280,7 @@ export default defineConfig(({ command }) => ({
},
plugins: [
react(),
nginxHeadersPreviewPlugin(),
...(command === 'build'
? [
VitePWA({