1528 lines
52 KiB
JavaScript
1528 lines
52 KiB
JavaScript
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);
|