import { PureComponent } from 'react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import CircularProgress from '@mui/material/CircularProgress'; import Typography from '@mui/material/Typography'; import { enqueueSnackbar } from 'notistack'; import BackendError from '../BackendError'; import { PageTitleRight } from '../PageLayout'; import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import { StorageManagerContext } from '../../api/storageManagerContext'; 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 { buildManifestBody, restoreAttachmentsFromApi, dateInputValue, emptyLine, lineFromApi, loadParties, manifestConfig, } from './manifestConfig'; class ManifestForm extends PureComponent { static contextType = StorageManagerContext; 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: '', }; 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.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); 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, this.context); 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([ this.context[cfg.api].get(docId), loadParties(kind, this.context), ]); 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) => line.key === key ? { ...line, [field]: value, ...(field === 'net' ? { netprice: value } : {}), ...(field === 'netprice' ? { net: value } : {}), ...(field === 'tax' ? { taxrate: value } : {}), ...(field === 'taxrate' ? { tax: value } : {}), } : line, ), })); } handleAddLine() { this.setState((prev) => ({ lines: [...prev.lines, emptyLine()] })); } 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 = this.context?.delivery?.parsePdfSse; if (!gen) throw new Error('parsePdfSse nicht verfügbar.'); for await (const { event, data } of this.context.delivery.parsePdfSse(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 }); try { if (mode === 'create') { const data = await this.context[cfg.api].create(body); requireObject(data, cfg.postPathLabel); requireObject(data[cfg.docField], cfg.docField); requireDefined(data[cfg.docField].id, `${cfg.docField}.id`); await this.context[cfg.updateCache](); if (this._cancelled) return; enqueueSnackbar(cfg.createdMsg, { variant: 'success' }); this.props.navigate(cfg.editPath(data[cfg.docField].id)); return; } const data = await this.context[cfg.api].update(docId, body); requireObject(data, cfg.patchPathLabel); requireObject(data[cfg.docField], cfg.docField); requireDefined(data[cfg.docField].id, `${cfg.docField}.id`); await this.context[cfg.updateCache](); 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, mode } = 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);