feat(lieferung/neu): PDF upload with SSE streaming and result dialog

- Add PDF-Upload button to ManifestHeader (delivery forms only)
- New ManifestPdfDialog: live SSE event log, structured basedata,
  and line-items table streamed from POST /api/delivery/parsePDF
- ApiClient.delivery.parsePdfSse: multipart/form-data upload,
  async generator yielding typed SSE events (status, llm_call,
  tool_call, usage, progress, result, error)
- ManifestForm.handlePdfUpload: drives SSE generator, updates
  dialog state in real-time; dialog opens immediately on upload
- Fix MUI icon imports (CheckCircle, ErrorOutlined)
This commit is contained in:
seb
2026-09-15 08:50:16 +02:00
parent 4df5110253
commit dbf978d015
9 changed files with 2064 additions and 33 deletions

View File

@@ -12,6 +12,7 @@ import { notifyError } from '../../utils/notify';
import withNavigate from '../withNavigate';
import ManifestHeader from './ManifestHeader';
import ManifestLines from './ManifestLines';
import ManifestPdfDialog from './ManifestPdfDialog';
import {
buildManifestBody,
dateInputValue,
@@ -38,6 +39,10 @@ class ManifestForm extends PureComponent {
externalNumber: '',
lines: [emptyLine()],
saving: false,
pdfUploading: false,
pdfDialogOpen: false,
pdfDialogData: null,
pdfFileName: '',
};
this._cancelled = false;
this.handleRetry = this.handleRetry.bind(this);
@@ -50,6 +55,8 @@ class ManifestForm extends PureComponent {
this.handleAddLine = this.handleAddLine.bind(this);
this.handleRemoveLine = this.handleRemoveLine.bind(this);
this.handleSave = this.handleSave.bind(this);
this.handlePdfUpload = this.handlePdfUpload.bind(this);
this.handleClosePdfDialog = this.handleClosePdfDialog.bind(this);
}
componentDidMount() {
@@ -185,6 +192,83 @@ class ManifestForm extends PureComponent {
});
}
async handlePdfUpload(file) {
if (!file || this.state.pdfUploading) return;
this.setState({
pdfUploading: true,
pdfDialogOpen: true,
pdfFileName: file.name,
pdfDialogData: { streaming: true, messages: [], result: null, error: null },
});
const appendMessage = (msg) => {
if (this._cancelled) 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)) {
if (this._cancelled) 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;
notifyError(err);
this.setState((prev) => ({
pdfDialogData: {
...(prev.pdfDialogData ?? {}),
error: err.message || String(err),
streaming: false,
},
}));
} finally {
if (!this._cancelled) this.setState({ pdfUploading: false });
}
}
handleClosePdfDialog() {
this.setState({ pdfDialogOpen: false });
}
async handleSave() {
const { mode, docId } = this.props;
const cfg = this.cfg;
@@ -298,6 +382,10 @@ class ManifestForm extends PureComponent {
number,
externalNumber,
saving,
pdfUploading,
pdfDialogOpen,
pdfDialogData,
pdfFileName,
} = this.state;
if (status === 'loading') {
@@ -313,26 +401,36 @@ class ManifestForm extends PureComponent {
}
return (
<PanelTileGrid>
<ManifestHeader
kind={kind}
headerColor={cfg.headerColor}
dateLabel={cfg.dateLabel}
partyLabel={cfg.partyLabel}
parties={parties}
partiesLoading={partiesLoading || status === 'loadingParties'}
partyId={partyId}
date={date}
number={number}
externalNumber={externalNumber}
saving={saving}
onPartyChange={this.handlePartyChange}
onDateChange={this.handleDateChange}
onNumberChange={this.handleNumberChange}
onExternalNumberChange={this.handleExternalNumberChange}
<>
<PanelTileGrid>
<ManifestHeader
kind={kind}
headerColor={cfg.headerColor}
dateLabel={cfg.dateLabel}
partyLabel={cfg.partyLabel}
parties={parties}
partiesLoading={partiesLoading || status === 'loadingParties'}
partyId={partyId}
date={date}
number={number}
externalNumber={externalNumber}
saving={saving}
pdfUploading={pdfUploading}
onPdfUpload={kind === 'delivery' ? this.handlePdfUpload : undefined}
onPartyChange={this.handlePartyChange}
onDateChange={this.handleDateChange}
onNumberChange={this.handleNumberChange}
onExternalNumberChange={this.handleExternalNumberChange}
/>
{status === 'ready' ? this.renderArticles() : null}
</PanelTileGrid>
<ManifestPdfDialog
open={pdfDialogOpen}
filename={pdfFileName}
data={pdfDialogData}
onClose={this.handleClosePdfDialog}
/>
{status === 'ready' ? this.renderArticles() : null}
</PanelTileGrid>
</>
);
}
}