import { PureComponent, createRef } from 'react'; import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; import { enqueueSnackbar } from 'notistack'; import BackendError from '../BackendError'; import { PanelTileGrid } from 'dashgui/panels'; import { delivery as deliveryApi, quote as quoteApi, order as orderApi, shipment as shipmentApi, payment as paymentApi } from '../../api'; import { requireArray, requireDefined, requireObject } from '../../utils/apiShape'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError, notifyErrorMessage } from '../../utils/notify'; import withNavigate from '../withNavigate'; import ManifestHeader from './ManifestHeader'; import ManifestLines from './ManifestLines'; import ManifestPdfDialog from './ManifestPdfDialog'; import ArticleSearchDialog from './ArticleSearchDialog'; import OrderDocumentsTiles from './OrderDocumentsTiles'; import OrderShipmentDialog from './OrderShipmentDialog'; import artikelAutomat, { getArticle } from '../../automats/artikelAutomat'; import entityAutomat from '../../automats/entityAutomat'; import customergroupAutomat from '../../automats/customergroupAutomat'; import supplierAutomat from '../../automats/supplierAutomat'; import orderAutomat from '../../automats/orderAutomat'; import retoureAutomat from '../../automats/retoureAutomat'; import companyAutomat, { selectCompanyTaxrate } from '../../automats/companyAutomat'; import warehouseAutomat from '../../automats/warehouseAutomat'; import sseAutomat from '../../automats/sseAutomat'; import OrderLedgerPanel from './OrderLedgerPanel'; import { fetchOrderLedgerEntries } from '../../utils/orderLedger'; import { netFromGross, grossFromNet, getArticleGross } from '../../utils/price'; import { resolveSalesPrice } from '../../utils/articleDetail'; import { buildManifestBody, restoreAttachmentsFromApi, dateInputValue, todayInputValue, emptyLine, lineFromApi, loadParties, manifestConfig, apiFor, parseNum, filterNonSupplierEntities, } from './manifestConfig'; import { ensureDocumentNumber, getNextDocumentNumber } from '../../utils/documentNumber'; import { getManifestDraftAutomat, getManifestEditAutomat, } from '../../automats/manifestDraftAutomat'; export function computeDocGrossTotal(lines) { if (!Array.isArray(lines)) return 0; return lines.reduce((acc, line) => { const amount = Number(line.amount ?? line.count) || 0; if (line.gross != null && line.gross !== '') { const g = Number(line.gross) || 0; return acc + amount * g; } const net = Number(line.net ?? line.netprice ?? line.netPrice) || 0; const tax = Number(line.tax ?? line.taxrate ?? line.taxRate) || 0; const rebate = Number(line.rebate) || 0; const lineNet = amount * net * (1 - rebate / 100); return acc + lineNet * (1 + tax / 100); }, 0); } class ManifestForm extends PureComponent { constructor(props) { super(props); this.cfg = manifestConfig(props.kind); const groups = customergroupAutomat.state.items ?? []; const initialParties = props.kind === 'delivery' ? (supplierAutomat.state.status === 'ready' ? (supplierAutomat.state.suppliers ?? []) : []) : (entityAutomat.state.status === 'ready' ? filterNonSupplierEntities(entityAutomat.state.items ?? [], groups) : []); const partiesReady = props.kind === 'delivery' ? supplierAutomat.state.status === 'ready' : (entityAutomat.state.status === 'ready' && customergroupAutomat.state.status === 'ready'); this.draftAutomat = props.mode === 'create' ? getManifestDraftAutomat(props.kind) : null; this.editAutomat = props.mode === 'edit' ? getManifestEditAutomat(props.kind) : null; const initialDraft = this.draftAutomat?.state; const initialEdit = (this.editAutomat && String(this.editAutomat.state?.id) === String(props.docId)) ? this.editAutomat.state : null; const initialDoc = initialDraft || initialEdit; const initialDate = initialDoc?.date || (props.mode === 'create' ? todayInputValue() : ''); if (props.mode === 'create' && this.draftAutomat) { if (!this.draftAutomat.state.date) { this.draftAutomat.setState({ date: initialDate }); } if ( props.kind === 'order' && Array.isArray(this.draftAutomat.state.lines) && this.draftAutomat.state.lines.length === 1 && !this.draftAutomat.state.lines[0]?.sku && !this.draftAutomat.state.lines[0]?.name ) { this.draftAutomat.setState({ lines: [] }); } } this.state = { status: props.mode === 'edit' && !initialEdit ? 'loading' : (partiesReady ? 'ready' : 'loadingParties'), error: null, parties: initialParties, partiesLoading: !partiesReady, date: initialDate, createdAt: initialDoc?.createdAt || initialEdit?.createdAt || null, partyId: initialDoc?.partyId ?? null, number: initialDoc?.number || '', externalNumber: initialDoc?.externalNumber || '', lines: Array.isArray(initialDoc?.lines) ? (props.kind === 'order' && props.mode === 'create' && initialDoc.lines.length === 1 && !initialDoc.lines[0]?.sku && !initialDoc.lines[0]?.name ? [] : initialDoc.lines) : (props.mode === 'create' && props.kind !== 'order' ? [emptyLine()] : []), attachments: initialDoc?.attachments || [], shipments: initialEdit?.shipments || [], payments: initialEdit?.payments || [], shippings: initialEdit?.shippings || [], ledgerEntries: [], orderTotal: initialEdit?.orderTotal ?? null, paidTotal: initialEdit?.paidTotal ?? null, paid: Boolean(initialEdit?.paid), shipped: Boolean(initialEdit?.shipped), isShipped: Boolean(initialEdit?.isShipped ?? initialEdit?.shipped), delivered: Boolean(initialEdit?.delivered), isDirty: Boolean(initialEdit?.isDirty), dirtyFields: initialEdit?.dirtyFields || null, dirtyLineKeys: initialEdit?.dirtyLineKeys || [], dirtyLineFields: initialEdit?.dirtyLineFields || {}, dirtySummary: initialEdit?.dirtySummary || [], saving: false, orderCreating: false, partyError: false, pdfUploading: false, pdfCreating: false, pdfDialogOpen: false, shipmentDialogOpen: false, pdfDialogData: null, pdfFileName: '', articleSearchDialogOpen: false, articleSearchLine: null, }; this.headerRef = createRef(); this._cancelled = false; this.handleRetry = this.handleRetry.bind(this); this.handleRestore = this.handleRestore.bind(this); this.handlePartyChange = this.handlePartyChange.bind(this); this.handleDateChange = this.handleDateChange.bind(this); this.handleNumberChange = this.handleNumberChange.bind(this); this.handleExternalNumberChange = this.handleExternalNumberChange.bind(this); this.handleLineChange = this.handleLineChange.bind(this); this.handleSkuBlur = this.handleSkuBlur.bind(this); this.getCustomerGroupId = this.getCustomerGroupId.bind(this); this.getOrFetchArticle = this.getOrFetchArticle.bind(this); this.handleAddLine = this.handleAddLine.bind(this); this.handleAddArticleLine = this.handleAddArticleLine.bind(this); this.handleOpenArticleSearchForLine = this.handleOpenArticleSearchForLine.bind(this); this.handleCloseArticleSearch = this.handleCloseArticleSearch.bind(this); this.handleAssignArticleForLine = this.handleAssignArticleForLine.bind(this); this.handleRemoveLine = this.handleRemoveLine.bind(this); this.handleMoveLine = this.handleMoveLine.bind(this); this.handleRemoveAllLines = this.handleRemoveAllLines.bind(this); this.handleSave = this.handleSave.bind(this); this.handleCreateOrder = this.handleCreateOrder.bind(this); this.handleOpenCreateShipment = this.handleOpenCreateShipment.bind(this); this.handleCloseShipmentDialog = this.handleCloseShipmentDialog.bind(this); this.handleShipmentCreated = this.handleShipmentCreated.bind(this); this.handlePdfUpload = this.handlePdfUpload.bind(this); this.handleCreatePdf = this.handleCreatePdf.bind(this); this.handleAttachmentDelete = this.handleAttachmentDelete.bind(this); this.handleClosePdfDialog = this.handleClosePdfDialog.bind(this); this.handleAbortParse = this.handleAbortParse.bind(this); this.isFullyDelivered = this.isFullyDelivered.bind(this); } componentDidMount() { this._cancelled = false; if (this.props.kind === 'delivery') { this.unsubParties = supplierAutomat.subscribe(this, (state) => ({ parties: state.status === 'ready' ? (state.suppliers ?? []) : [], partiesLoading: state.status === 'loading', })); } else { const updateParties = () => { const entities = entityAutomat.state.items ?? []; const groups = customergroupAutomat.state.items ?? []; this.setState({ parties: filterNonSupplierEntities(entities, groups), partiesLoading: entityAutomat.state.status === 'loading' || customergroupAutomat.state.status === 'loading', }); }; this.unsubParties = entityAutomat.subscribe(this, () => { updateParties(); return null; }); this.unsubGroups = customergroupAutomat.subscribe(this, () => { updateParties(); return null; }); void customergroupAutomat.actions.ensureLoaded(); } if (companyAutomat.state.status === 'idle') { void companyAutomat.actions.load(); } this.unsubCompany = companyAutomat.subscribe(this, (state) => { const taxrate = state.current?.taxrate; if (taxrate != null) { this.setState((prev) => { if ( prev.lines.length === 1 && !prev.lines[0].sku && !prev.lines[0].name && !prev.lines[0].net && prev.lines[0].tax !== String(taxrate) ) { return { lines: [ { ...prev.lines[0], tax: String(taxrate), taxrate: String(taxrate), }, ], }; } return null; }); } }); if (warehouseAutomat.state.status === 'idle') { void warehouseAutomat.actions.load(); } if (this.props.mode === 'edit') { void this.loadEdit(); if (this.props.kind === 'order') { this.unsubSseLedger = sseAutomat?.actions?.on?.('ledger', () => { void this.reloadLedger(); }); this.unsubSseStock = sseAutomat?.actions?.on?.('stock', () => { void this.reloadLedger(); }); } } else { void this.loadPartiesOnly(); void ensureDocumentNumber(this.props.kind).catch(() => {}); } if (this.draftAutomat) { this.unsubDraft = this.draftAutomat.subscribe(this, (state) => ({ date: state.date || '', partyId: state.partyId, externalNumber: state.externalNumber || '', lines: state.lines || [], attachments: state.attachments || [], })); } if (this.editAutomat) { this.unsubEdit = this.editAutomat.subscribe(this, (state) => { if (String(state.id) === String(this.props.docId)) { return { date: state.date || '', createdAt: state.createdAt ?? null, partyId: state.partyId, externalNumber: state.externalNumber || '', lines: state.lines || [], attachments: state.attachments || [], shipments: state.shipments || [], payments: state.payments || [], shippings: state.shippings || [], orderTotal: state.orderTotal ?? null, paidTotal: state.paidTotal ?? null, paid: Boolean(state.paid), shipped: Boolean(state.shipped), isShipped: Boolean(state.isShipped ?? state.shipped), delivered: Boolean(state.delivered), isDirty: Boolean(state.isDirty), dirtyFields: state.dirtyFields, dirtyLineKeys: state.dirtyLineKeys, dirtyLineFields: state.dirtyLineFields, dirtySummary: state.dirtySummary, }; } return null; }); } } componentDidUpdate(prevProps, prevState) { if (prevProps.kind !== this.props.kind) { this.cfg = manifestConfig(this.props.kind); this.unsubParties?.(); this.unsubGroups?.(); if (this.props.kind === 'delivery') { this.unsubParties = supplierAutomat.subscribe(this, (state) => ({ parties: state.status === 'ready' ? (state.suppliers ?? []) : [], partiesLoading: state.status === 'loading', })); } else { const updateParties = () => { const entities = entityAutomat.state.items ?? []; const groups = customergroupAutomat.state.items ?? []; this.setState({ parties: filterNonSupplierEntities(entities, groups), partiesLoading: entityAutomat.state.status === 'loading' || customergroupAutomat.state.status === 'loading', }); }; this.unsubParties = entityAutomat.subscribe(this, () => { updateParties(); return null; }); this.unsubGroups = customergroupAutomat.subscribe(this, () => { updateParties(); return null; }); void customergroupAutomat.actions.ensureLoaded(); } } if ( this.props.mode === 'edit' && prevProps.docId !== this.props.docId ) { void this.loadEdit(); } if (this.props.mode === 'create' && this.draftAutomat) { if ( prevState.date !== this.state.date || prevState.partyId !== this.state.partyId || prevState.externalNumber !== this.state.externalNumber || prevState.lines !== this.state.lines || prevState.attachments !== this.state.attachments ) { this.draftAutomat.setState({ date: this.state.date, partyId: this.state.partyId, externalNumber: this.state.externalNumber, lines: this.state.lines, attachments: this.state.attachments, }); } } if ( this.props.mode === 'edit' && this.editAutomat && String(this.editAutomat.state.id) === String(this.props.docId) ) { if ( prevState.date !== this.state.date || prevState.partyId !== this.state.partyId || prevState.externalNumber !== this.state.externalNumber || prevState.lines !== this.state.lines ) { this.editAutomat.actions.update({ date: this.state.date, partyId: this.state.partyId, externalNumber: this.state.externalNumber, lines: this.state.lines, }); const { isDirty, dirtyFields, dirtyLineKeys, dirtyLineFields, dirtySummary } = this.editAutomat.state; if ( this.state.isDirty !== isDirty || this.state.dirtyFields !== dirtyFields || this.state.dirtyLineFields !== dirtyLineFields || this.state.dirtySummary !== dirtySummary ) { this.setState({ isDirty, dirtyFields, dirtyLineKeys, dirtyLineFields, dirtySummary }); } } } } componentWillUnmount() { this._cancelled = true; this.unsubDraft?.(); this.unsubEdit?.(); this.unsubParties?.(); this.unsubGroups?.(); this.unsubCompany?.(); this.unsubSseLedger?.(); this.unsubSseStock?.(); if (this._parseAbortController) { this._parseAbortController.abort(); this._parseAbortController = null; } } async reloadLedger() { if (this.props.kind !== 'order' || this.props.mode !== 'edit' || !this.props.docId) return; try { const ledgerEntries = await fetchOrderLedgerEntries(this.props.docId, { shipments: this.state.shipments, lines: this.state.lines, }); if (this._cancelled) return; this.setState({ ledgerEntries }); } catch (err) { console.warn('[ManifestForm] Failed to reload ledger entries:', err); } } async loadPartiesOnly() { if (this.state.parties.length === 0) { this.setState({ status: 'loadingParties', error: null, partiesLoading: true }); } try { const parties = await loadParties(this.props.kind); if (this._cancelled) return; this.setState({ status: 'ready', parties, partiesLoading: false, }); } catch (err) { if (this._cancelled) return; if (this.state.parties.length === 0) { this.setState({ status: 'failed', error: backendErrorFrom(err), partiesLoading: false, }); } notifyError(err); } } async loadEdit() { const { docId, kind } = this.props; const cfg = this.cfg; if (docId == null) { this.setState({ status: 'failed', error: backendErrorFrom(new Error(cfg.invalidId)), }); return; } // If editAutomat already holds this exact document in memory, preserve in-progress edits if ( this.editAutomat && String(this.editAutomat.state.id) === String(docId) && this.editAutomat.state.baseline != null ) { void this.loadPartiesOnly(); this.setState({ status: 'ready', isDirty: Boolean(this.editAutomat.state.isDirty), dirtyFields: this.editAutomat.state.dirtyFields, dirtyLineKeys: this.editAutomat.state.dirtyLineKeys, dirtyLineFields: this.editAutomat.state.dirtyLineFields, dirtySummary: this.editAutomat.state.dirtySummary, date: this.editAutomat.state.date, createdAt: this.editAutomat.state.createdAt ?? null, partyId: this.editAutomat.state.partyId, number: this.editAutomat.state.number, externalNumber: this.editAutomat.state.externalNumber, lines: this.editAutomat.state.lines, attachments: this.editAutomat.state.attachments, }); return; } this.setState({ status: 'loading', error: null, partiesLoading: true }); try { const [docRes, parties] = await Promise.all([ apiFor(cfg).get(docId), loadParties(kind), ]); if (this._cancelled) return; requireObject(docRes, cfg.getPathLabel); requireObject(docRes[cfg.docField], cfg.docField); const doc = docRes[cfg.docField]; const apiLines = requireArray(doc.lines, `${cfg.docField}.lines`); const lines = apiLines.map(lineFromApi); // Restore attachments if available const apiFiles = doc.files ?? []; const attachments = await restoreAttachmentsFromApi(apiFiles); let shipments = doc.shipments; let payments = doc.payments; if (kind === 'order' || kind === 'retoure') { if (!shipments) { try { const shRes = await shipmentApi.listByOrder(docId); shipments = shRes?.shipments ?? []; } catch { shipments = []; } } if (!payments) { try { const payRes = await paymentApi.listByOrder(docId); payments = payRes?.payments ?? []; } catch { payments = []; } } } let ledgerEntries = []; if (kind === 'order') { try { ledgerEntries = await fetchOrderLedgerEntries(docId, { shipments, lines: doc.lines, }); } catch (lErr) { console.warn('[ManifestForm] Failed to load ledger entries:', lErr); } } const linesGross = computeDocGrossTotal(lines); const computedOrderTotal = doc.orderTotal ?? doc.grossTotal ?? doc.gross_total ?? (linesGross !== 0 ? linesGross : null); const computedPaidTotal = doc.paidTotal ?? doc.paid_total ?? (Array.isArray(payments) && payments.length > 0 ? payments.reduce((acc, p) => acc + (Number(p.amount) || 0), 0) : null); const loadedDoc = { date: dateInputValue(doc.date), createdAt: doc.createdAt ?? doc.created_at ?? null, partyId: doc[cfg.partyField], number: doc.number ?? '', externalNumber: doc.externalNumber != null ? String(doc.externalNumber) : '', lines, attachments, shipments: shipments || [], payments: payments || [], shippings: doc.shippings || [], orderTotal: computedOrderTotal, paidTotal: computedPaidTotal, paid: Boolean(doc.paid ?? doc.isPaid ?? doc.is_paid), shipped: Boolean(doc.shipped ?? doc.isShipped ?? doc.is_shipped), isShipped: Boolean(doc.isShipped ?? doc.shipped ?? doc.is_shipped), delivered: Boolean(doc.delivered), }; if (this.editAutomat) { this.editAutomat.actions.loadDoc(docId, loadedDoc); } this.setState({ status: 'ready', parties, partiesLoading: false, ...loadedDoc, ledgerEntries, isDirty: false, dirtyFields: null, dirtyLineKeys: [], dirtyLineFields: {}, dirtySummary: [], }); } catch (err) { if (this._cancelled) return; this.setState({ status: 'failed', error: backendErrorFrom(err), partiesLoading: false, }); } } handleRetry() { if (this.props.mode === 'edit') void this.loadEdit(); else void this.loadPartiesOnly(); } handleRestore() { if (!this.editAutomat) return; this.editAutomat.actions.restoreBaseline(); const state = this.editAutomat.state; this.setState({ date: state.date || '', partyId: state.partyId, number: state.number || '', externalNumber: state.externalNumber || '', lines: state.lines || [], attachments: state.attachments || [], isDirty: false, dirtyFields: state.dirtyFields, dirtyLineKeys: state.dirtyLineKeys, dirtyLineFields: state.dirtyLineFields, dirtySummary: state.dirtySummary, }); enqueueSnackbar('Änderungen verworfen und ursprünglicher Stand wiederhergestellt.', { variant: 'info' }); } handlePartyChange(partyId) { const { mode } = this.props; const { requirePartyBeforeLines } = this.cfg; if (mode === 'create' && requirePartyBeforeLines && partyId == null) { this.setState({ partyId: null, lines: [emptyLine()], partyError: false }); return; } this.setState({ partyId, partyError: false }); } handleDateChange(date) { this.setState({ date }); } handleNumberChange(number) { this.setState({ number }); } handleExternalNumberChange(externalNumber) { this.setState({ externalNumber }); } handleLineChange(key, field, value) { this.setState((prev) => ({ lines: prev.lines.map((line) => { if (line.key !== key) return line; if (field === 'gross') { const taxVal = parseNum(line.tax ?? line.taxrate) ?? 0; const rebateVal = parseNum(line.rebate) ?? 0; const grossNum = parseNum(value); let updatedNet = line.net; if (grossNum != null) { const netNum = netFromGross(grossNum, taxVal, rebateVal); if (netNum != null) { updatedNet = String(netNum); } } else if (value === '') { updatedNet = ''; } return { ...line, gross: value, net: updatedNet, netprice: updatedNet, }; } if (field === 'net' || field === 'netprice') { const taxVal = parseNum(line.tax ?? line.taxrate) ?? 0; const rebateVal = parseNum(line.rebate) ?? 0; const netNum = parseNum(value); let updatedGross = line.gross; if (netNum != null) { const grossNum = grossFromNet(netNum, taxVal, rebateVal); updatedGross = grossNum != null ? String(grossNum) : ''; } else if (value === '') { updatedGross = ''; } return { ...line, [field]: value, net: value, netprice: value, gross: updatedGross, }; } if (field === 'tax' || field === 'taxrate') { const taxNum = parseNum(value) ?? 0; const rebateVal = parseNum(line.rebate) ?? 0; const netNum = parseNum(line.net ?? line.netprice); let updatedGross = line.gross; if (netNum != null) { const grossNum = grossFromNet(netNum, taxNum, rebateVal); updatedGross = grossNum != null ? String(grossNum) : ''; } return { ...line, [field]: value, tax: value, taxrate: value, gross: updatedGross, }; } if (field === 'rebate') { const rebateNum = parseNum(value) ?? 0; const taxVal = parseNum(line.tax ?? line.taxrate) ?? 0; const netNum = parseNum(line.net ?? line.netprice); let updatedGross = line.gross; if (netNum != null) { const grossNum = grossFromNet(netNum, taxVal, rebateNum); updatedGross = grossNum != null ? String(grossNum) : ''; } return { ...line, rebate: value, gross: updatedGross, }; } return { ...line, [field]: value, }; }), })); } getCustomerGroupId() { const { partyId, parties } = this.state; if (!partyId || !Array.isArray(parties)) return null; const entity = parties.find((p) => String(p.id) === String(partyId)); return entity?.customergroupId ?? null; } async getOrFetchArticle(articleOrSku) { const sku = typeof articleOrSku === 'string' ? articleOrSku : articleOrSku?.sku; const trimmed = String(sku ?? '').trim(); if (!trimmed) return typeof articleOrSku === 'object' ? articleOrSku : null; let art = getArticle(trimmed); if (!art) { try { art = await artikelAutomat.actions.loadArticle(trimmed); } catch { // fallback } } if (art) return art; return typeof articleOrSku === 'object' ? articleOrSku : null; } async handleSkuBlur(key, sku) { const trimmed = String(sku ?? '').trim(); if (!trimmed) return; try { const art = await this.getOrFetchArticle(trimmed); if (art && !this._cancelled) { const normTax = selectCompanyTaxrate() != null ? Number(selectCompanyTaxrate()) : 19; const customerGroupId = this.getCustomerGroupId(); this.setState((prev) => ({ lines: prev.lines.map((l) => { if (l.key !== key) return l; const rebateNum = parseNum(l.rebate) ?? 0; const { net, gross, taxRate } = resolveSalesPrice(art, { customerGroupId, defaultTaxRate: normTax, rebate: rebateNum, }); const netVal = net != null ? String(net) : ''; const grossVal = gross != null ? String(gross) : ''; const finalTax = taxRate != null ? String(taxRate) : l.tax || String(normTax); return { ...l, amount: l.amount || '1', sku: art.sku || l.sku, name: art.name || l.name || '', tax: finalTax, taxrate: finalTax, rebate: l.rebate || '0', net: netVal || l.net, netprice: netVal || l.netprice, gross: grossVal || l.gross, unit: art.unit || l.unit || 'Stück', }; }), })); } } catch { // not in catalog or load error; preserve user entry } } handleAddLine() { this.setState((prev) => ({ lines: [...prev.lines, emptyLine()] })); } async handleAddArticleLine(article) { if (!article) return; const fullArticle = (await this.getOrFetchArticle(article)) || article; const normTax = selectCompanyTaxrate() != null ? Number(selectCompanyTaxrate()) : 19; const customerGroupId = this.getCustomerGroupId(); const { net, gross, taxRate } = resolveSalesPrice(fullArticle, { customerGroupId, defaultTaxRate: normTax, rebate: 0, }); const netVal = net != null ? String(net) : ''; const grossVal = gross != null ? String(gross) : ''; const taxVal = taxRate != null ? String(taxRate) : String(normTax); const newLine = { key: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, amount: '1', sku: fullArticle.sku || article.sku || '', name: fullArticle.name || article.name || '', tax: taxVal, taxrate: taxVal, net: netVal, netprice: netVal, gross: grossVal, rebate: '0', pu: '1', unit: fullArticle.unit || article.unit || 'Stück', }; this.setState((prev) => { if (prev.lines.length === 1 && !prev.lines[0].sku && !prev.lines[0].name) { return { lines: [newLine] }; } return { lines: [...prev.lines, newLine] }; }); } handleOpenArticleSearchForLine(line) { this.setState({ articleSearchDialogOpen: true, articleSearchLine: line, }); } handleCloseArticleSearch() { this.setState({ articleSearchDialogOpen: false, articleSearchLine: null, }); } async handleAssignArticleForLine(article, targetLine) { if (!article) return; if (!targetLine) { await this.handleAddArticleLine(article); this.setState({ articleSearchDialogOpen: false, articleSearchLine: null, }); return; } const fullArticle = (await this.getOrFetchArticle(article)) || article; const normTax = selectCompanyTaxrate() != null ? Number(selectCompanyTaxrate()) : 19; const customerGroupId = this.getCustomerGroupId(); const rebateNum = parseNum(targetLine.rebate) ?? 0; const { net, gross, taxRate } = resolveSalesPrice(fullArticle, { customerGroupId, defaultTaxRate: normTax, rebate: rebateNum, }); const netVal = net != null ? String(net) : ''; const grossVal = gross != null ? String(gross) : ''; const taxVal = taxRate != null ? String(taxRate) : String(normTax); this.setState((prev) => ({ lines: prev.lines.map((l) => { if (l.key !== targetLine.key) return l; return { ...l, amount: l.amount || '1', sku: fullArticle.sku || article.sku || l.sku, name: fullArticle.name || article.name || l.name, tax: taxVal || l.tax || String(normTax), taxrate: taxVal || l.taxrate || String(normTax), rebate: l.rebate || '0', net: netVal || l.net, netprice: netVal || l.netprice, gross: grossVal || l.gross, unit: fullArticle.unit || article.unit || l.unit || 'Stück', }; }), articleSearchDialogOpen: false, articleSearchLine: null, })); } isFullyDelivered() { if (this.state.delivered || this.state.isShipped || this.state.shipped) return true; const lines = this.state.lines || []; const shippableLines = lines.filter((l) => l.shippable !== false && Number(l.amount) > 0); if (shippableLines.length === 0) return lines.length > 0; const shippedBySku = {}; const shippedByOrderLineId = {}; const shippedByName = {}; for (const sh of this.state.shipments || []) { for (const sl of sh.lines || []) { const amt = Number(sl.amount) || 0; if (sl.sku) { const k = String(sl.sku).trim().toLowerCase(); shippedBySku[k] = (shippedBySku[k] || 0) + amt; } if (sl.orderLineId != null) { shippedByOrderLineId[sl.orderLineId] = (shippedByOrderLineId[sl.orderLineId] || 0) + amt; } if (sl.name) { const n = String(sl.name).trim().toLowerCase(); shippedByName[n] = (shippedByName[n] || 0) + amt; } } } const remainingShippedByName = { ...shippedByName }; return shippableLines.every((l) => { const amt = Number(l.amount) || 0; if (l.sku) { const k = String(l.sku).trim().toLowerCase(); return (shippedBySku[k] || 0) >= amt; } if (l.id != null && shippedByOrderLineId[l.id] != null) { return shippedByOrderLineId[l.id] >= amt; } if (l.name) { const n = String(l.name).trim().toLowerCase(); const pool = remainingShippedByName[n] || 0; if (pool >= amt) { remainingShippedByName[n] = pool - amt; return true; } return false; } return false; }); } handleRemoveLine(key) { this.setState((prev) => { const lineToRemove = prev.lines.find((line) => line.key === key); if (lineToRemove && this.props.kind === 'order') { const rawSku = lineToRemove.sku != null ? String(lineToRemove.sku).trim().toLowerCase() : ''; const orderLineId = lineToRemove.id; const lineName = lineToRemove.name != null ? String(lineToRemove.name).trim().toLowerCase() : ''; const isShipped = Boolean( (prev.shipments || []).some((sh) => (sh.lines || []).some((sl) => (rawSku && sl.sku && String(sl.sku).trim().toLowerCase() === rawSku) || (orderLineId != null && sl.orderLineId != null && sl.orderLineId === orderLineId) || (!rawSku && !sl.sku && lineName && sl.name && String(sl.name).trim().toLowerCase() === lineName) ) ) ); if (isShipped) { enqueueSnackbar(`Artikel „${lineToRemove.sku || lineToRemove.name}“ ist bereits auf einem Lieferschein und kann nicht entfernt werden.`, { variant: 'warning' }); return null; } } const next = prev.lines.filter((line) => line.key !== key); return { lines: next, isDirty: true }; }); } handleMoveLine(key, delta) { this.setState((prev) => { const fromIndex = prev.lines.findIndex((l) => l.key === key); if (fromIndex < 0) return null; const toIndex = fromIndex + delta; if (toIndex < 0 || toIndex >= prev.lines.length) return null; const next = [...prev.lines]; const [item] = next.splice(fromIndex, 1); next.splice(toIndex, 0, item); return { lines: next, isDirty: true }; }); } handleRemoveAllLines() { this.setState((prev) => { let nextLines = []; if (this.props.kind === 'order') { const shippedSkus = new Set(); for (const sh of prev.shipments || []) { for (const sl of sh.lines || []) { if (sl.sku) shippedSkus.add(String(sl.sku).trim().toLowerCase()); } } if (shippedSkus.size > 0) { const remaining = (prev.lines || []).filter((l) => { const raw = l.sku != null ? String(l.sku).trim().toLowerCase() : ''; return shippedSkus.has(raw); }); if (remaining.length === prev.lines.length) { enqueueSnackbar('Alle Positionen sind bereits auf Lieferscheinen und können nicht gelöscht werden.', { variant: 'warning' }); return null; } enqueueSnackbar('Nicht ausgelieferte Positionen wurden entfernt.', { variant: 'info' }); nextLines = remaining; } } return { lines: nextLines, isDirty: true }; }); } async handlePdfUpload(file) { if (!file || this.state.pdfUploading) return; if (this._parseAbortController) { this._parseAbortController.abort(); } const abortController = new AbortController(); this._parseAbortController = abortController; this.setState({ pdfUploading: true, pdfDialogOpen: true, pdfFileName: file.name, pdfDialogData: { streaming: true, messages: [], result: null, error: null, aborted: false }, }); const appendMessage = (msg) => { if (this._cancelled || abortController.signal.aborted) return; this.setState((prev) => ({ pdfDialogData: { ...prev.pdfDialogData, messages: [...(prev.pdfDialogData?.messages ?? []), msg], }, })); }; try { const gen = deliveryApi.parsePdfSse; if (!gen) throw new Error('parsePdfSse nicht verfügbar.'); for await (const { event, data } of gen(file, { signal: abortController.signal })) { if (this._cancelled || abortController.signal.aborted) return; switch (event) { case 'status': appendMessage({ type: 'status', text: data.message ?? String(data) }); break; case 'llm_call': appendMessage({ type: 'llm_call', text: `LLM-Aufruf: ${data.model} (${data.promptSize} tokens, ${data.messages} msg)` }); break; case 'tool_call': appendMessage({ type: 'tool_call', text: `Tool-Aufruf: ${data.name}` }); break; case 'usage': appendMessage({ type: 'usage', text: `Token-Verbrauch: ${data.input} in / ${data.cached} cached / ${data.output} out — $${(data.cost ?? 0).toFixed(4)}` }); break; case 'progress': this.setState((prev) => ({ pdfDialogData: { ...prev.pdfDialogData, result: data }, })); break; case 'result': this.setState((prev) => ({ pdfDialogData: { ...prev.pdfDialogData, result: data, streaming: false }, })); break; case 'error': this.setState((prev) => ({ pdfDialogData: { ...prev.pdfDialogData, error: data.message ?? String(data), streaming: false }, })); return; default: break; } } } catch (err) { if (this._cancelled) return; if (abortController.signal.aborted || err.name === 'AbortError') { this.setState((prev) => ({ pdfDialogData: { ...prev.pdfDialogData, streaming: false, aborted: true, error: 'Analyse abgebrochen.', }, })); return; } notifyError(err); this.setState((prev) => ({ pdfDialogData: { ...(prev.pdfDialogData ?? {}), error: err.message || String(err), streaming: false, }, })); } finally { if (this._parseAbortController === abortController) { this._parseAbortController = null; } if (!this._cancelled) { this.setState((prev) => ({ pdfUploading: false, pdfDialogData: prev.pdfDialogData ? { ...prev.pdfDialogData, streaming: false } : null, })); } } } handleAbortParse() { if (this._parseAbortController) { this._parseAbortController.abort(); this._parseAbortController = null; } this.setState((prev) => ({ pdfUploading: false, pdfDialogData: prev.pdfDialogData ? { ...prev.pdfDialogData, streaming: false, aborted: true, error: 'Analyse abgebrochen.', } : null, })); } handleClosePdfDialog() { if (this._parseAbortController) { this._parseAbortController.abort(); this._parseAbortController = null; } this.setState({ pdfDialogOpen: false, pdfUploading: false }); } async handleCreatePdf(e) { e?.preventDefault?.(); e?.stopPropagation?.(); const { docId, kind } = this.props; if (this.state.pdfCreating || this.state.saving) return; if (docId == null) return; this.setState({ pdfCreating: true }); try { const api = kind === 'order' ? orderApi : quoteApi; const res = await api.createPdf(docId); requireObject(res, `POST /api/${kind}/:id/pdf`); const getRes = await api.get(docId); const doc = getRes?.[kind]; if (doc?.files && !this._cancelled) { const attachments = await restoreAttachmentsFromApi( doc.files, this.state.attachments, ); this.setState({ attachments }); if (this.editAutomat) { this.editAutomat.actions.setAttachments?.(attachments); } } enqueueSnackbar( kind === 'order' ? 'Auftrag-PDF erfolgreich erstellt.' : 'Angebot-PDF erfolgreich erstellt.', { variant: 'success' }, ); } catch (err) { if (!this._cancelled) { notifyError(err); } } finally { if (!this._cancelled) { this.setState({ pdfCreating: false }); } } } // MARK: calls quoteApi.createOrder from ./api/index.js // #region POST /api/quote/:id/order — createOrder async handleCreateOrder(e) { e?.preventDefault?.(); e?.stopPropagation?.(); const { docId, kind } = this.props; if (this.state.orderCreating || this.state.saving) return; if (docId == null || kind !== 'quote') return; if (!this.state.partyId) { this.setState({ partyError: true }); notifyErrorMessage( 'Für eine Bestellung muss ein Kunde ausgewählt sein. Bitte wählen Sie zuerst einen Kunden aus.', ); this.headerRef.current?.focusPartyInput?.(); return; } if (this.state.isDirty) { const saved = await this.handleSave(); if (!saved) return; } this.setState({ orderCreating: true }); try { const res = await quoteApi.createOrder(docId, { entityId: this.state.partyId, }); requireObject(res, 'POST /api/quote/:id/order'); const order = requireObject(res.order, 'order'); requireDefined(order.id, 'order.id'); void orderAutomat.actions.reload(); enqueueSnackbar( order.number ? `Bestellung ${order.number} erfolgreich erstellt.` : 'Bestellung erfolgreich erstellt.', { variant: 'success' }, ); this.props.navigate(`/auftrag/bestellung/${order.id}`); } catch (err) { if (!this._cancelled) { notifyError(err); this.setState({ orderCreating: false }); } } } // #endregion async handleOpenCreateShipment() { if (this.state.isDirty) { const saved = await this.handleSave(); if (!saved) return; } this.setState({ shipmentDialogOpen: true }); } handleCloseShipmentDialog() { this.setState({ shipmentDialogOpen: false }); } async handleShipmentCreated() { await this.loadEdit(); if (this.props.kind === 'retoure') { void retoureAutomat.actions.reload(); } else { void orderAutomat.actions.reload(); } } async handleAttachmentDelete(attachment) { if (!attachment) return; const fileId = attachment.fileId ?? attachment.id; const { docId } = this.props; const nextAttachments = (this.state.attachments || []).filter( (a) => (a.fileId ?? a.id) !== fileId && a !== attachment, ); this.setState({ attachments: nextAttachments }); if (this.editAutomat) { this.editAutomat.actions.setAttachments?.(nextAttachments); } if (docId != null && fileId != null && attachment.isPersisted) { try { const api = apiFor(this.cfg); if (typeof api.deleteFile === 'function') { await api.deleteFile(docId, fileId); enqueueSnackbar('Anhang gelöscht.', { variant: 'success' }); } } catch (err) { notifyError(err); } } } async handleSave() { const { mode, docId } = this.props; const cfg = this.cfg; if (this.state.saving) return false; if (mode === 'edit' && docId == null) return false; if (cfg.kind === 'order' && (this.state.partyId == null || this.state.partyId === '')) { this.setState({ partyError: true }); notifyErrorMessage('Für eine Bestellung ist ein Kunde erforderlich.'); this.headerRef.current?.focusPartyInput?.(); return false; } const isEdit = mode === 'edit'; let body; try { body = await buildManifestBody( { number: this.state.number, externalNumber: this.state.externalNumber, date: this.state.date, partyId: this.state.partyId, lines: this.state.lines, attachments: this.state.attachments, }, { kind: cfg.kind, partyField: cfg.partyField, requireAtLeastOne: !isEdit, externalNumber: cfg.externalNumber, isUpdate: isEdit, mode, }, ); } catch (err) { notifyError(err); return false; } this.setState({ saving: true }); const api = apiFor(cfg); try { if (mode === 'create') { const data = await api.create(body); requireObject(data, cfg.postPathLabel); requireObject(data[cfg.docField], cfg.docField); requireDefined(data[cfg.docField].id, `${cfg.docField}.id`); this.draftAutomat?.actions?.reset(); await cfg.listAutomat.actions.reload(); if (this._cancelled) return false; enqueueSnackbar(cfg.createdMsg, { variant: 'success' }); this.props.navigate(cfg.editPath(data[cfg.docField].id)); return true; } const data = await api.update(docId, body); requireObject(data, cfg.patchPathLabel); requireObject(data[cfg.docField], cfg.docField); requireDefined(data[cfg.docField].id, `${cfg.docField}.id`); await cfg.listAutomat.actions.reload(); if (this._cancelled) return false; const doc = data[cfg.docField]; const apiLines = requireArray(doc.lines, `${cfg.docField}.lines`); const savedDoc = { date: dateInputValue(doc.date), createdAt: doc.createdAt ?? doc.created_at ?? this.state.createdAt ?? null, partyId: doc[cfg.partyField], number: doc.number ?? '', externalNumber: doc.externalNumber != null ? String(doc.externalNumber) : '', lines: apiLines.map(lineFromApi), attachments: this.state.attachments, }; const linesGross = computeDocGrossTotal(savedDoc.lines); const savedDocWithTotals = { ...savedDoc, orderTotal: doc.orderTotal ?? doc.grossTotal ?? doc.gross_total ?? this.state.orderTotal ?? (linesGross !== 0 ? linesGross : null), paidTotal: doc.paidTotal ?? doc.paid_total ?? this.state.paidTotal ?? null, shipments: this.state.shipments, payments: this.state.payments, }; if (this.editAutomat && String(this.editAutomat.state.id) === String(docId)) { this.editAutomat.actions.commitSaved(savedDocWithTotals); } this.setState({ ...savedDocWithTotals, isDirty: false, dirtyFields: null, dirtyLineKeys: [], dirtyLineFields: {}, dirtySummary: [], saving: false, }); enqueueSnackbar(cfg.savedMsg, { variant: 'success' }); return true; } catch (err) { if (this._cancelled) return false; notifyError(err); this.setState({ saving: false }); return false; } } renderArticles() { const cfg = this.cfg; const { lines, saving, dirtyLineFields, shipments } = this.state; return ( void this.handleSave()} /> ); } render() { const { kind } = this.props; const cfg = this.cfg; const { status, error, parties, partiesLoading, date, partyId, number, externalNumber, lines, saving, pdfUploading, pdfDialogOpen, pdfDialogData, pdfFileName, } = this.state; if (status === 'loading') { return ( ); } if (status === 'failed') { return ; } return ( <> {status === 'ready' ? this.renderArticles() : null} {status === 'ready' && (kind === 'order' || kind === 'retoure') && this.props.mode === 'edit' ? ( 0 ? this.state.payments.reduce((acc, p) => acc + (Number(p.amount) || 0), 0) : 0) } paid={this.state.paid} shipped={this.state.shipped} delivered={this.state.delivered} shipments={this.state.shipments} payments={this.state.payments} attachments={this.state.attachments} deliverDisabled={this.isFullyDelivered()} onPdfCreate={this.handleCreatePdf} pdfCreating={this.state.pdfCreating} onAttachmentDelete={this.handleAttachmentDelete} onOpenCreateShipment={this.handleOpenCreateShipment} onShipmentChanged={this.handleShipmentCreated} navigate={this.props.navigate} /> ) : null} {status === 'ready' && kind === 'order' && this.props.mode === 'edit' ? ( ) : null} ); } } export default withNavigate(ManifestForm);