import { PureComponent } 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 } from '../../api'; import { requireArray, requireDefined, requireObject } from '../../utils/apiShape'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; import withNavigate from '../withNavigate'; import ManifestHeader from './ManifestHeader'; import ManifestLines from './ManifestLines'; import ManifestPdfDialog from './ManifestPdfDialog'; import ArticleSearchDialog from './ArticleSearchDialog'; import artikelAutomat, { getArticle } from '../../automats/artikelAutomat'; import { netFromGross, getArticleGross } from '../../utils/price'; import { buildManifestBody, restoreAttachmentsFromApi, dateInputValue, emptyLine, lineFromApi, loadParties, manifestConfig, apiFor, parseNum, } from './manifestConfig'; class ManifestForm extends PureComponent { constructor(props) { super(props); this.cfg = manifestConfig(props.kind); this.state = { status: props.mode === 'edit' ? 'loading' : 'loadingParties', error: null, parties: [], partiesLoading: true, date: '', partyId: null, number: '', externalNumber: '', lines: [emptyLine()], attachments: [], saving: false, pdfUploading: false, pdfDialogOpen: false, pdfDialogData: null, pdfFileName: '', articleSearchDialogOpen: false, articleSearchLine: null, }; this._cancelled = false; this.handleRetry = this.handleRetry.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.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.handleRemoveAllLines = this.handleRemoveAllLines.bind(this); this.handleSave = this.handleSave.bind(this); this.handlePdfUpload = this.handlePdfUpload.bind(this); this.handleClosePdfDialog = this.handleClosePdfDialog.bind(this); this.handleAbortParse = this.handleAbortParse.bind(this); } componentDidMount() { this._cancelled = false; if (this.props.mode === 'edit') void this.loadEdit(); else void this.loadPartiesOnly(); } componentDidUpdate(prevProps) { if ( this.props.mode === 'edit' && prevProps.docId !== this.props.docId ) { void this.loadEdit(); } } componentWillUnmount() { this._cancelled = true; if (this._parseAbortController) { this._parseAbortController.abort(); this._parseAbortController = null; } } async loadPartiesOnly() { 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; 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; } 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.length > 0 ? apiLines.map(lineFromApi) : [emptyLine()]; // Restore attachments if available const apiFiles = doc.files ?? []; const attachments = await restoreAttachmentsFromApi(apiFiles); this.setState({ status: 'ready', parties, partiesLoading: false, date: dateInputValue(doc.date), partyId: doc[cfg.partyField], number: doc.number ?? '', externalNumber: doc.externalNumber != null ? String(doc.externalNumber) : '', lines, attachments, }); } 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(); } handlePartyChange(partyId) { const { mode } = this.props; const { requirePartyBeforeLines } = this.cfg; if (mode === 'create' && requirePartyBeforeLines && partyId == null) { this.setState({ partyId: null, lines: [emptyLine()] }); return; } this.setState({ partyId }); } 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 grossNum = parseNum(value); let updatedNet = line.net; if (grossNum != null) { const netNum = netFromGross(grossNum, taxVal); 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 netNum = parseNum(value); let updatedGross = line.gross; if (netNum != null) { const grossNum = Math.round((netNum * (1 + taxVal / 100) + Number.EPSILON) * 100) / 100; updatedGross = 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 netNum = parseNum(line.net ?? line.netprice); let updatedGross = line.gross; if (netNum != null) { const grossNum = Math.round((netNum * (1 + taxNum / 100) + Number.EPSILON) * 100) / 100; updatedGross = String(grossNum); } return { ...line, [field]: value, tax: value, taxrate: value, gross: updatedGross, }; } return { ...line, [field]: value, }; }), })); } async handleSkuBlur(key, sku) { const trimmed = String(sku ?? '').trim(); if (!trimmed) return; try { const art = getArticle(trimmed) || (await artikelAutomat.actions.loadArticle(trimmed)); if (art && !this._cancelled) { this.setState((prev) => ({ lines: prev.lines.map((l) => { if (l.key !== key) return l; const netVal = art.price != null ? String(art.price) : art.vknetto != null ? String(art.vknetto) : art.net != null ? String(art.net) : ''; const taxVal = art.taxrate != null ? String(art.taxrate) : art.tax != null ? String(art.tax) : ''; const netNum = parseNum(netVal); const taxNum = parseNum(taxVal); const artGross = getArticleGross(art, taxNum ?? 0); let grossVal = ''; if (artGross != null) { grossVal = String(artGross); } else if (netNum != null) { const g = Math.round((netNum * (1 + (taxNum ?? 0) / 100) + Number.EPSILON) * 100) / 100; grossVal = String(g); } return { ...l, sku: art.sku || l.sku, name: art.name || l.name || '', tax: l.tax || taxVal, taxrate: l.taxrate || taxVal, net: l.net || netVal, netprice: l.netprice || netVal, gross: l.gross || grossVal, 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()] })); } handleAddArticleLine(article) { if (!article) return; const netVal = article.price != null ? String(article.price) : article.vknetto != null ? String(article.vknetto) : article.net != null ? String(article.net) : ''; const taxVal = article.taxrate != null ? String(article.taxrate) : article.tax != null ? String(article.tax) : ''; const netNum = parseNum(netVal); const taxNum = parseNum(taxVal); const artGross = getArticleGross(article, taxNum ?? 0); let grossVal = ''; if (artGross != null) { grossVal = String(artGross); } else if (netNum != null) { const g = Math.round((netNum * (1 + (taxNum ?? 0) / 100) + Number.EPSILON) * 100) / 100; grossVal = String(g); } const newLine = { key: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, amount: '1', sku: article.sku || '', name: article.name || '', tax: taxVal, taxrate: taxVal, net: netVal, netprice: netVal, gross: grossVal, rebate: '', pu: '1', 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, }); } handleAssignArticleForLine(article, targetLine) { if (!article) return; if (!targetLine) { this.handleAddArticleLine(article); this.setState({ articleSearchDialogOpen: false, articleSearchLine: null, }); return; } const netVal = article.price != null ? String(article.price) : article.vknetto != null ? String(article.vknetto) : article.net != null ? String(article.net) : ''; const taxVal = article.taxrate != null ? String(article.taxrate) : article.tax != null ? String(article.tax) : ''; const netNum = parseNum(netVal); const taxNum = parseNum(taxVal); const artGross = getArticleGross(article, taxNum ?? 0); let grossVal = ''; if (artGross != null) { grossVal = String(artGross); } else if (netNum != null) { const g = Math.round((netNum * (1 + (taxNum ?? 0) / 100) + Number.EPSILON) * 100) / 100; grossVal = String(g); } this.setState((prev) => ({ lines: prev.lines.map((l) => { if (l.key !== targetLine.key) return l; return { ...l, sku: article.sku || l.sku, name: article.name || l.name, tax: taxVal || l.tax, taxrate: taxVal || l.taxrate, net: netVal || l.net, netprice: netVal || l.netprice, gross: grossVal || l.gross, unit: article.unit || l.unit || 'Stück', }; }), articleSearchDialogOpen: false, articleSearchLine: null, })); } handleRemoveLine(key) { this.setState((prev) => { const next = prev.lines.filter((line) => line.key !== key); return { lines: next.length > 0 ? next : [emptyLine()] }; }); } handleRemoveAllLines() { this.setState({ lines: [] }); } 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 handleSave() { const { mode, docId } = this.props; const cfg = this.cfg; if (this.state.saving) return; if (mode === 'edit' && docId == null) return; const requireAtLeastOne = mode === 'create'; 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, }, { partyField: cfg.partyField, requireAtLeastOne, externalNumber: cfg.externalNumber, }, ); } catch (err) { notifyError(err); return; } 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`); await cfg.listAutomat.actions.reload(); if (this._cancelled) return; enqueueSnackbar(cfg.createdMsg, { variant: 'success' }); this.props.navigate(cfg.editPath(data[cfg.docField].id)); return; } 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; const doc = data[cfg.docField]; const apiLines = requireArray(doc.lines, `${cfg.docField}.lines`); this.setState({ date: dateInputValue(doc.date), partyId: doc[cfg.partyField], number: doc.number ?? '', externalNumber: doc.externalNumber != null ? String(doc.externalNumber) : '', lines: apiLines.length > 0 ? apiLines.map(lineFromApi) : [emptyLine()], saving: false, }); enqueueSnackbar(cfg.savedMsg, { variant: 'success' }); } catch (err) { if (this._cancelled) return; notifyError(err); this.setState({ saving: false }); } } renderArticles() { const cfg = this.cfg; const { lines, saving } = 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} ); } } export default withNavigate(ManifestForm);