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>
</>
);
}
}

View File

@@ -1,7 +1,10 @@
import { PureComponent } from 'react';
import { PureComponent, createRef } from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { PanelTile } from 'dashgui/panels';
import { manifestConfig, partyDisplayName } from './manifestConfig';
@@ -9,13 +12,27 @@ import { manifestConfig, partyDisplayName } from './manifestConfig';
class ManifestHeader extends PureComponent {
constructor(props) {
super(props);
this.fileInputRef = createRef();
this.handlePartyChange = this.handlePartyChange.bind(this);
this.handleUploadClick = this.handleUploadClick.bind(this);
this.handleFileChange = this.handleFileChange.bind(this);
}
handlePartyChange(_event, option) {
this.props.onPartyChange(option?.id ?? null);
}
handleUploadClick() {
this.fileInputRef.current?.click();
}
handleFileChange(event) {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
this.props.onPdfUpload?.(file);
}
render() {
const {
kind,
@@ -29,6 +46,8 @@ class ManifestHeader extends PureComponent {
number,
externalNumber,
saving,
pdfUploading,
onPdfUpload,
onDateChange,
onNumberChange,
onExternalNumberChange,
@@ -44,13 +63,13 @@ class ManifestHeader extends PureComponent {
return (
<PanelTile wide={3} minHeight={160} color={headerColor} title="Kopfdaten">
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ xs: 'stretch', sm: 'center' }}>
<TextField
size="small"
type="date"
label={dateLabel}
value={date}
disabled={saving}
disabled={saving || pdfUploading}
onChange={(e) => onDateChange(e.target.value)}
autoComplete="off"
slotProps={{
@@ -64,7 +83,7 @@ class ManifestHeader extends PureComponent {
options={parties}
value={selected}
loading={Boolean(partiesLoading)}
disabled={saving}
disabled={saving || pdfUploading}
getOptionLabel={(option) => partyDisplayName(kind, option)}
isOptionEqualToValue={(option, value) => option.id === value.id}
onChange={this.handlePartyChange}
@@ -88,7 +107,7 @@ class ManifestHeader extends PureComponent {
size="small"
label="Nummer"
value={number}
disabled={saving || useExternalNumber}
disabled={saving || pdfUploading || useExternalNumber}
onChange={onNumberChange}
sx={{ minWidth: 180, flex: 1 }}
/>
@@ -97,15 +116,37 @@ class ManifestHeader extends PureComponent {
size="small"
label="Referenz"
value={externalNumber ?? ''}
disabled={saving}
disabled={saving || pdfUploading}
onChange={onExternalNumberChange}
sx={{ minWidth: 180, flex: 1 }}
/>
) : null}
{onPdfUpload ? (
<Button
variant="outlined"
size="small"
startIcon={pdfUploading ? <CircularProgress size={16} color="inherit" /> : <UploadFileIcon />}
disabled={saving || pdfUploading}
onClick={this.handleUploadClick}
sx={{ whiteSpace: 'nowrap', height: 40, alignSelf: { xs: 'stretch', sm: 'center' }, px: 2 }}
>
{pdfUploading ? 'Wird analysiert…' : 'PDF hochladen'}
</Button>
) : null}
</Stack>
{onPdfUpload ? (
<input
ref={this.fileInputRef}
type="file"
accept="application/pdf,.pdf"
style={{ display: 'none' }}
onChange={this.handleFileChange}
/>
) : null}
</PanelTile>
);
}
}
export default ManifestHeader;

View File

@@ -0,0 +1,273 @@
import { useEffect, useRef, useState } 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 Collapse from '@mui/material/Collapse';
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 Divider from '@mui/material/Divider';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Typography from '@mui/material/Typography';
import Alert from '@mui/material/Alert';
import CloseIcon from '@mui/icons-material/Close';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
/** Chip colour by SSE event type */
const EVENT_CHIP_COLOR = {
status: 'default',
llm_call: 'primary',
tool_call: 'secondary',
usage: 'info',
};
function EventLog({ messages, streaming }) {
const bottomRef = useRef(null);
const [expanded, setExpanded] = useState(true);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, [messages]);
if (!messages?.length && !streaming) return null;
return (
<Box>
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 1, cursor: 'pointer', userSelect: 'none', mb: 0.5 }}
onClick={() => setExpanded((v) => !v)}
>
<Typography variant="caption" color="text.secondary" fontWeight={600}>
LLM-Protokoll
</Typography>
{streaming ? (
<CircularProgress size={12} sx={{ ml: 'auto' }} />
) : (
<CheckCircleIcon sx={{ ml: 'auto', fontSize: 16, color: 'success.main' }} />
)}
<IconButton size="small" sx={{ p: 0 }}>
{expanded ? <ExpandLessIcon fontSize="small" /> : <ExpandMoreIcon fontSize="small" />}
</IconButton>
</Box>
<Collapse in={expanded}>
<Box
sx={{
maxHeight: 180,
overflowY: 'auto',
border: 1,
borderColor: 'divider',
borderRadius: 1,
bgcolor: (t) => (t.palette.mode === 'dark' ? 'grey.900' : 'grey.50'),
p: 1,
}}
>
{messages.map((msg, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'baseline', gap: 1, mb: 0.25 }}>
<Chip
label={msg.type}
size="small"
color={EVENT_CHIP_COLOR[msg.type] ?? 'default'}
sx={{ fontFamily: 'monospace', fontSize: '0.65rem', height: 18, flexShrink: 0 }}
/>
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontSize: '0.75rem', wordBreak: 'break-all' }}>
{msg.text}
</Typography>
</Box>
))}
{streaming && !messages.length && (
<Typography variant="caption" color="text.secondary">
Verbinde…
</Typography>
)}
<div ref={bottomRef} />
</Box>
</Collapse>
</Box>
);
}
function BaseDataSection({ basedata }) {
if (!basedata) return null;
return (
<Box>
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ display: 'block', mb: 0.5 }}>
Kopfdaten
</Typography>
<Stack direction="row" flexWrap="wrap" gap={1}>
{basedata.date && <Chip size="small" label={`Datum: ${basedata.date}`} />}
{basedata.manifestNr && <Chip size="small" label={`Nr: ${basedata.manifestNr}`} />}
{basedata.manifestType && <Chip size="small" label={`Typ: ${basedata.manifestType}`} />}
{basedata.lieferantName && <Chip size="small" label={`Lieferant: ${basedata.lieferantName}`} />}
{basedata.kLieferant != null && <Chip size="small" label={`kLieferant: ${basedata.kLieferant}`} />}
</Stack>
</Box>
);
}
function ItemsSection({ items }) {
if (!items?.items?.length) return null;
return (
<Box>
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ display: 'block', mb: 0.5 }}>
Positionen ({items.items.length})
{items.pagesFrom != null && (
<span style={{ fontWeight: 400, marginLeft: 4 }}>
— Seiten {items.pagesFrom}–{items.pagesTo}
</span>
)}
</Typography>
<Box sx={{ overflowX: 'auto', border: 1, borderColor: 'divider', borderRadius: 1 }}>
<Table size="small" padding="none" sx={{ minWidth: 520 }}>
<TableHead>
<TableRow sx={{ bgcolor: 'action.hover' }}>
{['Pos.', 'Artikel', 'LiefArtNr', 'Menge', 'VPE', 'Einheit', 'EK-Netto', 'MwSt'].map((h) => (
<TableCell key={h} sx={{ px: 1, py: 0.5, fontSize: '0.72rem', fontWeight: 700, whiteSpace: 'nowrap' }}>
{h}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{items.items.map((item, i) => (
<TableRow key={i} hover>
<TableCell sx={{ px: 1, py: 0.4, fontSize: '0.75rem' }}>{item.position ?? i + 1}</TableCell>
<TableCell sx={{ px: 1, py: 0.4, fontSize: '0.75rem', maxWidth: 180, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.articleName ?? '–'}
</TableCell>
<TableCell sx={{ px: 1, py: 0.4, fontSize: '0.75rem', fontFamily: 'monospace' }}>{item.liefArtNr ?? '–'}</TableCell>
<TableCell sx={{ px: 1, py: 0.4, fontSize: '0.75rem', textAlign: 'right' }}>{item.count}</TableCell>
<TableCell sx={{ px: 1, py: 0.4, fontSize: '0.75rem', textAlign: 'right' }}>{item.vpe ?? 1}</TableCell>
<TableCell sx={{ px: 1, py: 0.4, fontSize: '0.75rem' }}>{item.unit ?? '–'}</TableCell>
<TableCell sx={{ px: 1, py: 0.4, fontSize: '0.75rem', textAlign: 'right', fontFamily: 'monospace' }}>
{item.eknetto != null ? item.eknetto.toFixed(2) : '–'}
</TableCell>
<TableCell sx={{ px: 1, py: 0.4, fontSize: '0.75rem', textAlign: 'right' }}>
{item.mwst != null ? `${item.mwst} %` : '–'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
</Box>
);
}
export default function ManifestPdfDialog({ open, filename, data, onClose }) {
if (!open) return null;
const { streaming = false, messages = [], result = null, error = null } = data ?? {};
const hasResult = Boolean(result);
return (
<Dialog
open={open}
onClose={streaming ? undefined : onClose}
maxWidth="md"
fullWidth
aria-labelledby="pdf-dialog-title"
>
<DialogTitle
id="pdf-dialog-title"
sx={{
m: 0,
p: 2,
display: 'flex',
alignItems: 'center',
gap: 1.5,
borderBottom: 1,
borderColor: 'divider',
}}
>
{error ? (
<ErrorOutlinedIcon color="error" />
) : streaming ? (
<CircularProgress size={22} />
) : (
<PictureAsPdfIcon color="action" />
)}
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle1" component="div" fontWeight={600}>
{streaming ? 'PDF wird analysiert…' : error ? 'PDF-Analyse fehlgeschlagen' : 'PDF-Analyseergebnis'}
</Typography>
{filename ? (
<Typography variant="caption" color="text.secondary">
{filename}
</Typography>
) : null}
</Box>
<IconButton
aria-label="close"
onClick={onClose}
size="small"
disabled={streaming}
sx={{ color: (theme) => theme.palette.grey[500] }}
>
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent dividers sx={{ p: 2 }}>
<Stack spacing={2}>
{/* Error */}
{error && (
<Alert severity="error" icon={<ErrorOutlinedIcon />}>
<Typography variant="body2" fontWeight={600}>{error}</Typography>
</Alert>
)}
{/* Live log */}
<EventLog messages={messages} streaming={streaming} />
{/* Notes */}
{result?.notes?.length > 0 && (
<Box>
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ display: 'block', mb: 0.5 }}>
Hinweise
</Typography>
<Stack spacing={0.5}>
{result.notes.map((note, i) => (
<Alert key={i} severity="warning" sx={{ py: 0, '& .MuiAlert-message': { fontSize: '0.8rem' } }}>
{note}
</Alert>
))}
</Stack>
</Box>
)}
{/* Basedata + Items */}
{hasResult && (
<>
<Divider />
<BaseDataSection basedata={result.basedata} />
<ItemsSection items={result.items} />
</>
)}
{/* No result yet and no error: show waiting state */}
{!hasResult && !error && !streaming && (
<Alert severity="info">Keine Daten extrahiert.</Alert>
)}
</Stack>
</DialogContent>
<DialogActions sx={{ p: 1.5, px: 2 }}>
<Button onClick={onClose} variant="contained" size="small" disabled={streaming}>
Schließen
</Button>
</DialogActions>
</Dialog>
);
}