feat(manifest): improve PDF extraction workflow and fix delivery lines rendering
- Fix SSE 'result' event handling in delivery creation and stop circular progress - Directly populate deliveryEditorSlice with parsed header, line items, and notes - Simplify ManifestPdfDialog to display LLM protocol log and status only - Render PDF analysis notes at the bottom of the delivery page - Keep article lines visible when imported items exist even if partyId is not yet selected - Bind handleEdit and handleDelete in ManifestList constructor to fix row click TypeError - Support delivery columns (name, pu, netprice, taxrate) in ManifestForm and lineFromApi - Remove obsolete Unit column from delivery lines
This commit is contained in:
@@ -1,10 +1,472 @@
|
||||
import { PureComponent } from 'react';
|
||||
import { ManifestForm } from '../../components/manifest';
|
||||
import { connect } from 'react-redux';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||
import BackendError from '../../components/BackendError';
|
||||
import withNavigate from '../../components/withNavigate';
|
||||
import {
|
||||
ManifestHeader,
|
||||
ManifestLines,
|
||||
DELIVERY_LINE_COLUMNS,
|
||||
buildManifestBody,
|
||||
manifestConfig,
|
||||
} from '../../components/manifest';
|
||||
import ManifestPdfDialog from '../../components/manifest/ManifestPdfDialog';
|
||||
import { loadParties } from '../../components/manifest/manifestConfig';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { requireDefined, requireObject } from '../../utils/apiShape';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import {
|
||||
setDeliveryEntities,
|
||||
setDate,
|
||||
setDeliveryEntity,
|
||||
setNumber,
|
||||
setExternalNumber,
|
||||
setType,
|
||||
setLineitems,
|
||||
setNotes,
|
||||
addLineitem,
|
||||
updateLineitem,
|
||||
removeLineitem,
|
||||
resetDelivery,
|
||||
selectDeliveryEntities,
|
||||
selectDeliveryDate,
|
||||
selectDeliveryEntity,
|
||||
selectDeliveryNumber,
|
||||
selectDeliveryExternalNumber,
|
||||
selectDeliveryLineitems,
|
||||
selectDeliveryNotes,
|
||||
} from '../../store/deliveryEditorSlice';
|
||||
|
||||
class LieferungNeuPage extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.cfg = manifestConfig('delivery');
|
||||
this.state = {
|
||||
status: 'loadingParties',
|
||||
error: null,
|
||||
partiesLoading: true,
|
||||
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.handleSave = this.handleSave.bind(this);
|
||||
this.handlePdfUpload = this.handlePdfUpload.bind(this);
|
||||
this.handleClosePdfDialog = this.handleClosePdfDialog.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._cancelled = false;
|
||||
void this.loadPartiesOnly();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._cancelled = true;
|
||||
}
|
||||
|
||||
async loadPartiesOnly() {
|
||||
this.setState({ status: 'loadingParties', error: null, partiesLoading: true });
|
||||
try {
|
||||
const parties = await loadParties('delivery', this.context);
|
||||
if (this._cancelled) return;
|
||||
this.props.setDeliveryEntities(parties);
|
||||
this.setState({
|
||||
status: 'ready',
|
||||
partiesLoading: false,
|
||||
});
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
this.setState({
|
||||
status: 'failed',
|
||||
error: backendErrorFrom(err),
|
||||
partiesLoading: false,
|
||||
});
|
||||
notifyError(err);
|
||||
}
|
||||
}
|
||||
|
||||
handleRetry() {
|
||||
void this.loadPartiesOnly();
|
||||
}
|
||||
|
||||
handlePartyChange(partyId) {
|
||||
this.props.setDeliveryEntity(partyId);
|
||||
}
|
||||
|
||||
handleDateChange(date) {
|
||||
this.props.setDate(date);
|
||||
}
|
||||
|
||||
handleNumberChange(number) {
|
||||
this.props.setNumber(number);
|
||||
}
|
||||
|
||||
handleExternalNumberChange(externalNumber) {
|
||||
this.props.setExternalNumber(externalNumber);
|
||||
}
|
||||
|
||||
handleLineChange(key, field, value) {
|
||||
const index = typeof key === 'number' ? key : Number(key);
|
||||
this.props.updateLineitem({
|
||||
index,
|
||||
[field]: value,
|
||||
...(field === 'net' ? { netprice: value } : {}),
|
||||
...(field === 'tax' ? { taxrate: value } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
handleAddLine() {
|
||||
this.props.addLineitem();
|
||||
}
|
||||
|
||||
handleRemoveLine(key) {
|
||||
const index = typeof key === 'number' ? key : Number(key);
|
||||
this.props.removeLineitem(index);
|
||||
}
|
||||
|
||||
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':
|
||||
case 'done': {
|
||||
const result = data?.delivery ?? data;
|
||||
const rawItems = Array.isArray(result?.lines)
|
||||
? result.lines
|
||||
: Array.isArray(result?.items?.items)
|
||||
? result.items.items
|
||||
: null;
|
||||
const newLines = Array.isArray(rawItems)
|
||||
? rawItems.map((l) => ({
|
||||
name: l.name ?? l.articleName ?? null,
|
||||
sku: l.sku ?? l.liefArtNr ?? '',
|
||||
amount: (l.amount ?? l.count) != null ? String(l.amount ?? l.count) : '',
|
||||
pu: (l.pu ?? l.vpe) != null ? String(l.pu ?? l.vpe) : '',
|
||||
unit: l.unit ?? '',
|
||||
taxrate: (l.taxrate ?? l.tax ?? l.mwst) != null ? String(l.taxrate ?? l.tax ?? l.mwst) : '',
|
||||
netprice: (l.netprice ?? l.net ?? (l.eknetto != null ? (l.eknetto / 100).toFixed(2) : null)) != null
|
||||
? String(l.netprice ?? l.net ?? (l.eknetto != null ? (l.eknetto / 100).toFixed(2) : ''))
|
||||
: '',
|
||||
}))
|
||||
: null;
|
||||
|
||||
this.setState((prev) => ({
|
||||
pdfDialogData: { ...prev.pdfDialogData, streaming: false, result },
|
||||
}));
|
||||
|
||||
const docDate = result?.date ?? result?.basedata?.date;
|
||||
let supplierId = result?.supplierId ?? result?.basedata?.kLieferant;
|
||||
const extNum = result?.externalNumber ?? result?.basedata?.manifestNr;
|
||||
const docType = result?.type ?? result?.basedata?.manifestType;
|
||||
const notes = Array.isArray(result?.notes) ? result.notes : [];
|
||||
|
||||
// If supplierId is null, try matching supplier by name from loaded delivery entities
|
||||
if (supplierId == null && result?.basedata?.lieferantName && Array.isArray(this.props.deliveryEntities)) {
|
||||
const nameToMatch = result.basedata.lieferantName.trim().toLowerCase();
|
||||
const matched = this.props.deliveryEntities.find(
|
||||
(e) =>
|
||||
e.name &&
|
||||
(e.name.trim().toLowerCase() === nameToMatch ||
|
||||
e.name.toLowerCase().includes(nameToMatch) ||
|
||||
nameToMatch.includes(e.name.toLowerCase())),
|
||||
);
|
||||
if (matched) supplierId = matched.id;
|
||||
}
|
||||
|
||||
if (docDate) this.props.setDate(docDate);
|
||||
if (supplierId != null) this.props.setDeliveryEntity(supplierId);
|
||||
if (extNum != null) {
|
||||
this.props.setExternalNumber(String(extNum));
|
||||
}
|
||||
if (docType) this.props.setType(docType);
|
||||
if (newLines && newLines.length > 0) {
|
||||
this.props.setLineitems(newLines);
|
||||
}
|
||||
if (notes.length > 0) {
|
||||
this.props.setNotes(notes);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'error':
|
||||
this.setState((prev) => ({
|
||||
pdfDialogData: {
|
||||
...prev.pdfDialogData,
|
||||
streaming: false,
|
||||
error: data.message ?? 'PDF-Analyse fehlgeschlagen.',
|
||||
},
|
||||
}));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
this.setState((prev) => ({
|
||||
pdfDialogData: {
|
||||
...prev.pdfDialogData,
|
||||
streaming: false,
|
||||
error: err.message ?? 'Netzwerkfehler bei PDF-Analyse.',
|
||||
},
|
||||
}));
|
||||
} finally {
|
||||
if (!this._cancelled) {
|
||||
this.setState((prev) => ({
|
||||
pdfUploading: false,
|
||||
pdfDialogData: prev.pdfDialogData
|
||||
? { ...prev.pdfDialogData, streaming: false }
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleClosePdfDialog() {
|
||||
this.setState({ pdfDialogOpen: false });
|
||||
}
|
||||
|
||||
async handleSave() {
|
||||
const cfg = this.cfg;
|
||||
if (this.state.saving) return;
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = buildManifestBody(
|
||||
{
|
||||
number: this.props.number ?? '',
|
||||
externalNumber: this.props.externalNumber ?? '',
|
||||
date: this.props.date ?? '',
|
||||
partyId: this.props.partyId,
|
||||
lines: this.props.lines,
|
||||
},
|
||||
{
|
||||
partyField: cfg.partyField,
|
||||
requireAtLeastOne: true,
|
||||
externalNumber: cfg.externalNumber,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ saving: true });
|
||||
try {
|
||||
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.resetDelivery();
|
||||
this.props.navigate(cfg.editPath(data[cfg.docField].id));
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState({ saving: false });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return <ManifestForm kind="delivery" mode="create" />;
|
||||
const cfg = this.cfg;
|
||||
const {
|
||||
status,
|
||||
error,
|
||||
partiesLoading,
|
||||
saving,
|
||||
pdfUploading,
|
||||
pdfDialogOpen,
|
||||
pdfDialogData,
|
||||
pdfFileName,
|
||||
} = this.state;
|
||||
const {
|
||||
deliveryEntities,
|
||||
date,
|
||||
partyId,
|
||||
number,
|
||||
externalNumber,
|
||||
lines,
|
||||
notes,
|
||||
} = this.props;
|
||||
|
||||
if (status === 'loadingParties') {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'failed') {
|
||||
return <BackendError error={error} onRetry={this.handleRetry} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelTileGrid>
|
||||
<ManifestHeader
|
||||
kind="delivery"
|
||||
headerColor={cfg.headerColor}
|
||||
dateLabel={cfg.dateLabel}
|
||||
partyLabel={cfg.partyLabel}
|
||||
parties={deliveryEntities}
|
||||
partiesLoading={partiesLoading}
|
||||
partyId={partyId}
|
||||
date={date ?? ''}
|
||||
number={number ?? ''}
|
||||
externalNumber={externalNumber ?? ''}
|
||||
saving={saving}
|
||||
pdfUploading={pdfUploading}
|
||||
onPdfUpload={this.handlePdfUpload}
|
||||
onPartyChange={this.handlePartyChange}
|
||||
onDateChange={this.handleDateChange}
|
||||
onNumberChange={this.handleNumberChange}
|
||||
onExternalNumberChange={this.handleExternalNumberChange}
|
||||
/>
|
||||
{partyId == null && (!lines || lines.length === 0) ? (
|
||||
<PanelTile wide={3} minHeight={160} color="#e2e8f0">
|
||||
<Typography color="text.secondary">
|
||||
{cfg.partyGateMessage}
|
||||
</Typography>
|
||||
</PanelTile>
|
||||
) : (
|
||||
<>
|
||||
{partyId == null && lines && lines.length > 0 && (
|
||||
<PanelTile wide={3} color="#fef3c7">
|
||||
<Alert severity="warning" sx={{ py: 0.5 }}>
|
||||
Bitte wählen Sie oben einen Lieferanten aus, um die erfassten Positionen speichern zu können.
|
||||
</Alert>
|
||||
</PanelTile>
|
||||
)}
|
||||
<ManifestLines
|
||||
columns={DELIVERY_LINE_COLUMNS}
|
||||
lines={lines}
|
||||
saving={saving}
|
||||
linesColor={cfg.linesColor}
|
||||
onLineChange={this.handleLineChange}
|
||||
onAddLine={this.handleAddLine}
|
||||
onRemoveLine={this.handleRemoveLine}
|
||||
onSave={() => void this.handleSave()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{notes && notes.length > 0 && (
|
||||
<PanelTile wide={3} color="#fffbeb">
|
||||
<Typography variant="subtitle2" fontWeight={700} color="warning.dark" sx={{ mb: 1.5 }}>
|
||||
Hinweise zur PDF-Analyse ({notes.length})
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
{notes.map((note, i) => (
|
||||
<Alert key={i} severity="warning" variant="outlined" sx={{ bgcolor: 'background.paper', py: 0.5 }}>
|
||||
{note}
|
||||
</Alert>
|
||||
))}
|
||||
</Stack>
|
||||
</PanelTile>
|
||||
)}
|
||||
</PanelTileGrid>
|
||||
<ManifestPdfDialog
|
||||
open={pdfDialogOpen}
|
||||
filename={pdfFileName}
|
||||
data={pdfDialogData}
|
||||
onClose={this.handleClosePdfDialog}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default LieferungNeuPage;
|
||||
const mapStateToProps = (state) => ({
|
||||
deliveryEntities: selectDeliveryEntities(state),
|
||||
date: selectDeliveryDate(state),
|
||||
partyId: selectDeliveryEntity(state),
|
||||
number: selectDeliveryNumber(state),
|
||||
externalNumber: selectDeliveryExternalNumber(state),
|
||||
lines: selectDeliveryLineitems(state),
|
||||
notes: selectDeliveryNotes(state),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = {
|
||||
setDeliveryEntities,
|
||||
setDate,
|
||||
setDeliveryEntity,
|
||||
setNumber,
|
||||
setExternalNumber,
|
||||
setType,
|
||||
setLineitems,
|
||||
setNotes,
|
||||
addLineitem,
|
||||
updateLineitem,
|
||||
removeLineitem,
|
||||
resetDelivery,
|
||||
};
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
)(withNavigate(LieferungNeuPage));
|
||||
|
||||
Reference in New Issue
Block a user