Files
dashfront/src/routes/lieferung/neu.jsx
2026-10-18 19:10:43 +02:00

1528 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { PureComponent } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import Tooltip from '@mui/material/Tooltip';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Paper from '@mui/material/Paper';
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
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,
restoreAttachmentsFromApi,
dateInputValue,
todayInputValue,
emptyLine,
lineFromApi,
manifestConfig,
TextEditorPanel,
PdfViewerPanel,
ArticleSearchDialog,
BookDeliveryDialog,
} from '../../components/manifest';
import { loadParties, apiFor, resolveLineSkus, sortSuppliers } from '../../components/manifest/manifestConfig';
import supplierAutomat from '../../automats/supplierAutomat';
import warehouseAutomat from '../../automats/warehouseAutomat';
import { article as articleApi, delivery as deliveryApi } from '../../api';
import { getCategoryState } from '../../automats/categoryAutomat';
import { requireArray, requireDefined, requireObject } from '../../utils/apiShape';
import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
import { generatePdfThumbnail } from '../../utils/pdfThumbnail';
import { generateTextThumbnail } from '../../utils/textThumbnail';
import { extractPdfText } from '../../utils/pdfText';
import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat';
import { setLastDeliveryPath, userSettingsAutomat, setViewerPosition } from '../../automats';
import chatAutomat from '../../automats/chatAutomat';
import { fetchDeliveryLedgerSummary } from '../../utils/deliveryLedger';
import {
SURFACE_BASE,
SURFACE_RAISED,
YELLOW_BG,
} from '../../colors';
function areLinesEqual(lines1, lines2) {
if (!Array.isArray(lines1) || !Array.isArray(lines2)) return false;
if (lines1.length !== lines2.length) return false;
for (let i = 0; i < lines1.length; i++) {
const a = lines1[i] || {};
const b = lines2[i] || {};
const aSku = String(a.sku ?? '').trim();
const bSku = String(b.sku ?? '').trim();
if (aSku !== bSku) return false;
const aName = String(a.name ?? '').trim();
const bName = String(b.name ?? '').trim();
if (aName !== bName) return false;
const aAmount = a.amount != null && a.amount !== '' ? Number(a.amount) : null;
const bAmount = b.amount != null && b.amount !== '' ? Number(b.amount) : null;
if (aAmount !== bAmount) return false;
const aNet =
(a.netprice ?? a.net) != null && (a.netprice ?? a.net) !== ''
? Number(a.netprice ?? a.net)
: null;
const bNet =
(b.netprice ?? b.net) != null && (b.netprice ?? b.net) !== ''
? Number(b.netprice ?? b.net)
: null;
if (aNet !== bNet) return false;
const aTax =
(a.taxrate ?? a.tax) != null && (a.taxrate ?? a.tax) !== ''
? Number(a.taxrate ?? a.tax)
: null;
const bTax =
(b.taxrate ?? b.tax) != null && (b.taxrate ?? b.tax) !== ''
? Number(b.taxrate ?? b.tax)
: null;
if (aTax !== bTax) return false;
const aPu = a.pu != null && a.pu !== '' ? Number(a.pu) : 1;
const bPu = b.pu != null && b.pu !== '' ? Number(b.pu) : 1;
if (aPu !== bPu) return false;
}
return true;
}
class LieferungNeuPage extends PureComponent {
constructor(props) {
super(props);
this.cfg = manifestConfig('delivery');
this.automat = getDeliveryEditorAutomat(props.docId);
if (
supplierAutomat.state.status === 'ready' &&
(!this.automat.state.deliveryEntities || this.automat.state.deliveryEntities.length === 0)
) {
this.automat.actions.setDeliveryEntities(sortSuppliers(supplierAutomat.state.suppliers));
}
const initialDate = this.automat.state.date || (props.docId == null ? todayInputValue() : null);
if (props.docId == null && !this.automat.state.date) {
this.automat.actions.setDate(initialDate);
}
this.state = {
...this.automat.state,
date: initialDate,
status: props.docId != null ? 'loading' : 'loadingParties',
error: null,
partiesLoading: true,
saving: false,
pdfUploading: false,
textEditorOpen: false,
textEditorAttachment: null,
viewerPosition: userSettingsAutomat.state.viewerPosition,
pdfViewerOpen: false,
pdfViewerAttachment: null,
articleSearchDialogOpen: false,
articleSearchLine: null,
unmatchedSupplierName: null,
ledgerSummary: null,
bookDeliveryDialogOpen: false,
bookDeliveryPrefillLineId: null,
pdfCreating: false,
warehouses: warehouseAutomat.state.warehouses || [],
};
this._cancelled = false;
this.handleRetry = this.handleRetry.bind(this);
this.handlePartyChange = this.handlePartyChange.bind(this);
this.handleCreateSupplierFromPdf = this.handleCreateSupplierFromPdf.bind(this);
this.handleDateChange = this.handleDateChange.bind(this);
this.handleOpenBookDialog = this.handleOpenBookDialog.bind(this);
this.handleBookLine = this.handleBookLine.bind(this);
this.handleCreatePdf = this.handleCreatePdf.bind(this);
this.handleCloseBookDialog = this.handleCloseBookDialog.bind(this);
this.handleBookSuccess = this.handleBookSuccess.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.handleAddArticleLine = this.handleAddArticleLine.bind(this);
this.handleRemoveLine = this.handleRemoveLine.bind(this);
this.handleMoveLine = this.handleMoveLine.bind(this);
this.handleRemoveAllLines = this.handleRemoveAllLines.bind(this);
this.handleSave = this.handleSave.bind(this);
this.handlePdfUpload = this.handlePdfUpload.bind(this);
this.handlePdfRescan = this.handlePdfRescan.bind(this);
this.handlePdfDelete = this.handlePdfDelete.bind(this);
this.handleMentionInChat = this.handleMentionInChat.bind(this);
this.handleAttachmentClick = this.handleAttachmentClick.bind(this);
this.handleCloseTextEditor = this.handleCloseTextEditor.bind(this);
this.handleTextEditorPositionChange = this.handleTextEditorPositionChange.bind(this);
this.handleClosePdfViewer = this.handleClosePdfViewer.bind(this);
this.handleSaveTextEditor = this.handleSaveTextEditor.bind(this);
this.handleTextEditorContentLoaded = this.handleTextEditorContentLoaded.bind(this);
this.ensureAttachmentFile = this.ensureAttachmentFile.bind(this);
this.handleSkuBlur = this.handleSkuBlur.bind(this);
this.handleCreateArticleForLine = this.handleCreateArticleForLine.bind(this);
this.handleOpenArticleSearchForLine = this.handleOpenArticleSearchForLine.bind(this);
this.handleCloseArticleSearch = this.handleCloseArticleSearch.bind(this);
this.handleAssignArticleForLine = this.handleAssignArticleForLine.bind(this);
this.handleRemoveSupplierArticle = this.handleRemoveSupplierArticle.bind(this);
}
componentDidMount() {
this.unsubscribe = this.automat.subscribe(this);
this._settingsUnsub = userSettingsAutomat.subscribe(this, (state) => ({
viewerPosition: state.viewerPosition,
}));
this._warehouseUnsub = warehouseAutomat.subscribe(this, (state) => ({
warehouses: state.warehouses,
}));
this._supplierUnsub = supplierAutomat.subscribe(this, (state) => {
if (state.status === 'ready' && Array.isArray(state.suppliers)) {
this.automat.actions.setDeliveryEntities(sortSuppliers(state.suppliers));
}
});
void warehouseAutomat.actions.load().catch(() => { });
this._cancelled = false;
if (this.props.docId != null) {
setLastDeliveryPath(`/lieferung/${this.props.docId}`);
void this.loadEdit();
} else {
setLastDeliveryPath('/lieferung/neu');
void this.loadPartiesOnly();
if (this.props.initialDelivery) {
this.automat.actions.setDelivery(this.props.initialDelivery);
}
}
}
componentDidUpdate(prevProps) {
if (prevProps.docId !== this.props.docId) {
this.unsubscribe?.();
this.automat = getDeliveryEditorAutomat(this.props.docId);
this.unsubscribe = this.automat.subscribe(this);
this.setState({ ...this.automat.state });
this._initialLines = null;
if (this.props.docId != null) {
void this.loadEdit();
} else {
if (!this.automat.state.date) {
this.automat.actions.setDate(todayInputValue());
}
void this.loadPartiesOnly();
if (this.props.initialDelivery) {
this.automat.actions.setDelivery(this.props.initialDelivery);
}
}
}
}
componentWillUnmount() {
this._cancelled = true;
if (this._skuDebounceTimers) {
Object.values(this._skuDebounceTimers).forEach(clearTimeout);
this._skuDebounceTimers = null;
}
this.unsubscribe?.();
this._settingsUnsub?.();
this._warehouseUnsub?.();
this._supplierUnsub?.();
}
async loadPartiesOnly() {
this.setState({ status: 'loadingParties', error: null, partiesLoading: true });
try {
const parties = await loadParties('delivery');
if (this._cancelled) return;
this.automat.actions.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);
}
}
async loadEdit() {
const { docId } = 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.kind).get(docId),
loadParties('delivery'),
]);
if (this._cancelled) return;
this.automat.actions.setDeliveryEntities(parties);
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()];
this.automat.actions.setId(doc.id ?? docId);
this.automat.actions.setDate(dateInputValue(doc.date));
if (doc[cfg.partyField] != null) this.automat.actions.setDeliveryEntity(doc[cfg.partyField]);
if (doc.number != null) this.automat.actions.setNumber(doc.number);
if (doc.externalNumber != null) this.automat.actions.setExternalNumber(String(doc.externalNumber));
this.automat.actions.setLineitems(lines);
this._initialLines = lines;
// Restore attachments with thumbnails
const apiFiles = doc.files ?? [];
const attachments = await restoreAttachmentsFromApi(apiFiles, this.state.attachments);
this.automat.actions.setAttachments(attachments);
let ledgerSummary = null;
try {
ledgerSummary = await fetchDeliveryLedgerSummary(docId);
} catch (lErr) {
console.error('[neu] Failed to load ledger summary:', lErr);
}
this.setState({
status: 'ready',
partiesLoading: false,
ledgerSummary,
});
} catch (err) {
if (this._cancelled) return;
this.setState({
status: 'failed',
error: backendErrorFrom(err),
partiesLoading: false,
});
}
}
handleOpenBookDialog() {
this.setState({ bookDeliveryDialogOpen: true, bookDeliveryPrefillLineId: null });
}
handleBookLine(ledgerLine) {
this.setState({
bookDeliveryDialogOpen: true,
bookDeliveryPrefillLineId: ledgerLine?.id ?? null,
});
}
handleCloseBookDialog() {
this.setState({ bookDeliveryDialogOpen: false, bookDeliveryPrefillLineId: null });
}
async reloadLedgerSummary() {
const { docId } = this.props;
if (docId == null) return;
try {
const ledgerSummary = await fetchDeliveryLedgerSummary(docId);
if (this._cancelled) return;
this.setState({ ledgerSummary });
} catch (err) {
console.error('[neu] Failed to reload ledger summary:', err);
}
}
handleBookSuccess(updatedSummary) {
this.setState({ ledgerSummary: updatedSummary });
void this.reloadLedgerSummary();
}
handleRetry() {
if (this.props.docId != null) void this.loadEdit();
else void this.loadPartiesOnly();
}
handlePartyChange(partyId) {
this.automat.actions.setDeliveryEntity(partyId);
if (partyId != null) {
this.setState({ unmatchedSupplierName: null });
this.automat.actions.setUnmatchedSupplierName(null);
}
}
handleCreateSupplierFromPdf(supplierName) {
const name = (supplierName ?? this.state.unmatchedSupplierName ?? '').trim();
const target = `/lieferung/lieferanten?name=${encodeURIComponent(name)}&returnTo=/lieferung/neu`;
this.props.navigate(target, { state: { name, returnTo: '/lieferung/neu' } });
}
handleDateChange(date) {
this.automat.actions.setDate(date);
}
handleNumberChange(number) {
this.automat.actions.setNumber(number);
}
handleExternalNumberChange(externalNumber) {
this.automat.actions.setExternalNumber(externalNumber);
}
findLineIndex(key) {
if (typeof key === 'number' && !Number.isNaN(key)) return key;
const lines = this.state.lines;
if (Array.isArray(lines)) {
const idx = lines.findIndex((l) => l.key === key);
if (idx !== -1) return idx;
}
const n = Number(key);
return Number.isInteger(n) ? n : -1;
}
handleLineChange(key, field, value) {
const index = this.findLineIndex(key);
this.automat.actions.updateLineitem({
index,
key,
[field]: value,
...(field === 'net' ? { netprice: value } : {}),
...(field === 'netprice' ? { net: value } : {}),
...(field === 'tax' ? { taxrate: value } : {}),
...(field === 'taxrate' ? { tax: value } : {}),
});
if (field === 'sku') {
if (!this._skuDebounceTimers) this._skuDebounceTimers = {};
if (this._skuDebounceTimers[key]) {
clearTimeout(this._skuDebounceTimers[key]);
}
this._skuDebounceTimers[key] = setTimeout(() => {
if (this._skuDebounceTimers) delete this._skuDebounceTimers[key];
this.automat.actions.fetchSupplierArticleForSku(value, key);
}, 350);
}
}
handleSkuBlur(key, value) {
if (this._skuDebounceTimers?.[key]) {
clearTimeout(this._skuDebounceTimers[key]);
delete this._skuDebounceTimers[key];
}
this.automat.actions.fetchSupplierArticleForSku(value, key);
}
async handleCreateArticleForLine(line) {
const { partyId } = this.state;
if (!partyId) {
enqueueSnackbar('Bitte wählen Sie zuerst einen Lieferanten aus.', { variant: 'warning' });
return;
}
const categoryName = line?.name;
if (!categoryName) {
enqueueSnackbar('Artikelname fehlt.', { variant: 'warning' });
return;
}
try {
// Get root category ID
const categoryState = getCategoryState();
if (!categoryState) {
enqueueSnackbar('Kategorie-Daten nicht verfügbar.', { variant: 'error' });
return;
}
const { rootCategoryId } = categoryState;
if (!rootCategoryId) {
enqueueSnackbar('Root-Kategorie nicht gefunden.', { variant: 'error' });
return;
}
// Create article in root category
const articleRes = await articleApi.create({
categoryId: rootCategoryId,
name: categoryName,
});
requireObject(articleRes, 'POST /api/article');
requireObject(articleRes.article, 'article');
requireDefined(articleRes.article.sku, 'article.sku');
const internalSku = articleRes.article.sku;
const supplierSku = line.sku;
// Add supplier relationship with the supplier's SKU
await articleApi.addSupplier(internalSku, {
entityId: partyId,
sku: supplierSku || undefined,
pu: line.pu ? Number(line.pu) : undefined,
stock: 0,
netPrice: line.netprice ? Number(line.netprice) : undefined,
taxRate: line.taxrate ? Number(line.taxrate) : undefined,
});
// Keep the supplier SKU in the delivery line, but refresh supplier article data
// using the supplier SKU (not the internal SKU)
this.automat.actions.fetchSupplierArticleForSku(supplierSku, line.key);
enqueueSnackbar(`Artikel "${internalSku}" erstellt und Lieferanten-Artikel "${supplierSku}" zugeordnet.`, { variant: 'success' });
} catch (err) {
console.error('Fehler beim Erstellen des Artikels:', err);
notifyError(err);
}
}
handleOpenArticleSearchForLine(line) {
this.setState({
articleSearchDialogOpen: true,
articleSearchLine: line,
});
}
handleCloseArticleSearch() {
this.setState({
articleSearchDialogOpen: false,
articleSearchLine: null,
});
}
async handleAssignArticleForLine(article, line) {
const { partyId } = this.state;
if (!partyId) {
const msg = 'Bitte wählen Sie zuerst einen Lieferanten aus.';
enqueueSnackbar(msg, { variant: 'warning' });
throw new Error(msg);
}
const internalSku = article?.sku;
if (!internalSku) {
const msg = 'Artikel hat keine SKU.';
enqueueSnackbar(msg, { variant: 'error' });
throw new Error(msg);
}
const supplierSku = line?.sku;
if (!line) {
const supplierArticle =
this.state.supplierArticlesMap?.[internalSku.toLowerCase()] ||
this.state.supplierArticlesMap?.[internalSku] ||
this.state.supplierArticles?.find(
(sa) => sa.article?.sku === internalSku || sa.sku === internalSku
);
const supplierSkuVal = supplierArticle?.sku || '';
const netVal =
supplierArticle?.price?.net != null
? String(supplierArticle.price.net)
: article.price != null
? String(article.price)
: article.net != null
? String(article.net)
: '';
const taxVal =
supplierArticle?.price?.taxRate != null
? String(supplierArticle.price.taxRate)
: article.taxrate != null
? String(article.taxrate)
: article.tax != null
? String(article.tax)
: '';
const puVal = supplierArticle?.pu != null ? Number(supplierArticle.pu) : 1;
this.automat.actions.addLineitem({
amount: 1,
sku: supplierSkuVal || internalSku,
name: article.name || supplierArticle?.article?.name || '',
tax: taxVal,
taxrate: taxVal,
net: netVal,
netprice: netVal,
pu: puVal,
});
enqueueSnackbar(`Artikel "${internalSku}" hinzugefügt.`, { variant: 'success' });
this.setState({
articleSearchDialogOpen: false,
articleSearchLine: null,
});
return;
}
try {
await articleApi.addSupplier(internalSku, {
entityId: partyId,
sku: supplierSku || undefined,
pu: line?.pu ? Number(line.pu) : undefined,
stock: 0,
netPrice: line?.netprice ? Number(line.netprice) : undefined,
taxRate: line?.taxrate ? Number(line.taxrate) : undefined,
});
if (supplierSku) {
await this.automat.actions.fetchSupplierArticleForSku(supplierSku, line.key);
}
await this.automat.actions.fetchSupplierArticles(partyId);
if (!line.name && article.name) {
this.automat.actions.updateLineitem({
...line,
name: article.name,
});
}
enqueueSnackbar(
`Lieferant erfolgreich dem Artikel "${internalSku}" zugeordnet.`,
{ variant: 'success' },
);
this.setState({
articleSearchDialogOpen: false,
articleSearchLine: null,
});
} catch (err) {
console.error('Fehler beim Zuordnen des Artikels:', err);
notifyError(err);
throw err;
}
}
async handleRemoveSupplierArticle(line, supplierArticle) {
const internalSku = supplierArticle?.article?.sku;
const assignmentId = supplierArticle?.id;
if (!internalSku || assignmentId == null) {
enqueueSnackbar('Verknüpfung konnte nicht ermittelt werden.', { variant: 'warning' });
return;
}
try {
await this.automat.actions.unlinkSupplierArticle(internalSku, assignmentId, line?.key);
enqueueSnackbar(
`Verknüpfung zu Artikel "${internalSku}" wurde entfernt.`,
{ variant: 'success' },
);
} catch (err) {
console.error('Fehler beim Lösen der Lieferanten-Verknüpfung:', err);
notifyError(err);
}
}
handleAddLine() {
this.automat.actions.addLineitem();
}
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)
: '';
this.automat.actions.addLineitem({
amount: 1,
sku: article.sku || '',
name: article.name || '',
tax: taxVal,
taxrate: taxVal,
net: netVal,
netprice: netVal,
pu: 1,
});
}
handleRemoveLine(key) {
const index = this.findLineIndex(key);
this.automat.actions.removeLineitem(index >= 0 ? index : key);
}
handleMoveLine(key, delta) {
const fromIndex = this.findLineIndex(key);
if (fromIndex < 0) return;
const toIndex = fromIndex + delta;
this.automat.actions.moveLineitem(fromIndex, toIndex);
}
handleRemoveAllLines() {
this.automat.actions.clearLineitems();
}
async handlePdfUpload(file) {
if (!file || this.state.pdfUploading) return;
const isPdf =
file.type === 'application/pdf' || file.name?.toLowerCase().endsWith('.pdf');
const isCsv =
file.type === 'text/csv' || file.name?.toLowerCase().endsWith('.csv');
if (isPdf) {
// 1. Generate thumbnail for PDF
let thumbnail = null;
try {
thumbnail = await generatePdfThumbnail(file);
} catch (err) {
console.error('Fehler beim Erstellen des PDF-Thumbnails:', err);
}
const pdfId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const pdfAttachment = {
id: pdfId,
file,
name: file.name,
size: file.size,
type: 'application/pdf',
thumbnail,
uploadedAt: new Date().toISOString(),
};
this.automat.actions.addAttachment(pdfAttachment);
// 2. Extract text using pdfconvert-wasm to create 2nd file (textfile)
try {
const text = await extractPdfText(file, { layout: true });
const txtName = file.name.replace(/\.pdf$/i, '') + '.txt';
const txtFile = new File([text], txtName, { type: 'text/plain;charset=utf-8' });
let txtThumbnail = null;
try {
txtThumbnail = await generateTextThumbnail(text);
} catch (thumbErr) {
console.error('Fehler beim Erstellen des Text-Thumbnails:', thumbErr);
}
const txtAttachment = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
file: txtFile,
name: txtFile.name,
size: txtFile.size,
type: 'text/plain',
thumbnail: txtThumbnail,
text,
parentPdfId: pdfId,
uploadedAt: new Date().toISOString(),
};
this.automat.actions.addAttachment(txtAttachment);
} catch (txtErr) {
console.error('Fehler beim Extrahieren von Text mit pdfconvert-wasm:', txtErr);
}
} else {
// Direct text / CSV attachment
let text = '';
try {
text = await file.text();
} catch (err) {
console.error('Fehler beim Lesen der Datei:', err);
}
let thumbnail = null;
try {
thumbnail = await generateTextThumbnail(text || file);
} catch (thumbErr) {
console.error('Fehler beim Erstellen des Text-Thumbnails:', thumbErr);
}
const attachmentId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const textAttachment = {
id: attachmentId,
file,
name: file.name,
size: file.size,
type: isCsv ? 'text/csv' : 'text/plain',
thumbnail,
text,
uploadedAt: new Date().toISOString(),
};
this.automat.actions.addAttachment(textAttachment);
}
}
async handlePdfRescan(attachment) {
if (!attachment || this.state.pdfUploading) return;
const name = attachment.name || '';
if (name.toLowerCase().startsWith('wareneingang-') || attachment.meta?.type === 'delivery_pdf') {
return;
}
let targetAttachment = attachment;
if (!targetAttachment.file || targetAttachment.file.size === 0) {
const file = await this.ensureAttachmentFile(targetAttachment);
if (!file || file.size === 0) {
enqueueSnackbar('Datei konnte nicht geladen werden', { variant: 'error' });
return;
}
targetAttachment = { ...targetAttachment, file };
}
// Refresh textfile upon rescan for PDF
const isPdf = targetAttachment.type === 'application/pdf' || targetAttachment.name.toLowerCase().endsWith('.pdf');
if (isPdf) {
try {
const text = await extractPdfText(targetAttachment.file, { layout: true });
const txtName = attachment.name.replace(/\.pdf$/i, '') + '.txt';
const txtFile = new File([text], txtName, { type: 'text/plain;charset=utf-8' });
let txtThumbnail = null;
try {
txtThumbnail = await generateTextThumbnail(text);
} catch (thumbErr) {
console.error('Fehler beim Aktualisieren des Text-Thumbnails:', thumbErr);
}
const existingTxt = this.state.attachments?.find(
(a) => a.parentPdfId === attachment.id || a.name === txtName,
);
if (existingTxt) {
this.automat.actions.updateAttachment({
id: existingTxt.id,
file: txtFile,
size: txtFile.size,
text,
thumbnail: txtThumbnail || existingTxt.thumbnail,
isDirty: true,
});
} else {
this.automat.actions.addAttachment({
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
file: txtFile,
name: txtFile.name,
size: txtFile.size,
type: 'text/plain',
thumbnail: txtThumbnail,
text,
parentPdfId: attachment.id,
uploadedAt: new Date().toISOString(),
});
}
} catch (txtErr) {
console.error('Fehler beim Aktualisieren der Textdatei:', txtErr);
}
}
}
async handleMentionInChat(attachment) {
console.log('[neu.jsx] handleMentionInChat called', attachment);
if (!attachment) return;
let text = attachment.text;
if (!text && (!attachment.file || attachment.file.size === 0)) {
const file = await this.ensureAttachmentFile(attachment);
if (file) {
try {
text = await file.text();
} catch (e) {
console.error('Fehler beim Lesen des Texts:', e);
}
}
} else if (!text && (attachment.file instanceof Blob || attachment.file instanceof File)) {
try {
text = await attachment.file.text();
} catch (e) {
console.error('Fehler beim Lesen des Texts:', e);
}
}
const trimmed = (text || '').trim();
if (!trimmed) {
console.warn('[neu.jsx] handleMentionInChat: text is empty for attachment', attachment);
return;
}
console.log('[neu.jsx] Inserting extraction snippet to chat, length:', trimmed.length);
chatAutomat.actions.insertExtractionSnippet({
text: trimmed,
label: 'parsed invoice',
instruction: "use 'Process Incoming Product and Invoices' SKILL",
});
}
async handlePdfDelete(attachment) {
if (!attachment) return;
const fileId = attachment.fileId ?? attachment.id;
const docId = this.props.docId ?? attachment.manifestId ?? this.state.id;
this.automat.actions.removeAttachment(attachment.id ?? fileId);
if (fileId != null && fileId !== attachment.id) {
this.automat.actions.removeAttachment(fileId);
}
if (docId != null && fileId != null && attachment.isPersisted) {
try {
await deliveryApi.deleteFile(docId, fileId);
enqueueSnackbar('Anhang gelöscht.', { variant: 'success' });
} catch (err) {
notifyError(err);
}
}
}
async ensureAttachmentFile(attachment) {
if (!attachment) return null;
const hasContent =
(attachment.file instanceof Blob || attachment.file instanceof File) &&
attachment.file.size > 0;
if (hasContent) {
return attachment.file;
}
const docId = this.props.docId ?? attachment.manifestId ?? this.state.id;
const fileId = attachment.fileId ?? attachment.id;
if (docId == null || fileId == null) {
return attachment.file || null;
}
try {
const blob = await deliveryApi.getFile(docId, fileId);
const filename = attachment.name || 'Dokument';
const mimetype = attachment.type || blob.type || 'application/octet-stream';
const file = new File([blob], filename, { type: mimetype });
let text = attachment.text;
const isPdf =
mimetype === 'application/pdf' || filename.toLowerCase().endsWith('.pdf');
const isText =
!isPdf &&
(mimetype.startsWith('text/') ||
filename.toLowerCase().endsWith('.txt') ||
filename.toLowerCase().endsWith('.csv'));
if (isText && typeof text !== 'string') {
try {
text = await blob.text();
} catch (e) {
console.error('Fehler beim Dekodieren des Texts:', e);
}
}
this.automat.actions.updateAttachment({
id: attachment.id,
file,
size: blob.size,
...(typeof text === 'string' ? { text } : {}),
});
return file;
} catch (err) {
console.error('Fehler beim Laden der Dateiinhalte:', err);
notifyError(err);
return null;
}
}
handleTextEditorContentLoaded(attachment, text, blob) {
const file = new File([blob], attachment.name || 'Dokument.txt', {
type: attachment.type || 'text/plain;charset=utf-8',
});
this.automat.actions.updateAttachment({
id: attachment.id,
file,
size: blob.size,
text,
});
}
handleAttachmentClick(attachment) {
if (!attachment) return;
const isPdf =
attachment.type === 'application/pdf' ||
attachment.name?.toLowerCase().endsWith('.pdf');
const isText =
!isPdf &&
(attachment.type?.startsWith('text/') ||
attachment.name?.toLowerCase().endsWith('.txt') ||
attachment.name?.toLowerCase().endsWith('.csv'));
if (isText) {
this.setState({
textEditorOpen: true,
textEditorAttachment: attachment,
pdfViewerOpen: false,
pdfViewerAttachment: null,
});
return;
}
if (isPdf) {
this.setState({
pdfViewerOpen: true,
pdfViewerAttachment: attachment,
textEditorOpen: false,
textEditorAttachment: null,
});
void this.ensureAttachmentFile(attachment);
return;
}
if (attachment.file instanceof Blob || attachment.file instanceof File) {
if (attachment.file.size > 0) {
const url = URL.createObjectURL(attachment.file);
window.open(url, '_blank');
return;
}
}
const docId = this.props.docId ?? attachment.manifestId ?? this.state.id;
const fileId = attachment.fileId ?? attachment.id;
if (docId != null && fileId != null) {
window.open(
`/api/delivery/${encodeURIComponent(docId)}/file/${encodeURIComponent(fileId)}`,
'_blank',
);
}
}
handleCloseTextEditor() {
this.setState({
textEditorOpen: false,
textEditorAttachment: null,
});
}
handleClosePdfViewer() {
this.setState({
pdfViewerOpen: false,
pdfViewerAttachment: null,
});
}
handleTextEditorPositionChange(position) {
setViewerPosition(position);
}
renderViewerPanels(wide) {
const {
textEditorOpen,
textEditorAttachment,
pdfViewerOpen,
pdfViewerAttachment,
viewerPosition,
} = this.state;
const docId = this.props.docId ?? this.state.id;
const panels = [];
if (textEditorOpen && textEditorAttachment) {
panels.push(
<TextEditorPanel
key="text"
docId={docId}
attachment={textEditorAttachment}
position={viewerPosition}
wide={wide}
height={panels.length === 0 ? '60vh' : '40vh'}
onPositionChange={this.handleTextEditorPositionChange}
onClose={this.handleCloseTextEditor}
onSave={this.handleSaveTextEditor}
onContentLoad={this.handleTextEditorContentLoaded}
/>,
);
}
if (pdfViewerOpen && pdfViewerAttachment) {
panels.push(
<PdfViewerPanel
key="pdf"
docId={docId}
attachment={pdfViewerAttachment}
position={viewerPosition}
wide={wide}
height={panels.length === 0 ? '60vh' : '40vh'}
onPositionChange={this.handleTextEditorPositionChange}
onClose={this.handleClosePdfViewer}
/>,
);
}
if (panels.length === 0) return null;
return (
<Box
sx={{
gridColumn: { xs: '1 / -1', sm: wide >= 2 ? 'span 2' : 'span 1', md: `span ${wide}` },
display: 'flex',
flexDirection: 'column',
gap: 2,
minWidth: 0,
...(viewerPosition === 'right'
? { position: 'sticky', top: 16, alignSelf: 'flex-start' }
: {}),
}}
>
{panels}
</Box>
);
}
isViewerOpen() {
return Boolean(
(this.state.textEditorOpen && this.state.textEditorAttachment) ||
(this.state.pdfViewerOpen && this.state.pdfViewerAttachment),
);
}
async handleSaveTextEditor(newText) {
const attachment = this.state.textEditorAttachment;
if (!attachment) return;
const newFile = new File([newText], attachment.name, {
type: 'text/plain;charset=utf-8',
});
let thumbnail = attachment.thumbnail;
try {
thumbnail = await generateTextThumbnail(newText);
} catch (err) {
console.error('Fehler beim Aktualisieren des Text-Thumbnails:', err);
}
this.automat.actions.updateAttachment({
id: attachment.id,
file: newFile,
size: newFile.size,
text: newText,
thumbnail,
isDirty: true,
});
this.setState({
textEditorOpen: false,
textEditorAttachment: null,
});
}
async handleSave() {
const { docId } = this.props;
const cfg = this.cfg;
if (this.state.saving) return;
const isCreate = docId == null;
const linesModified =
isCreate || !this._initialLines || !areLinesEqual(this.state.lines, this._initialLines);
let body;
try {
body = await buildManifestBody(
{
number: this.state.number ?? '',
externalNumber: this.state.externalNumber ?? '',
date: this.state.date ?? '',
partyId: this.state.partyId,
lines: linesModified ? this.state.lines : undefined,
attachments: this.state.attachments,
},
{
kind: cfg.kind,
partyField: cfg.partyField,
requireAtLeastOne: isCreate,
externalNumber: cfg.externalNumber,
isUpdate: !isCreate,
mode: isCreate ? 'create' : 'edit',
omitLines: !linesModified,
},
);
} catch (err) {
notifyError(err);
return;
}
this.setState({ saving: true });
const api = apiFor(cfg.kind);
try {
if (isCreate) {
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.automat.actions.resetDelivery();
this.props.navigate(cfg.editPath(data[cfg.docField].id));
} else {
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`);
const lines = apiLines.length > 0 ? apiLines.map(lineFromApi) : [emptyLine()];
this.automat.actions.setId(doc.id ?? docId);
this.automat.actions.setDate(dateInputValue(doc.date));
if (doc[cfg.partyField] != null) this.automat.actions.setDeliveryEntity(doc[cfg.partyField]);
if (doc.number != null) this.automat.actions.setNumber(doc.number);
if (doc.externalNumber != null) this.automat.actions.setExternalNumber(String(doc.externalNumber));
this.automat.actions.setLineitems(lines);
this._initialLines = lines;
void this.reloadLedgerSummary();
// Restore attachments with thumbnails
const apiFiles = doc.files ?? [];
const attachments = await restoreAttachmentsFromApi(apiFiles, this.state.attachments);
this.automat.actions.setAttachments(attachments);
enqueueSnackbar(cfg.savedMsg, { variant: 'success' });
this.setState({ saving: false });
}
} catch (err) {
if (this._cancelled) return;
notifyError(err);
this.setState({ saving: false });
}
}
/** Generates the "Wareneingang" PDF server-side, stores it and shows it among the attachments. */
async handleCreatePdf(e) {
e?.preventDefault?.();
e?.stopPropagation?.();
const { docId } = this.props;
if (docId == null || this.state.pdfCreating || this.state.saving) return;
this.setState({ pdfCreating: true });
try {
const res = await deliveryApi.createPdf(docId);
requireObject(res, 'POST /api/delivery/:id/pdf');
const getRes = await deliveryApi.get(docId);
const doc = getRes?.delivery;
if (doc?.files && !this._cancelled) {
const attachments = await restoreAttachmentsFromApi(doc.files, this.state.attachments);
this.automat.actions.setAttachments(attachments);
}
enqueueSnackbar('Wareneingang-PDF erfolgreich erstellt.', { variant: 'success' });
} catch (err) {
if (!this._cancelled) notifyError(err);
} finally {
if (!this._cancelled) this.setState({ pdfCreating: false });
}
}
render() {
const cfg = this.cfg;
const {
status,
error,
partiesLoading,
saving,
pdfUploading,
deliveryEntities,
date,
partyId,
number,
externalNumber,
lines,
notes,
attachments,
} = this.state;
if (status === 'loadingParties' || status === 'loading') {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
);
}
if (status === 'failed') {
return <BackendError error={error} onRetry={this.handleRetry} />;
}
const ledgerLinesById = {};
if (Array.isArray(this.state.ledgerSummary?.lines)) {
for (const l of this.state.ledgerSummary.lines) {
ledgerLinesById[l.id] = l;
}
}
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}
pdfCreating={this.state.pdfCreating}
onPdfCreate={this.handleCreatePdf}
attachments={attachments}
lines={lines}
onPdfUpload={this.handlePdfUpload}
onPdfRescan={this.handlePdfRescan}
onPdfDelete={this.handlePdfDelete}
onAttachmentClick={this.handleAttachmentClick}
onMentionInChat={this.handleMentionInChat}
onSave={this.handleSave}
onPartyChange={this.handlePartyChange}
onDateChange={this.handleDateChange}
onNumberChange={this.handleNumberChange}
onExternalNumberChange={this.handleExternalNumberChange}
docId={this.props.docId}
ledgerSummary={this.state.ledgerSummary}
onOpenBookDialog={this.handleOpenBookDialog}
/>
{this.state.viewerPosition === 'top' && this.renderViewerPanels(4)}
{this.state.unmatchedSupplierName && (
<PanelTile wide={4} color={YELLOW_BG} sx={{ gridColumn: { xs: '1 / -1', sm: '1 / -1', md: 'span 4' } }}>
<Alert
severity="warning"
action={
<Button
color="inherit"
size="small"
variant="outlined"
onClick={() => this.handleCreateSupplierFromPdf(this.state.unmatchedSupplierName)}
>
Lieferant anlegen
</Button>
}
sx={{ py: 0.5, alignItems: 'center' }}
>
Lieferant „{this.state.unmatchedSupplierName}“ wurde im Dokument erkannt, existiert aber noch nicht in den Stammdaten.
</Alert>
</PanelTile>
)}
<ManifestLines
kind="delivery"
columns={DELIVERY_LINE_COLUMNS}
lines={lines}
saving={saving}
partyId={this.state.partyId}
wide={
this.state.viewerPosition === 'right' && this.isViewerOpen()
? 2
: 4
}
linesColor={cfg.linesColor}
supplierArticlesMap={this.state.supplierArticlesMap}
supplierArticlesByLineKey={this.state.supplierArticlesByLineKey}
supplierArticlesLoading={this.state.supplierArticlesLoading}
loadingLineKeys={this.state.loadingLineKeys}
onLineChange={this.handleLineChange}
onSkuBlur={this.handleSkuBlur}
onAddLine={this.handleAddLine}
onAddArticleLine={this.handleAddArticleLine}
onRemoveLine={this.handleRemoveLine}
onMoveLine={this.handleMoveLine}
onRemoveAllLines={this.handleRemoveAllLines}
onSave={() => void this.handleSave()}
onCreateArticle={this.handleCreateArticleForLine}
onSearchArticle={this.handleOpenArticleSearchForLine}
onRemoveSupplierArticle={this.handleRemoveSupplierArticle}
busySupplierArticleLineKeys={this.state.busySupplierArticleLineKeys}
ledgerLinesById={ledgerLinesById}
onBookLine={this.handleBookLine}
/>
{this.state.viewerPosition === 'right' && this.renderViewerPanels(2)}
{this.state.viewerPosition === 'below' && this.renderViewerPanels(4)}
{this.props.docId != null && this.renderLedgerPanel()}
{notes && notes.length > 0 && (
<PanelTile wide={4} color={YELLOW_BG} sx={{ gridColumn: { xs: '1 / -1', sm: '1 / -1', md: 'span 4' } }}>
<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>
<ArticleSearchDialog
open={this.state.articleSearchDialogOpen}
line={this.state.articleSearchLine}
kind="delivery"
partyId={this.state.partyId}
supplierArticles={this.state.supplierArticles}
onClose={this.handleCloseArticleSearch}
onAssign={this.handleAssignArticleForLine}
/>
{this.props.docId != null && (
<BookDeliveryDialog
open={this.state.bookDeliveryDialogOpen}
delivery={{
id: this.props.docId,
number: this.state.number,
externalNumber: this.state.externalNumber,
date: this.state.date,
supplierId: this.state.partyId,
}}
summary={this.state.ledgerSummary}
prefillLineId={this.state.bookDeliveryPrefillLineId}
partyName={this.state.deliveryEntities?.find((p) => p.id === this.state.partyId)?.name}
supplierArticlesMap={this.state.supplierArticlesMap}
onClose={this.handleCloseBookDialog}
onBookSuccess={this.handleBookSuccess}
/>
)}
</>
);
}
renderLedgerPanel() {
const { ledgerSummary, warehouses = [] } = this.state;
if (!ledgerSummary) return null;
const { lines } = ledgerSummary;
const delivery = this.state.delivery || this.automat?.state?.delivery;
const supplierArticlesMap = this.state.supplierArticlesMap || {};
const allEntries = [];
if (Array.isArray(lines)) {
for (const line of lines) {
if (Array.isArray(line.ledgerEntries)) {
const { supplierSku, ownSku } = resolveLineSkus(line, delivery, supplierArticlesMap);
const lineName = line.name || line.article?.name || line.description || '–';
const deliveredAmount = line.amount ?? line.count ?? line.targetAmount ?? '–';
for (const entry of line.ledgerEntries) {
const hasWarehouse = entry.warehouse_id != null;
const wh = hasWarehouse
? (warehouses.find((w) => Number(w.id) === Number(entry.warehouse_id)) ||
warehouseAutomat.state.warehouses?.find((w) => Number(w.id) === Number(entry.warehouse_id)))
: null;
const warehouseName = wh?.name || (hasWarehouse ? `Lager #${entry.warehouse_id}` : '–');
const rawBookedAmount = entry.warehouse_amount ?? (hasWarehouse ? entry.amount : null);
const mengeEingebucht = rawBookedAmount != null
? (Number(rawBookedAmount) > 0 ? `+${rawBookedAmount}` : String(rawBookedAmount))
: '–';
allEntries.push({
...entry,
supplierSku,
ownSku,
name: lineName,
deliveredAmount,
warehouseName,
mengeEingebucht,
});
}
}
}
}
allEntries.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
if (allEntries.length === 0) return null;
return (
<PanelTile
wide={4}
color={SURFACE_BASE}
sx={{
gridColumn: { xs: '1 / -1', sm: '1 / -1', md: 'span 4' },
}}
>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead sx={{ bgcolor: SURFACE_RAISED }}>
<TableRow>
<TableCell sx={{ fontWeight: 600, py: 0.5, whiteSpace: 'nowrap' }}>Zeitpunkt</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5, whiteSpace: 'nowrap' }}>Lieferanten SKU</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5, whiteSpace: 'nowrap' }}>Eigene SKU</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Name</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, py: 0.5, whiteSpace: 'nowrap' }}>Menge geliefert</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5, whiteSpace: 'nowrap' }}>Lager</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, py: 0.5, whiteSpace: 'nowrap' }}>Menge eingebucht</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Referenz</TableCell>
</TableRow>
</TableHead>
<TableBody>
{allEntries.map((entry) => (
<TableRow key={entry.id} hover>
<TableCell sx={{ py: 0.5, whiteSpace: 'nowrap' }}>
<Typography variant="caption">
{entry.created_at ? new Date(entry.created_at).toLocaleString('de-DE') : '–'}
</Typography>
</TableCell>
<TableCell sx={{ py: 0.5, whiteSpace: 'nowrap' }}>
<Typography variant="caption" sx={{ fontFamily: 'monospace' }}>
{entry.supplierSku}
</Typography>
</TableCell>
<TableCell sx={{ py: 0.5, whiteSpace: 'nowrap' }}>
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontWeight: 600 }}>
{entry.ownSku}
</Typography>
</TableCell>
<TableCell sx={{ py: 0.5 }}>
<Typography variant="caption">
{entry.name}
</Typography>
</TableCell>
<TableCell align="right" sx={{ py: 0.5, whiteSpace: 'nowrap' }}>
<Typography variant="caption">
{entry.deliveredAmount}
</Typography>
</TableCell>
<TableCell sx={{ py: 0.5, whiteSpace: 'nowrap' }}>
<Typography variant="caption">
{entry.warehouseName}
</Typography>
</TableCell>
<TableCell
align="right"
sx={{
py: 0.5,
fontWeight: 700,
color: entry.mengeEingebucht !== '–' ? 'success.main' : 'text.disabled',
whiteSpace: 'nowrap',
}}
>
{entry.mengeEingebucht}
</TableCell>
<TableCell sx={{ py: 0.5 }}>
<Typography variant="caption">
{entry.transaction_text || '–'}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</PanelTile>
);
}
}
export default withNavigate(LieferungNeuPage);