diff --git a/package.json b/package.json index 2f676e0..7adbca6 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/api/ApiClient.js b/src/api/ApiClient.js index 31778da..51eee6b 100644 --- a/src/api/ApiClient.js +++ b/src/api/ApiClient.js @@ -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 } */ diff --git a/src/api/index.js b/src/api/index.js index 23a8ff7..83ac6ab 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -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, diff --git a/src/automats/deliveryEditorAutomat.js b/src/automats/deliveryEditorAutomat.js index 4af990c..8175c4e 100644 --- a/src/automats/deliveryEditorAutomat.js +++ b/src/automats/deliveryEditorAutomat.js @@ -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, diff --git a/src/components/manifest/Attachment.jsx b/src/components/manifest/Attachment.jsx index d69b29b..38aed64 100644 --- a/src/components/manifest/Attachment.jsx +++ b/src/components/manifest/Attachment.jsx @@ -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 ? ( - - + + { + e.stopPropagation(); + notifyErrorMessage( + 'Neu analysieren nicht möglich, solange Artikel vorhanden sind. Bitte entfernen Sie zuerst alle Zeilen.', + ); + } + : undefined + } + > diff --git a/src/components/manifest/AttachmentList.jsx b/src/components/manifest/AttachmentList.jsx index 246b9f1..b495f21 100644 --- a/src/components/manifest/AttachmentList.jsx +++ b/src/components/manifest/AttachmentList.jsx @@ -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 {attachments.map((attachment, index) => ( @@ -30,6 +30,7 @@ export class AttachmentList extends PureComponent { onDelete={onDelete} onClick={onClick} disabled={disabled} + hasArticles={hasArticles} /> ))} diff --git a/src/components/manifest/BookDeliveryDialog.jsx b/src/components/manifest/BookDeliveryDialog.jsx new file mode 100644 index 0000000..4d33304 --- /dev/null +++ b/src/components/manifest/BookDeliveryDialog.jsx @@ -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 ( + + + + Nummer + + + {delivery?.number || '–'} + + + + {delivery?.externalNumber && ( + + + Referenz + + + {delivery.externalNumber} + + + )} + + + + Lieferdatum + + + {formatDate(delivery?.date)} + + + + {partyName && ( + + + Lieferant + + + {partyName} + + + )} + + + + Aktueller Status: + + {summary?.status === 'booked' && ( + } + label="Vollständig gebucht" + color="success" + variant="outlined" + sx={{ fontWeight: 600 }} + /> + )} + {summary?.status === 'partial' && ( + } + label={`Teilweise gebucht (${summary.bookedPositions}/${summary.totalPositions})`} + color="warning" + variant="outlined" + sx={{ fontWeight: 600 }} + /> + )} + {summary?.status === 'unbooked' && ( + + )} + {summary?.status === 'empty' && ( + + )} + + + ); + } + + renderLinesTable() { + const { summary, amountsToBook, isBooking } = this.state; + const lines = summary?.lines || []; + + if (lines.length === 0) { + return ( + + Diese Lieferung enthält keine Artikelpositionen zum Einbuchen. + + ); + } + + return ( + + + + + SKU + Artikel + Liefermenge + Bereits gebucht + Jetzt einbuchen + + + + {lines.map((line) => { + const toBook = amountsToBook[line.id] ?? 0; + const isAlreadyFullyBooked = line.isBooked && line.remainingAmount <= 0; + + return ( + + + {line.sku || '–'} + + + + {line.name || '–'} + + + + + {line.targetAmount} + + + + + 0 ? 'success.main' : 'text.secondary'} + sx={{ fontWeight: line.bookedAmount > 0 ? 600 : 400 }} + > + {line.bookedAmount} + + {isAlreadyFullyBooked && ( + + )} + + + + {isAlreadyFullyBooked ? ( + + ) : ( + 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 }} + /> + )} + + + ); + })} + +
+
+ ); + } + + 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 ( + + + + + + Lieferung {delivery?.number || ''} ins Lager buchen + + + + + + + + + {this.renderHeaderInfo()} + + {loading ? ( + + + Lade Positionen und Ledger-Status... + + ) : error ? ( + + {error} + + ) : ( + <> + {prefillLine && ( + + Vorausgewählt zum Einbuchen: Position{' '} + {prefillLine.sku ? `SKU ${prefillLine.sku}` : prefillLine.name} ({amountsToBook[prefillLine.id] || 0} Stk.). Andere Positionen stehen auf 0. + + )} + {this.renderLinesTable()} + + {!isFullyBooked && lines.length > 0 && ( + + + + + )} + + + + + + {isFullyBooked ? ( + }> + Alle Artikel dieser Lieferung sind bereits vollständig im Lager verbucht. + + ) : totalToBook > 0 ? ( + + Es werden {totalToBook} Stück für{' '} + {countLinesToBook} Position{countLinesToBook === 1 ? '' : 'en'} als Lagerzugang (DELIVERY) im Ledger gebucht. + + ) : ( + + Wählen Sie mindestens eine Menge größer als 0 aus, um den Lagerzugang zu buchen. + + )} + + )} + + + + + + {!isFullyBooked && lines.length > 0 && ( + + )} + + + ); + } +} diff --git a/src/components/manifest/ManifestForm.jsx b/src/components/manifest/ManifestForm.jsx index 1dd791e..72c0ba9 100644 --- a/src/components/manifest/ManifestForm.jsx +++ b/src/components/manifest/ManifestForm.jsx @@ -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 ( - - - {cfg.partyGateMessage} - - - ); - } + const { lines, saving } = this.state; return ( void this.handleSave()} /> ); @@ -475,18 +467,6 @@ class ManifestForm extends PureComponent { return ( <> - {status === 'ready' && ( - - - - )} {}); + } + 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 ( + + { + e.stopPropagation(); + onBookLine(ledgerLine); + } + : undefined + } + icon={ + isBooked ? ( + + ) : ( + + ) + } + 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, + }} + /> + + ); + } + + 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 ( 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 ( + {/* Unlink button at the front */} + {this.props.onRemoveSupplierArticle && supplierArticle.id != null && ( + + + + + + + + )} + {/* Article Photo Thumbnail */} - {/* Match Indicator */} - - {/* Internal Article SKU */} {article.sku && ( - {article.name} + {fullArticle.name || article.name} )} @@ -225,32 +398,49 @@ class ManifestFormLine extends PureComponent { )} - {/* Stored Purchase Price (Netto) */} - {priceNet != null && ( - - EK Netto: {formatPrice(priceNet)} - {taxRate != null ? ` (${taxRate}% MwSt)` : ''} - + {priceNet != null && ( + + EK Netto: {formatPrice(priceNet)} + {taxRate != null ? ` (${taxRate}% MwSt)` : ''} + + )} + {vkNetto != null && ( + + VK Netto: {formatPrice(vkNetto)} + + )} + {vkBrutto != null && ( + + VK Brutto: {formatPrice(vkBrutto)} + + )} + )} - {/* Remove supplier-article connection */} - {this.props.onRemoveSupplierArticle && supplierArticle.id != null && ( - - - - - - )} + {this.renderLedgerChip()} ); } @@ -258,10 +448,6 @@ class ManifestFormLine extends PureComponent { if (rawSku) { return ( - - - Kein Lieferanten-Artikel für SKU „{rawSku}“ gefunden. - {this.props.onCreateArticle && ( - )} - - ) : null} - - + {saving ? 'Speichern…' : 'Speichern'} + + {docId != null && kind === 'delivery' && onOpenBookDialog && ( + + )} + {onPdfUpload ? ( + + {({ isDragging }) => ( + + )} + + ) : null} + + {onPdfUpload ? ( ) : null} - + ); } } diff --git a/src/components/manifest/ManifestLines.jsx b/src/components/manifest/ManifestLines.jsx index 91926ab..8d50a81 100644 --- a/src/components/manifest/ManifestLines.jsx +++ b/src/components/manifest/ManifestLines.jsx @@ -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 ( - + = 4 ? { gridColumn: { xs: '1 / -1', sm: '1 / -1', md: 'span 4' } } : {}), + ...(!hasArticles + ? { + border: '1.5px solid', + borderColor: 'error.main', + } + : {}), + }} + > @@ -52,47 +72,53 @@ class ManifestLines extends PureComponent { whiteSpace: 'nowrap', bgcolor: linesColor, fontWeight: 700, + px: 1, + py: 0.75, }} > {col.label} ))} - + - {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 ( - - ); - })} + return ( + + ); + })}
- + +
diff --git a/src/components/manifest/ManifestList.jsx b/src/components/manifest/ManifestList.jsx index 67291b2..023d139 100644 --- a/src/components/manifest/ManifestList.jsx +++ b/src/components/manifest/ManifestList.jsx @@ -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 ( + + + + Prüfe... + + + ); + } + + if (summary.status === 'error') { + return ( + + + + ); + } + + if (summary.status === 'empty') { + return ( + + Keine Positionen + + ); + } + + if (summary.status === 'booked') { + return ( + + } + label={`Gebucht (${summary.bookedPositions}/${summary.totalPositions})`} + color="success" + variant="outlined" + onClick={(e) => { + e.stopPropagation(); + this.handleOpenBookDialog(row); + }} + sx={{ fontWeight: 600, cursor: 'pointer' }} + /> + + ); + } + + if (summary.status === 'partial') { + return ( + e.stopPropagation()} + > + + } + label={`Teilweise (${summary.bookedPositions}/${summary.totalPositions})`} + color="warning" + variant="outlined" + onClick={() => this.handleOpenBookDialog(row)} + sx={{ fontWeight: 600, cursor: 'pointer' }} + /> + + + + ); + } + + // Default: 'unbooked' + return ( + e.stopPropagation()} + > + + this.handleOpenBookDialog(row)} + sx={{ + color: 'text.secondary', + borderColor: 'divider', + fontWeight: 500, + cursor: 'pointer', + }} + /> + + + + ); + } + 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 ( - } - 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`} - /> + <> + } + 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' && ( + + )} + ); } } diff --git a/src/components/manifest/index.js b/src/components/manifest/index.js index 565f86c..543c2b8 100644 --- a/src/components/manifest/index.js +++ b/src/components/manifest/index.js @@ -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, diff --git a/src/components/manifest/manifestConfig.js b/src/components/manifest/manifestConfig.js index fa05554..90202e6 100644 --- a/src/components/manifest/manifestConfig.js +++ b/src/components/manifest/manifestConfig.js @@ -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) => diff --git a/src/pages/lieferung/neu.jsx b/src/pages/lieferung/neu.jsx index b1f04b1..c9044bc 100644 --- a/src/pages/lieferung/neu.jsx +++ b/src/pages/lieferung/neu.jsx @@ -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 ; } + const ledgerLinesById = {}; + if (Array.isArray(this.state.ledgerSummary?.lines)) { + for (const l of this.state.ledgerSummary.lines) { + ledgerLinesById[l.id] = l; + } + } + return ( <> - - - - {this.state.viewerPosition === 'top' && this.renderViewerPanels(3)} - {partyId == null && (!lines || lines.length === 0) ? ( - - {this.state.unmatchedSupplierName ? ( - - - Lieferant „{this.state.unmatchedSupplierName}“ wurde im Dokument erkannt, existiert aber noch nicht in den Stammdaten. - - - - - - ) : ( - - {cfg.partyGateMessage} - - )} - - ) : ( - <> - {partyId == null && ( - - this.handleCreateSupplierFromPdf(this.state.unmatchedSupplierName)} - > - Lieferant anlegen - - ) : null - } - sx={{ py: 0.5, alignItems: 'center' }} + {this.state.viewerPosition === 'top' && this.renderViewerPanels(4)} + {this.state.unmatchedSupplierName && ( + + 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.'} - - - )} - } - 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. + + )} + 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 && ( - + Hinweise zur PDF-Analyse ({notes.length}) @@ -1302,9 +1363,240 @@ class LieferungNeuPage extends PureComponent { onClose={this.handleCloseArticleSearch} onAssign={this.handleAssignArticleForLine} /> + + {this.props.docId != null && ( + 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 ( + + + + + + + Status + + {status === 'booked' && ( + } + label="Vollständig gebucht" + color="success" + variant="outlined" + sx={{ fontWeight: 600, height: 26 }} + /> + )} + {status === 'partial' && ( + } + label={`Teilweise gebucht (${bookedPositions}/${totalPositions})`} + color="warning" + variant="outlined" + sx={{ fontWeight: 600, height: 26 }} + /> + )} + {status === 'unbooked' && ( + + )} + {status === 'empty' && ( + + )} + + + + + Positionen + + + {bookedPositions} / {totalPositions} gebucht + + + + + + Menge + + + {bookedQuantity} / {totalQuantity} Stk. gebucht + + + + {totalQuantity > bookedQuantity && ( + + + Noch offen + + + {totalQuantity - bookedQuantity} Stk. + + + )} + + + + + + {allEntries.length > 0 && ( + + + Bisherige Ledger-Buchungen ({allEntries.length}): + + + + + + Zeitpunkt + Typ + SKU / Artikel + Menge + Buchungstext + Prüfsumme (prev_hash) + + + + {allEntries.map((entry) => ( + + + + {entry.created_at ? new Date(entry.created_at).toLocaleString('de-DE') : '–'} + + + + + + + + {entry.lineSku || entry.sku || '–'} + + {entry.lineName && ( + + {entry.lineName} + + )} + + + +{entry.amount} + + + + {entry.transaction_text || '–'} + + + + + {entry.prev_hash ? `${entry.prev_hash.slice(0, 16)}…` : '–'} + + + + ))} + +
+
+
+ )} +
+
+ ); + } } export default withNavigate(LieferungNeuPage); diff --git a/src/utils/deliveryLedger.js b/src/utils/deliveryLedger.js new file mode 100644 index 0000000..32091fc --- /dev/null +++ b/src/utils/deliveryLedger.js @@ -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} 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} 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} + */ +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>} + */ +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; +} diff --git a/vite.config.js b/vite.config.js index e3f061f..36e038a 100644 --- a/vite.config.js +++ b/vite.config.js @@ -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({