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,
emptyLine,
lineFromApi,
manifestConfig,
TextEditorPanel,
PdfViewerPanel,
ArticleSearchDialog,
BookDeliveryDialog,
} from '../../components/manifest';
import { loadParties, apiFor, resolveLineSkus } from '../../components/manifest/manifestConfig';
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';
class LieferungNeuPage extends PureComponent {
constructor(props) {
super(props);
this.cfg = manifestConfig('delivery');
this.automat = getDeliveryEditorAutomat(props.docId);
this.state = {
...this.automat.state,
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,
}));
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 });
if (this.props.docId != null) {
void this.loadEdit();
} else {
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?.();
}
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);
// 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;
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",
});
}
handlePdfDelete(attachment) {
if (!attachment?.id) return;
this.automat.actions.removeAttachment(attachment.id);
}
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(
,
);
}
if (pdfViewerOpen && pdfViewerAttachment) {
panels.push(
,
);
}
if (panels.length === 0) return null;
return (
= 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}
);
}
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;
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: isCreate,
externalNumber: cfg.externalNumber,
},
);
} 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);
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 (
);
}
if (status === 'failed') {
return ;
}
const ledgerLinesById = {};
if (Array.isArray(this.state.ledgerSummary?.lines)) {
for (const l of this.state.ledgerSummary.lines) {
ledgerLinesById[l.id] = l;
}
}
return (
<>
{this.state.viewerPosition === 'top' && this.renderViewerPanels(4)}
{this.state.unmatchedSupplierName && (
this.handleCreateSupplierFromPdf(this.state.unmatchedSupplierName)}
>
Lieferant anlegen
}
sx={{ py: 0.5, alignItems: 'center' }}
>
Lieferant „{this.state.unmatchedSupplierName}“ wurde im Dokument erkannt, existiert aber noch nicht in den Stammdaten.
)}
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 && (
Hinweise zur PDF-Analyse ({notes.length})
{notes.map((note, i) => (
{note}
))}
)}
{this.props.docId != null && (
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 (
Zeitpunkt
Lieferanten SKU
Eigene SKU
Name
Menge geliefert
Lager
Menge eingebucht
Referenz
{allEntries.map((entry) => (
{entry.created_at ? new Date(entry.created_at).toLocaleString('de-DE') : '–'}
{entry.supplierSku}
{entry.ownSku}
{entry.name}
{entry.deliveredAmount}
{entry.warehouseName}
{entry.mengeEingebucht}
{entry.transaction_text || '–'}
))}
);
}
}
export default withNavigate(LieferungNeuPage);