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:
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import Box from '@mui/material/Box';
|
||||
@@ -8,13 +8,23 @@ import MenuItem from '@mui/material/MenuItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import GetAppIcon from '@mui/icons-material/GetApp';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import PageLayout from './PageLayout';
|
||||
import { NAV_ITEMS, SUB_NAV, resolveActiveSub } from '../nav';
|
||||
import { isKategorienPath } from '../pages/artikel/categoryRoute';
|
||||
import { selectRenameMode, setRenameMode } from '../store/categorySlice';
|
||||
import {
|
||||
selectRenameMode,
|
||||
setRenameMode,
|
||||
selectCategoryMap,
|
||||
selectCategoryRootId,
|
||||
loadCategoryTree,
|
||||
} from '../store/categorySlice';
|
||||
import { ActionElevatedPill, ToggleElevatedPill } from 'dashgui/ElevatedPill';
|
||||
import { article, category } from '../api';
|
||||
import { importArtikelCsv } from '../utils/importArtikel';
|
||||
import { notifyErrorMessage } from '../utils/notify';
|
||||
|
||||
function triggerDownload(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -31,7 +41,11 @@ export default function SectionLayout({ sectionPath }) {
|
||||
const { pathname } = useLocation();
|
||||
const dispatch = useDispatch();
|
||||
const renameMode = useSelector(selectRenameMode);
|
||||
const categories = useSelector(selectCategoryMap);
|
||||
const rootCategoryId = useSelector(selectCategoryRootId);
|
||||
const importInputRef = useRef(null);
|
||||
const [extrasAnchor, setExtrasAnchor] = useState(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const title = NAV_ITEMS.find((item) => item.path === sectionPath)?.label ?? '';
|
||||
const subNav = SUB_NAV[sectionPath];
|
||||
const subSegment = resolveActiveSub(pathname, sectionPath);
|
||||
@@ -74,6 +88,49 @@ export default function SectionLayout({ sectionPath }) {
|
||||
triggerDownload(await article.exportZip(), 'artikel.zip');
|
||||
};
|
||||
|
||||
const handleImportFile = async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
setExtrasAnchor(null);
|
||||
setImporting(true);
|
||||
try {
|
||||
const csvText = await file.text();
|
||||
const result = await importArtikelCsv(csvText, {
|
||||
categories,
|
||||
rootCategoryId,
|
||||
onProgress: (progress) => {
|
||||
enqueueSnackbar(`Importiere Artikel… ${progress}`, {
|
||||
variant: 'info',
|
||||
autoHideDuration: 800,
|
||||
preventDuplicate: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
if (result.ok > 0 && rootCategoryId != null) {
|
||||
await dispatch(loadCategoryTree()).unwrap();
|
||||
}
|
||||
if (result.ok > 0) {
|
||||
enqueueSnackbar(
|
||||
`${result.ok} Artikel importiert${result.skipped ? `, ${result.skipped} übersprungen` : ''}.`,
|
||||
{ variant: 'success' },
|
||||
);
|
||||
} else {
|
||||
enqueueSnackbar('Keine Artikel importiert.', { variant: 'warning' });
|
||||
}
|
||||
} catch (err) {
|
||||
notifyErrorMessage(err?.message ?? String(err));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportClick = () => {
|
||||
setExtrasAnchor(null);
|
||||
importInputRef.current?.click();
|
||||
};
|
||||
|
||||
const titleAction = showsRenameSwitch ? (
|
||||
<Box sx={{ display: 'inline-flex', gap: 1 }}>
|
||||
<ToggleElevatedPill
|
||||
@@ -86,6 +143,13 @@ export default function SectionLayout({ sectionPath }) {
|
||||
icon={<SettingsIcon sx={{ fontSize: 18 }} />}
|
||||
onClick={(event) => setExtrasAnchor(event.currentTarget)}
|
||||
/>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv,text/plain"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleImportFile}
|
||||
/>
|
||||
<Popover
|
||||
open={Boolean(extrasAnchor)}
|
||||
anchorEl={extrasAnchor}
|
||||
@@ -94,13 +158,21 @@ export default function SectionLayout({ sectionPath }) {
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<MenuList sx={{ py: 0.5 }}>
|
||||
<MenuItem onClick={exportArtikel}>
|
||||
<MenuItem onClick={exportArtikel} disabled={importing}>
|
||||
<ListItemIcon>
|
||||
<GetAppIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Artikel exportieren</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={exportKategorien}>
|
||||
{renameMode && (
|
||||
<MenuItem onClick={handleImportClick} disabled={importing}>
|
||||
<ListItemIcon>
|
||||
<UploadFileIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Artikel importieren</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={exportKategorien} disabled={importing}>
|
||||
<ListItemIcon>
|
||||
<GetAppIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
273
src/components/manifest/ManifestPdfDialog.jsx
Normal file
273
src/components/manifest/ManifestPdfDialog.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user