This commit is contained in:
seb
2026-09-25 07:27:37 +02:00
parent b331f8f535
commit a3db1742e7
17 changed files with 2273 additions and 371 deletions

View File

@@ -6,6 +6,17 @@ 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';
@@ -24,6 +35,7 @@ import {
TextEditorPanel,
PdfViewerPanel,
ArticleSearchDialog,
BookDeliveryDialog,
} from '../../components/manifest';
import ManifestPdfDialog from '../../components/manifest/ManifestPdfDialog';
import { loadParties } from '../../components/manifest/manifestConfig';
@@ -36,7 +48,8 @@ import { generateTextThumbnail } from '../../utils/textThumbnail';
import { extractPdfText } from '../../utils/pdfText';
import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat';
import { setLastDeliveryPath, userSettingsAutomat, setViewerPosition } from '../../automats';
import { delivery as deliveryApi } from '../../api';
import { delivery as deliveryApi, ledger as defaultLedgerApi } from '../../api';
import { fetchDeliveryLedgerSummary } from '../../utils/deliveryLedger';
class LieferungNeuPage extends PureComponent {
static contextType = StorageManagerContext;
@@ -63,18 +76,26 @@ class LieferungNeuPage extends PureComponent {
articleSearchDialogOpen: false,
articleSearchLine: null,
unmatchedSupplierName: null,
ledgerSummary: null,
bookDeliveryDialogOpen: false,
bookDeliveryPrefillLineId: null,
};
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.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.handleRemoveLine = this.handleRemoveLine.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);
@@ -206,9 +227,22 @@ class LieferungNeuPage extends PureComponent {
const attachments = await restoreAttachmentsFromApi(apiFiles, this.state.attachments);
this.automat.actions.setAttachments(attachments);
let ledgerSummary = null;
try {
const ledgerClient = this.context?.ledger || defaultLedgerApi;
const deliveryClient = this.context?.[cfg.api] || deliveryApi;
ledgerSummary = await fetchDeliveryLedgerSummary(docId, {
ledgerApi: ledgerClient,
deliveryApi: deliveryClient,
});
} catch (lErr) {
console.error('[neu] Failed to load ledger summary:', lErr);
}
this.setState({
status: 'ready',
partiesLoading: false,
ledgerSummary,
});
} catch (err) {
if (this._cancelled) return;
@@ -220,6 +254,43 @@ class LieferungNeuPage extends PureComponent {
}
}
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 ledgerClient = this.context?.ledger || defaultLedgerApi;
const deliveryClient = this.context?.[this.cfg.api] || deliveryApi;
const ledgerSummary = await fetchDeliveryLedgerSummary(docId, {
ledgerApi: ledgerClient,
deliveryApi: deliveryClient,
});
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();
@@ -271,7 +342,9 @@ class LieferungNeuPage extends PureComponent {
key,
[field]: value,
...(field === 'net' ? { netprice: value } : {}),
...(field === 'netprice' ? { net: value } : {}),
...(field === 'tax' ? { taxrate: value } : {}),
...(field === 'taxrate' ? { tax: value } : {}),
});
if (field === 'sku') {
@@ -339,7 +412,7 @@ class LieferungNeuPage extends PureComponent {
entityId: partyId,
sku: supplierSku || undefined,
pu: line.pu ? Number(line.pu) : undefined,
stock: line.amount ? Number(line.amount) : undefined,
stock: 0,
netPrice: line.netprice ? Number(line.netprice) : undefined,
taxRate: line.taxrate ? Number(line.taxrate) : undefined,
});
@@ -389,7 +462,7 @@ class LieferungNeuPage extends PureComponent {
entityId: partyId,
sku: supplierSku || undefined,
pu: line?.pu ? Number(line.pu) : undefined,
stock: line?.amount ? Number(line.amount) : undefined,
stock: 0,
netPrice: line?.netprice ? Number(line.netprice) : undefined,
taxRate: line?.taxrate ? Number(line.taxrate) : undefined,
});
@@ -445,6 +518,10 @@ class LieferungNeuPage extends PureComponent {
this.automat.actions.removeLineitem(index >= 0 ? index : key);
}
handleRemoveAllLines() {
this.automat.actions.clearLineitems();
}
async handlePdfUpload(file) {
if (!file || this.state.pdfUploading) return;
@@ -661,18 +738,26 @@ class LieferungNeuPage extends PureComponent {
? result.items.items
: null;
const newLines = Array.isArray(rawItems)
? rawItems.map((l, idx) => ({
key: l.key ?? `${Date.now()}-${idx}-${Math.random().toString(36).slice(2, 8)}`,
name: l.name ?? l.articleName ?? null,
sku: l.sku ?? l.liefArtNr ?? '',
amount: (l.amount ?? l.count) != null ? String(l.amount ?? l.count) : '',
pu: (l.pu ?? l.vpe) != null ? String(l.pu ?? l.vpe) : '',
unit: l.unit ?? '',
taxrate: (l.taxrate ?? l.tax ?? l.mwst) != null ? String(l.taxrate ?? l.tax ?? l.mwst) : '',
netprice: (l.netprice ?? l.net ?? (l.eknetto != null ? (l.eknetto / 100).toFixed(2) : null)) != null
? String(l.netprice ?? l.net ?? (l.eknetto != null ? (l.eknetto / 100).toFixed(2) : ''))
: '',
}))
? rawItems.map((l, idx) => {
const taxVal = (l.taxrate ?? l.tax ?? l.taxRate ?? l.mwst) != null
? String(l.taxrate ?? l.tax ?? l.taxRate ?? l.mwst)
: '';
const netVal = (l.netprice ?? l.net ?? l.netPrice ?? (l.eknetto != null ? (l.eknetto / 100).toFixed(2) : null)) != null
? String(l.netprice ?? l.net ?? l.netPrice ?? (l.eknetto != null ? (l.eknetto / 100).toFixed(2) : ''))
: '';
return {
key: l.key ?? `${Date.now()}-${idx}-${Math.random().toString(36).slice(2, 8)}`,
name: l.name ?? l.articleName ?? null,
sku: l.sku ?? l.liefArtNr ?? '',
amount: (l.amount ?? l.count) != null ? String(l.amount ?? l.count) : '',
pu: (l.pu ?? l.vpe) != null ? String(l.pu ?? l.vpe) : '',
unit: l.unit ?? '',
taxrate: taxVal,
tax: taxVal,
netprice: netVal,
net: netVal,
};
})
: null;
this.setState((prev) => ({
@@ -1108,6 +1193,7 @@ class LieferungNeuPage extends PureComponent {
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 ?? [];
@@ -1157,18 +1243,15 @@ class LieferungNeuPage extends PureComponent {
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 (
<>
<PageTitleRight>
<Button
variant="contained"
size="small"
disabled={saving}
onClick={this.handleSave}
>
{saving ? 'Speichern…' : 'Speichern'}
</Button>
</PageTitleRight>
<PanelTileGrid>
<ManifestHeader
kind="delivery"
@@ -1184,95 +1267,73 @@ class LieferungNeuPage extends PureComponent {
saving={saving}
pdfUploading={pdfUploading}
attachments={attachments}
lines={lines}
onPdfUpload={this.handlePdfUpload}
onPdfRescan={this.handlePdfRescan}
onPdfDelete={this.handlePdfDelete}
onAttachmentClick={this.handleAttachmentClick}
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(3)}
{partyId == null && (!lines || lines.length === 0) ? (
<PanelTile wide={3} minHeight={160} color="#e2e8f0">
{this.state.unmatchedSupplierName ? (
<Stack spacing={2} sx={{ p: 1 }}>
<Typography color="text.secondary">
Lieferant <strong>„{this.state.unmatchedSupplierName}“</strong> wurde im Dokument erkannt, existiert aber noch nicht in den Stammdaten.
</Typography>
<Box>
<Button
variant="contained"
size="small"
onClick={() => this.handleCreateSupplierFromPdf(this.state.unmatchedSupplierName)}
>
Lieferant „{this.state.unmatchedSupplierName}“ anlegen
</Button>
</Box>
</Stack>
) : (
<Typography color="text.secondary">
{cfg.partyGateMessage}
</Typography>
)}
</PanelTile>
) : (
<>
{partyId == null && (
<PanelTile wide={3} color="#fef3c7">
<Alert
severity="warning"
action={
this.state.unmatchedSupplierName ? (
<Button
color="inherit"
size="small"
variant="outlined"
onClick={() => this.handleCreateSupplierFromPdf(this.state.unmatchedSupplierName)}
>
Lieferant anlegen
</Button>
) : null
}
sx={{ py: 0.5, alignItems: 'center' }}
{this.state.viewerPosition === 'top' && this.renderViewerPanels(4)}
{this.state.unmatchedSupplierName && (
<PanelTile wide={4} color="#fef3c7" 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)}
>
{this.state.unmatchedSupplierName
? `Lieferant „${this.state.unmatchedSupplierName}“ wurde im Dokument erkannt, existiert aber noch nicht in den Stammdaten.`
: 'Bitte wählen Sie oben einen Lieferanten aus, um die erfassten Positionen speichern zu können.'}
</Alert>
</PanelTile>
)}
<ManifestLines
columns={DELIVERY_LINE_COLUMNS}
lines={lines}
saving={saving}
wide={
this.state.viewerPosition === 'right' && this.isViewerOpen()
? 2
: 3
Lieferant anlegen
</Button>
}
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}
onRemoveLine={this.handleRemoveLine}
onSave={() => void this.handleSave()}
onCreateArticle={this.handleCreateArticleForLine}
onSearchArticle={this.handleOpenArticleSearchForLine}
onRemoveSupplierArticle={this.handleRemoveSupplierArticle}
busySupplierArticleLineKeys={this.state.busySupplierArticleLineKeys}
/>
</>
sx={{ py: 0.5, alignItems: 'center' }}
>
Lieferant „{this.state.unmatchedSupplierName}“ wurde im Dokument erkannt, existiert aber noch nicht in den Stammdaten.
</Alert>
</PanelTile>
)}
<ManifestLines
columns={DELIVERY_LINE_COLUMNS}
lines={lines}
saving={saving}
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}
onRemoveLine={this.handleRemoveLine}
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(3)}
{this.state.viewerPosition === 'below' && this.renderViewerPanels(4)}
{this.props.docId != null && this.renderLedgerPanel()}
{notes && notes.length > 0 && (
<PanelTile wide={3} color="#fffbeb">
<PanelTile wide={4} color="#fffbeb" 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>
@@ -1302,9 +1363,240 @@ class LieferungNeuPage extends PureComponent {
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}
onClose={this.handleCloseBookDialog}
onBookSuccess={this.handleBookSuccess}
/>
)}
</>
);
}
renderLedgerPanel() {
const { ledgerSummary } = this.state;
if (!ledgerSummary) return null;
const {
status,
totalPositions,
bookedPositions,
totalQuantity,
bookedQuantity,
lines,
} = ledgerSummary;
const allEntries = [];
if (Array.isArray(lines)) {
for (const line of lines) {
if (Array.isArray(line.ledgerEntries)) {
for (const entry of line.ledgerEntries) {
allEntries.push({
...entry,
lineSku: line.sku,
lineName: line.name,
});
}
}
}
}
allEntries.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
return (
<PanelTile
wide={4}
title="Lagerstatus (Ledger)"
color="#f8fafc"
sx={{
gridColumn: { xs: '1 / -1', sm: '1 / -1', md: 'span 4' },
border: '1px solid',
borderColor:
status === 'booked'
? 'success.light'
: status === 'partial'
? 'warning.light'
: 'divider',
}}
>
<Stack spacing={2}>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 3,
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3, alignItems: 'center' }}>
<Box>
<Typography variant="caption" color="text.secondary" display="block">
Status
</Typography>
{status === 'booked' && (
<Chip
size="small"
icon={<CheckCircleOutlineOutlinedIcon />}
label="Vollständig gebucht"
color="success"
variant="outlined"
sx={{ fontWeight: 600, height: 26 }}
/>
)}
{status === 'partial' && (
<Chip
size="small"
icon={<Inventory2OutlinedIcon />}
label={`Teilweise gebucht (${bookedPositions}/${totalPositions})`}
color="warning"
variant="outlined"
sx={{ fontWeight: 600, height: 26 }}
/>
)}
{status === 'unbooked' && (
<Chip
size="small"
label={`Offen (${totalPositions} Positionen)`}
variant="outlined"
sx={{ color: 'text.secondary', borderColor: 'divider', fontWeight: 500, height: 26 }}
/>
)}
{status === 'empty' && (
<Chip size="small" label="Keine Artikel" variant="outlined" />
)}
</Box>
<Box>
<Typography variant="caption" color="text.secondary" display="block">
Positionen
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{bookedPositions} / {totalPositions} gebucht
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" display="block">
Menge
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{bookedQuantity} / {totalQuantity} Stk. gebucht
</Typography>
</Box>
{totalQuantity > bookedQuantity && (
<Box>
<Typography variant="caption" color="text.secondary" display="block">
Noch offen
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, color: 'warning.dark' }}>
{totalQuantity - bookedQuantity} Stk.
</Typography>
</Box>
)}
</Box>
<Button
variant={status === 'booked' ? 'outlined' : 'contained'}
color={status === 'booked' ? 'success' : status === 'partial' ? 'warning' : 'primary'}
startIcon={
status === 'booked' ? (
<CheckCircleOutlineOutlinedIcon />
) : (
<Inventory2OutlinedIcon />
)
}
onClick={this.handleOpenBookDialog}
sx={{ fontWeight: 600, textTransform: 'none' }}
>
{status === 'booked'
? 'Buchungsdetails anzeigen'
: status === 'partial'
? 'Restliche Mengen einbuchen'
: 'Ins Lager buchen'}
</Button>
</Box>
{allEntries.length > 0 && (
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary', mb: 1, display: 'block' }}>
Bisherige Ledger-Buchungen ({allEntries.length}):
</Typography>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead sx={{ bgcolor: '#f1f5f9' }}>
<TableRow>
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Zeitpunkt</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Typ</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>SKU / Artikel</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, py: 0.5 }}>Menge</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Buchungstext</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5, fontFamily: 'monospace', fontSize: '0.75rem' }}>Prüfsumme (prev_hash)</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 }}>
<Chip
size="small"
label={entry.transaction_type}
color="success"
variant="outlined"
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 600 }}
/>
</TableCell>
<TableCell sx={{ py: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, fontFamily: 'monospace' }}>
{entry.lineSku || entry.sku || '–'}
</Typography>
{entry.lineName && (
<Typography variant="caption" color="text.secondary" sx={{ ml: 1 }}>
{entry.lineName}
</Typography>
)}
</TableCell>
<TableCell align="right" sx={{ py: 0.5, fontWeight: 700, color: 'success.main' }}>
+{entry.amount}
</TableCell>
<TableCell sx={{ py: 0.5 }}>
<Typography variant="caption">
{entry.transaction_text || '–'}
</Typography>
</TableCell>
<TableCell sx={{ py: 0.5, fontFamily: 'monospace', fontSize: '0.7rem', color: 'text.secondary' }}>
<Tooltip title={entry.prev_hash}>
<span>{entry.prev_hash ? `${entry.prev_hash.slice(0, 16)}…` : '–'}</span>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
)}
</Stack>
</PanelTile>
);
}
}
export default withNavigate(LieferungNeuPage);