Files
dashfront/src/components/manifest/ManifestForm.jsx
2026-10-08 22:44:51 +02:00

750 lines
22 KiB
JavaScript

import { PureComponent } from 'react';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import { enqueueSnackbar } from 'notistack';
import BackendError from '../BackendError';
import { PanelTileGrid } from 'dashgui/panels';
import { delivery as deliveryApi } from '../../api';
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 ArticleSearchDialog from './ArticleSearchDialog';
import artikelAutomat, { getArticle } from '../../automats/artikelAutomat';
import { netFromGross, getArticleGross } from '../../utils/price';
import {
buildManifestBody,
restoreAttachmentsFromApi,
dateInputValue,
emptyLine,
lineFromApi,
loadParties,
manifestConfig,
apiFor,
parseNum,
} from './manifestConfig';
class ManifestForm extends PureComponent {
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: '',
articleSearchDialogOpen: false,
articleSearchLine: null,
};
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.handleSkuBlur = this.handleSkuBlur.bind(this);
this.handleAddLine = this.handleAddLine.bind(this);
this.handleAddArticleLine = this.handleAddArticleLine.bind(this);
this.handleOpenArticleSearchForLine = this.handleOpenArticleSearchForLine.bind(this);
this.handleCloseArticleSearch = this.handleCloseArticleSearch.bind(this);
this.handleAssignArticleForLine = this.handleAssignArticleForLine.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);
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([
apiFor(cfg).get(docId),
loadParties(kind),
]);
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) => {
if (line.key !== key) return line;
if (field === 'gross') {
const taxVal = parseNum(line.tax ?? line.taxrate) ?? 0;
const grossNum = parseNum(value);
let updatedNet = line.net;
if (grossNum != null) {
const netNum = netFromGross(grossNum, taxVal);
if (netNum != null) {
updatedNet = String(netNum);
}
} else if (value === '') {
updatedNet = '';
}
return {
...line,
gross: value,
net: updatedNet,
netprice: updatedNet,
};
}
if (field === 'net' || field === 'netprice') {
const taxVal = parseNum(line.tax ?? line.taxrate) ?? 0;
const netNum = parseNum(value);
let updatedGross = line.gross;
if (netNum != null) {
const grossNum = Math.round((netNum * (1 + taxVal / 100) + Number.EPSILON) * 100) / 100;
updatedGross = String(grossNum);
} else if (value === '') {
updatedGross = '';
}
return {
...line,
[field]: value,
net: value,
netprice: value,
gross: updatedGross,
};
}
if (field === 'tax' || field === 'taxrate') {
const taxNum = parseNum(value) ?? 0;
const netNum = parseNum(line.net ?? line.netprice);
let updatedGross = line.gross;
if (netNum != null) {
const grossNum = Math.round((netNum * (1 + taxNum / 100) + Number.EPSILON) * 100) / 100;
updatedGross = String(grossNum);
}
return {
...line,
[field]: value,
tax: value,
taxrate: value,
gross: updatedGross,
};
}
return {
...line,
[field]: value,
};
}),
}));
}
async handleSkuBlur(key, sku) {
const trimmed = String(sku ?? '').trim();
if (!trimmed) return;
try {
const art = getArticle(trimmed) || (await artikelAutomat.actions.loadArticle(trimmed));
if (art && !this._cancelled) {
this.setState((prev) => ({
lines: prev.lines.map((l) => {
if (l.key !== key) return l;
const netVal =
art.price != null
? String(art.price)
: art.vknetto != null
? String(art.vknetto)
: art.net != null
? String(art.net)
: '';
const taxVal =
art.taxrate != null
? String(art.taxrate)
: art.tax != null
? String(art.tax)
: '';
const netNum = parseNum(netVal);
const taxNum = parseNum(taxVal);
const artGross = getArticleGross(art, taxNum ?? 0);
let grossVal = '';
if (artGross != null) {
grossVal = String(artGross);
} else if (netNum != null) {
const g = Math.round((netNum * (1 + (taxNum ?? 0) / 100) + Number.EPSILON) * 100) / 100;
grossVal = String(g);
}
return {
...l,
sku: art.sku || l.sku,
name: art.name || l.name || '',
tax: l.tax || taxVal,
taxrate: l.taxrate || taxVal,
net: l.net || netVal,
netprice: l.netprice || netVal,
gross: l.gross || grossVal,
unit: art.unit || l.unit || 'Stück',
};
}),
}));
}
} catch {
// not in catalog or load error; preserve user entry
}
}
handleAddLine() {
this.setState((prev) => ({ lines: [...prev.lines, emptyLine()] }));
}
handleAddArticleLine(article) {
if (!article) return;
const netVal =
article.price != null
? String(article.price)
: article.vknetto != null
? String(article.vknetto)
: article.net != null
? String(article.net)
: '';
const taxVal =
article.taxrate != null
? String(article.taxrate)
: article.tax != null
? String(article.tax)
: '';
const netNum = parseNum(netVal);
const taxNum = parseNum(taxVal);
const artGross = getArticleGross(article, taxNum ?? 0);
let grossVal = '';
if (artGross != null) {
grossVal = String(artGross);
} else if (netNum != null) {
const g = Math.round((netNum * (1 + (taxNum ?? 0) / 100) + Number.EPSILON) * 100) / 100;
grossVal = String(g);
}
const newLine = {
key: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
amount: '1',
sku: article.sku || '',
name: article.name || '',
tax: taxVal,
taxrate: taxVal,
net: netVal,
netprice: netVal,
gross: grossVal,
rebate: '',
pu: '1',
unit: article.unit || 'Stück',
};
this.setState((prev) => {
if (prev.lines.length === 1 && !prev.lines[0].sku && !prev.lines[0].name) {
return { lines: [newLine] };
}
return { lines: [...prev.lines, newLine] };
});
}
handleOpenArticleSearchForLine(line) {
this.setState({
articleSearchDialogOpen: true,
articleSearchLine: line,
});
}
handleCloseArticleSearch() {
this.setState({
articleSearchDialogOpen: false,
articleSearchLine: null,
});
}
handleAssignArticleForLine(article, targetLine) {
if (!article) return;
if (!targetLine) {
this.handleAddArticleLine(article);
this.setState({
articleSearchDialogOpen: false,
articleSearchLine: null,
});
return;
}
const netVal =
article.price != null
? String(article.price)
: article.vknetto != null
? String(article.vknetto)
: article.net != null
? String(article.net)
: '';
const taxVal =
article.taxrate != null
? String(article.taxrate)
: article.tax != null
? String(article.tax)
: '';
const netNum = parseNum(netVal);
const taxNum = parseNum(taxVal);
const artGross = getArticleGross(article, taxNum ?? 0);
let grossVal = '';
if (artGross != null) {
grossVal = String(artGross);
} else if (netNum != null) {
const g = Math.round((netNum * (1 + (taxNum ?? 0) / 100) + Number.EPSILON) * 100) / 100;
grossVal = String(g);
}
this.setState((prev) => ({
lines: prev.lines.map((l) => {
if (l.key !== targetLine.key) return l;
return {
...l,
sku: article.sku || l.sku,
name: article.name || l.name,
tax: taxVal || l.tax,
taxrate: taxVal || l.taxrate,
net: netVal || l.net,
netprice: netVal || l.netprice,
gross: grossVal || l.gross,
unit: article.unit || l.unit || 'Stück',
};
}),
articleSearchDialogOpen: false,
articleSearchLine: null,
}));
}
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 = deliveryApi.parsePdfSse;
if (!gen) throw new Error('parsePdfSse nicht verfügbar.');
for await (const { event, data } of gen(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 });
const api = apiFor(cfg);
try {
if (mode === 'create') {
const data = await api.create(body);
requireObject(data, cfg.postPathLabel);
requireObject(data[cfg.docField], cfg.docField);
requireDefined(data[cfg.docField].id, `${cfg.docField}.id`);
await cfg.listAutomat.actions.reload();
if (this._cancelled) return;
enqueueSnackbar(cfg.createdMsg, { variant: 'success' });
this.props.navigate(cfg.editPath(data[cfg.docField].id));
return;
}
const data = await api.update(docId, body);
requireObject(data, cfg.patchPathLabel);
requireObject(data[cfg.docField], cfg.docField);
requireDefined(data[cfg.docField].id, `${cfg.docField}.id`);
await cfg.listAutomat.actions.reload();
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 (
<ManifestLines
kind={cfg.kind}
columns={cfg.columns}
lines={lines}
saving={saving}
wide={4}
linesColor={cfg.linesColor}
onLineChange={this.handleLineChange}
onSkuBlur={this.handleSkuBlur}
onAddLine={this.handleAddLine}
onAddArticleLine={this.handleAddArticleLine}
onSearchArticle={this.handleOpenArticleSearchForLine}
onRemoveLine={this.handleRemoveLine}
onRemoveAllLines={this.handleRemoveAllLines}
onSave={() => void this.handleSave()}
/>
);
}
render() {
const { kind } = 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 (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
);
}
if (status === 'failed') {
return <BackendError error={error} onRetry={this.handleRetry} />;
}
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}
pdfUploading={pdfUploading}
lines={lines}
onPdfUpload={kind === 'delivery' ? this.handlePdfUpload : undefined}
onSave={this.handleSave}
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}
onAbort={this.handleAbortParse}
/>
<ArticleSearchDialog
open={this.state.articleSearchDialogOpen}
line={this.state.articleSearchLine}
kind={kind}
onClose={this.handleCloseArticleSearch}
onAssign={this.handleAssignArticleForLine}
/>
</>
);
}
}
export default withNavigate(ManifestForm);