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 companyAutomat, { setViewerPosition } from '../../automats/companyAutomat'; 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 } from '../../automats/navigationAutomat'; 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 ( companyAutomat.state.suppliersStatus === 'ready' && (!this.automat.state.deliveryEntities || this.automat.state.deliveryEntities.length === 0) ) { this.automat.actions.setDeliveryEntities(sortSuppliers(companyAutomat.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: companyAutomat.state.viewerPosition, pdfViewerOpen: false, pdfViewerAttachment: null, articleSearchDialogOpen: false, articleSearchLine: null, unmatchedSupplierName: null, ledgerSummary: null, bookDeliveryDialogOpen: false, bookDeliveryPrefillLineId: null, pdfCreating: false, warehouses: companyAutomat.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.handleRestore = this.handleRestore.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._companyUnsub = companyAutomat.subscribe(this, (state) => { if (state.suppliersStatus === 'ready' && Array.isArray(state.suppliers)) { this.automat.actions.setDeliveryEntities(sortSuppliers(state.suppliers)); } return { viewerPosition: state.viewerPosition, warehouses: state.warehouses, }; }); 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; this._initialDoc = 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._companyUnsub?.(); } 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; this._initialDoc = { date: dateInputValue(doc.date), partyId: doc[cfg.partyField] ?? null, externalNumber: doc.externalNumber != null ? String(doc.externalNumber) : '', lines, attachments: [], }; // Restore attachments with thumbnails const apiFiles = doc.files ?? []; const attachments = await restoreAttachmentsFromApi(apiFiles, this.state.attachments); this.automat.actions.setAttachments(attachments); if (this._initialDoc) { this._initialDoc.attachments = 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'); requireDefined(articleRes.sku, 'sku'); const internalSku = articleRes.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 ArtNr.'; 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 { // If this line was previously mapped to a different article, unlink the old supplier article const existingSupplierArticle = (line?.key != null ? this.state.supplierArticlesByLineKey?.[line.key] : null) || (supplierSku ? (this.state.supplierArticlesMap?.[supplierSku.toLowerCase()] || this.state.supplierArticlesMap?.[supplierSku]) : null); const oldInternalSku = existingSupplierArticle?.article?.sku; const oldAssignmentId = existingSupplierArticle?.id; if (oldInternalSku && oldAssignmentId != null && String(oldInternalSku).toLowerCase() !== String(internalSku).toLowerCase()) { try { await this.automat.actions.unlinkSupplierArticle(oldInternalSku, oldAssignmentId, line?.key); } catch (unlinkErr) { console.warn('Vorherige Lieferanten-Zuordnung konnte nicht gelöst werden:', unlinkErr); } } 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); } else { await this.automat.actions.fetchSupplierArticleForSku(internalSku, line.key); } await this.automat.actions.fetchSupplierArticles(partyId); if (!line.name || (existingSupplierArticle?.article?.name && line.name === existingSupplierArticle.article.name)) { this.automat.actions.updateLineitem({ ...line, name: article.name || line.name, ...(!line.sku ? { sku: internalSku } : {}), }); } 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(); this.setState({ lines: [] }); } handleRestore() { if (!this._initialDoc) return; const base = this._initialDoc; this.automat.actions.setDate(base.date); if (base.partyId != null) { this.automat.actions.setDeliveryEntity(base.partyId); } this.automat.actions.setExternalNumber(base.externalNumber || ''); const restoredLines = (base.lines || []).map((l) => ({ ...l })); this.automat.actions.setLineitems(restoredLines); this.automat.actions.setAttachments([...(base.attachments || [])]); this.setState({ lines: restoredLines, }); enqueueSnackbar('Änderungen verworfen und ursprünglicher Stand wiederhergestellt.', { variant: 'info' }); } 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( , ); } 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; 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); this._initialDoc = { date: dateInputValue(doc.date), partyId: doc[cfg.partyField] ?? null, externalNumber: doc.externalNumber != null ? String(doc.externalNumber) : '', lines, 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; } } const isEdit = this.props.docId != null; let isDirty = false; if (isEdit && this._initialDoc) { const dateChanged = String(date ?? '').trim() !== String(this._initialDoc.date ?? '').trim(); const partyChanged = (partyId ?? null) !== (this._initialDoc.partyId ?? null); const extChanged = String(externalNumber ?? '').trim() !== String(this._initialDoc.externalNumber ?? '').trim(); const linesChanged = !areLinesEqual(lines, this._initialDoc.lines); const currentAtts = attachments || []; const baseAtts = this._initialDoc.attachments || []; let attsChanged = currentAtts.length !== baseAtts.length; if (!attsChanged) { for (const a of currentAtts) { if (!a.isPersisted || a.isDirty) { attsChanged = true; break; } } } isDirty = dateChanged || partyChanged || extChanged || linesChanged || attsChanged; } 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-ArtNr Eigene ArtNr 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);