From b9bd0380cb7b3d88660b59bab24fed212d667904 Mon Sep 17 00:00:00 2001 From: seb Date: Fri, 23 Oct 2026 20:31:34 +0200 Subject: [PATCH] u --- .../article/tabs/ArticleHistoryTab.jsx | 250 ++++++++++++++---- src/components/manifest/ManifestForm.jsx | 49 ++++ src/components/manifest/OrderLedgerPanel.jsx | 96 +++++++ .../manifest/ShipmentDetailDialog.jsx | 17 +- src/components/manifest/ShipmentList.jsx | 14 - src/components/manifest/index.js | 1 + src/utils/orderLedger.js | 184 +++++++++++++ 7 files changed, 533 insertions(+), 78 deletions(-) create mode 100644 src/components/manifest/OrderLedgerPanel.jsx create mode 100644 src/utils/orderLedger.js diff --git a/src/components/article/tabs/ArticleHistoryTab.jsx b/src/components/article/tabs/ArticleHistoryTab.jsx index 6f8154c..c56f38c 100644 --- a/src/components/article/tabs/ArticleHistoryTab.jsx +++ b/src/components/article/tabs/ArticleHistoryTab.jsx @@ -4,10 +4,19 @@ import TableBody from '@mui/material/TableBody'; import TableCell from '@mui/material/TableCell'; import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; +import Link from '@mui/material/Link'; +import { enqueueSnackbar } from 'notistack'; import { articleHistoryAutomat } from '../../../automats/articleDetailAutomat'; import sseAutomat from '../../../automats/sseAutomat'; +import shipmentAutomat from '../../../automats/shipmentAutomat'; +import { shipment as shipmentApi } from '../../../api'; +import { ShipmentDetailDialog } from '../../manifest'; +import withNavigate from '../../withNavigate'; import { notifyError } from '../../../utils/notify'; import ResultsPagination from '../ResultsPagination'; +import { + SCRIM_SOFT, +} from '../../../colors'; const PAGE_SIZE = 20; @@ -15,9 +24,15 @@ function formatDate(iso) { return new Date(iso).toLocaleString('de-DE'); } -import { - SCRIM_SOFT, -} from '../../../colors'; +function decodeMeta(meta) { + if (!meta) return null; + if (typeof meta === 'object') return meta; + try { + return JSON.parse(meta); + } catch { + return null; + } +} const HISTORY_DATE_CELL_BORDER = `1px solid ${SCRIM_SOFT}`; @@ -30,11 +45,17 @@ const dateCellSx = { verticalAlign: 'middle', }; -export default class ArticleHistoryTab extends PureComponent { +class ArticleHistoryTab extends PureComponent { constructor(props) { super(props); - this.state = { all: [], skip: 0 }; + this.state = { + all: [], + skip: 0, + selectedShipment: null, + loadingShipment: false, + }; this.handlePageChange = this.handlePageChange.bind(this); + this.handleCloseShipment = this.handleCloseShipment.bind(this); } componentDidMount() { @@ -88,8 +109,122 @@ export default class ArticleHistoryTab extends PureComponent { this.setState({ skip: (page - 1) * PAGE_SIZE }); } + handleCloseShipment() { + this.setState({ selectedShipment: null }); + } + + handleOpenShipment = async (shipmentNumber, shipmentId = null) => { + if (shipmentId) { + this.setState({ selectedShipment: { id: shipmentId, number: shipmentNumber } }); + return; + } + if (!shipmentNumber) return; + + this.setState({ loadingShipment: true }); + try { + const cached = shipmentAutomat.state.items?.find( + (s) => s && String(s.number).toLowerCase() === String(shipmentNumber).toLowerCase() + ); + if (cached?.id) { + this.setState({ selectedShipment: cached, loadingShipment: false }); + return; + } + + const res = await shipmentApi.list({ search: shipmentNumber }); + const shipments = Array.isArray(res?.shipments) ? res.shipments : []; + const match = shipments.find( + (s) => String(s.number).toLowerCase() === String(shipmentNumber).toLowerCase() + ) || shipments[0]; + + if (match?.id) { + this.setState({ selectedShipment: match, loadingShipment: false }); + } else { + enqueueSnackbar(`Lieferschein „${shipmentNumber}“ nicht gefunden.`, { variant: 'warning' }); + this.setState({ loadingShipment: false }); + } + } catch (err) { + this.setState({ loadingShipment: false }); + notifyError(err); + } + }; + + renderEntryText(entry) { + const meta = decodeMeta(entry.meta); + const shipmentNumber = meta?.shipmentNumber || (meta?.type === 'shipment' ? meta?.manifestNumber : null); + const regexMatch = !shipmentNumber && entry.text ? entry.text.match(/Lieferschein\s+([A-Za-z0-9\-_]+)/) : null; + const effectiveShipmentNumber = shipmentNumber || regexMatch?.[1] || null; + + if (!effectiveShipmentNumber || !entry.text) { + return entry.text; + } + + const shipmentId = meta?.shipmentId ?? meta?.manifestId ?? meta?.id ?? null; + const targetPattern = `Lieferschein ${effectiveShipmentNumber}`; + const idx = entry.text.indexOf(targetPattern); + + const linkElement = (label) => ( + this.handleOpenShipment(effectiveShipmentNumber, shipmentId)} + sx={{ + fontWeight: 600, + verticalAlign: 'baseline', + textAlign: 'left', + cursor: 'pointer', + color: 'primary.main', + textDecoration: 'underline', + fontFamily: 'inherit', + fontSize: 'inherit', + p: 0, + m: 0, + border: 'none', + background: 'none', + display: 'inline', + '&:hover': { + color: 'primary.dark', + }, + }} + > + {label} + + ); + + if (idx !== -1) { + const before = entry.text.slice(0, idx); + const after = entry.text.slice(idx + targetPattern.length); + return ( + <> + {before} + {linkElement(targetPattern)} + {after} + + ); + } + + const numIdx = entry.text.indexOf(effectiveShipmentNumber); + if (numIdx !== -1) { + const before = entry.text.slice(0, numIdx); + const after = entry.text.slice(numIdx + effectiveShipmentNumber.length); + return ( + <> + {before} + {linkElement(effectiveShipmentNumber)} + {after} + + ); + } + + return ( + <> + {linkElement(`Lieferschein ${effectiveShipmentNumber}`)} {entry.text} + + ); + } + render() { - const { all, skip } = this.state; + const { all, skip, selectedShipment } = this.state; const total = all.length; const history = this.pageOf(all, skip); @@ -98,54 +233,69 @@ export default class ArticleHistoryTab extends PureComponent { const showPagination = total > PAGE_SIZE; return ( - - - - - Datum - - - {showPagination ? ( - - ) : null} - - - - - {history.map((entry) => ( - + <> +
+ + - {formatDate(entry.date)} + Datum + + + {showPagination ? ( + + ) : null} - {entry.text} - ))} - -
+ + + {history.map((entry) => ( + + + {formatDate(entry.date)} + + + {this.renderEntryText(entry)} + + + ))} + + + + {selectedShipment && ( + + )} + ); } } + +export default withNavigate(ArticleHistoryTab); diff --git a/src/components/manifest/ManifestForm.jsx b/src/components/manifest/ManifestForm.jsx index 77c4f56..49ba58e 100644 --- a/src/components/manifest/ManifestForm.jsx +++ b/src/components/manifest/ManifestForm.jsx @@ -21,6 +21,10 @@ import customergroupAutomat from '../../automats/customergroupAutomat'; import supplierAutomat from '../../automats/supplierAutomat'; import orderAutomat from '../../automats/orderAutomat'; import companyAutomat, { selectCompanyTaxrate } from '../../automats/companyAutomat'; +import warehouseAutomat from '../../automats/warehouseAutomat'; +import sseAutomat from '../../automats/sseAutomat'; +import OrderLedgerPanel from './OrderLedgerPanel'; +import { fetchOrderLedgerEntries } from '../../utils/orderLedger'; import { netFromGross, grossFromNet, getArticleGross } from '../../utils/price'; import { resolveSalesPrice } from '../../utils/articleDetail'; import { @@ -102,6 +106,7 @@ class ManifestForm extends PureComponent { shipments: initialEdit?.shipments || [], payments: initialEdit?.payments || [], shippings: initialEdit?.shippings || [], + ledgerEntries: [], orderTotal: initialEdit?.orderTotal ?? null, paidTotal: initialEdit?.paidTotal ?? null, paid: Boolean(initialEdit?.paid), @@ -217,8 +222,20 @@ class ManifestForm extends PureComponent { } }); + if (warehouseAutomat.state.status === 'idle') { + void warehouseAutomat.actions.load(); + } + if (this.props.mode === 'edit') { void this.loadEdit(); + if (this.props.kind === 'order') { + this.unsubSseLedger = sseAutomat?.actions?.on?.('ledger', () => { + void this.reloadLedger(); + }); + this.unsubSseStock = sseAutomat?.actions?.on?.('stock', () => { + void this.reloadLedger(); + }); + } } else { void this.loadPartiesOnly(); void ensureDocumentNumber(this.props.kind).catch(() => {}); @@ -359,12 +376,28 @@ class ManifestForm extends PureComponent { this.unsubParties?.(); this.unsubGroups?.(); this.unsubCompany?.(); + this.unsubSseLedger?.(); + this.unsubSseStock?.(); if (this._parseAbortController) { this._parseAbortController.abort(); this._parseAbortController = null; } } + async reloadLedger() { + if (this.props.kind !== 'order' || this.props.mode !== 'edit' || !this.props.docId) return; + try { + const ledgerEntries = await fetchOrderLedgerEntries(this.props.docId, { + shipments: this.state.shipments, + lines: this.state.lines, + }); + if (this._cancelled) return; + this.setState({ ledgerEntries }); + } catch (err) { + console.warn('[ManifestForm] Failed to reload ledger entries:', err); + } + } + async loadPartiesOnly() { if (this.state.parties.length === 0) { this.setState({ status: 'loadingParties', error: null, partiesLoading: true }); @@ -464,6 +497,18 @@ class ManifestForm extends PureComponent { } } + let ledgerEntries = []; + if (kind === 'order') { + try { + ledgerEntries = await fetchOrderLedgerEntries(docId, { + shipments, + lines: doc.lines, + }); + } catch (lErr) { + console.warn('[ManifestForm] Failed to load ledger entries:', lErr); + } + } + const loadedDoc = { date: dateInputValue(doc.date), partyId: doc[cfg.partyField], @@ -492,6 +537,7 @@ class ManifestForm extends PureComponent { parties, partiesLoading: false, ...loadedDoc, + ledgerEntries, isDirty: false, dirtyFields: null, dirtyLineKeys: [], @@ -1409,6 +1455,9 @@ class ManifestForm extends PureComponent { navigate={this.props.navigate} /> ) : null} + {status === 'ready' && kind === 'order' && this.props.mode === 'edit' ? ( + + ) : null} + + + + + Zeitpunkt + ArtNr + Name + Lieferschein / Beleg + Lager + Menge verbucht + Referenz + + + + {entries.map((entry) => { + const isNegative = entry.rawAmount != null && entry.rawAmount < 0; + return ( + + + + {entry.created_at ? new Date(entry.created_at).toLocaleString('de-DE') : '–'} + + + + + {entry.sku} + + + + + {entry.name} + + + + + {entry.documentNumber || '–'} + + + + + {entry.warehouseName} + + + + {entry.mengeVerbucht} + + + + {entry.transaction_text || '–'} + + + + ); + })} + +
+
+ + ); + } +} diff --git a/src/components/manifest/ShipmentDetailDialog.jsx b/src/components/manifest/ShipmentDetailDialog.jsx index 73309c1..050d0a5 100644 --- a/src/components/manifest/ShipmentDetailDialog.jsx +++ b/src/components/manifest/ShipmentDetailDialog.jsx @@ -328,7 +328,7 @@ export default class ShipmentDetailDialog extends PureComponent { this.setState((prev) => ({ shipment: { ...prev.shipment, state: newState }, })); - const label = newState === 'delivered' ? 'Versendet' : newState === 'returned' ? 'Retoure' : 'Neu'; + const label = newState === 'delivered' ? 'Versendet' : 'Neu'; enqueueSnackbar(`Lieferstatus geändert: ${label}`, { variant: 'success' }); await Promise.all([ orderAutomat.actions.reload(), @@ -631,20 +631,9 @@ export default class ShipmentDetailDialog extends PureComponent { )} {shipment.state === 'delivered' && ( - - )} - {shipment.state === 'returned' && ( diff --git a/src/components/manifest/ShipmentList.jsx b/src/components/manifest/ShipmentList.jsx index 706e557..a2a6bea 100644 --- a/src/components/manifest/ShipmentList.jsx +++ b/src/components/manifest/ShipmentList.jsx @@ -424,12 +424,6 @@ class DeliveryStateMenu extends PureComponent { > Geliefert (Zugestellt) - this.handleSelectState('returned')} - > - Retoure (Retourniert) - ); @@ -824,14 +818,6 @@ class ShipmentSearchField extends PureComponent { onClick={() => shipmentAutomat.actions.setStateFilter('delivered')} sx={{ fontWeight: 600, cursor: 'pointer', height: 26 }} /> - shipmentAutomat.actions.setStateFilter('returned')} - sx={{ fontWeight: 600, cursor: 'pointer', height: 26 }} - /> {/* Right: Negativeseek controls (-1 Month, -1 Year, Reset) */} diff --git a/src/components/manifest/index.js b/src/components/manifest/index.js index f3c8633..7f3cded 100644 --- a/src/components/manifest/index.js +++ b/src/components/manifest/index.js @@ -18,6 +18,7 @@ export { default as BookDeliveryDialog } from './BookDeliveryDialog'; export { default as OrderShipmentDialog } from './OrderShipmentDialog'; export { default as ShipmentDetailDialog } from './ShipmentDetailDialog'; export { default as OrderDocumentsTiles } from './OrderDocumentsTiles'; +export { default as OrderLedgerPanel } from './OrderLedgerPanel'; export { default as PartyFilterPanel, PartyFilterButton, PartyFilterChip } from './PartyFilterPanel'; export { withManifestId } from './withManifestId'; export { diff --git a/src/utils/orderLedger.js b/src/utils/orderLedger.js new file mode 100644 index 0000000..ba74386 --- /dev/null +++ b/src/utils/orderLedger.js @@ -0,0 +1,184 @@ +import { ledger as defaultLedgerApi, shipment as defaultShipmentApi } from '../api'; +import warehouseAutomat from '../automats/warehouseAutomat'; + +/** + * Fetches ledger entries for an order by inspecting: + * 1. All lines of the order's shipments (each shipment line id is a manifestarticle_id) + * 2. All lines of the order itself (in case order lines have direct ledger bookings) + * + * @param {number|string} orderId + * @param {Object} [options] + * @param {Array} [options.shipments] Preloaded shipments + * @param {Array} [options.lines] Order lines + * @param {Object} [options.ledgerApi] + * @param {Object} [options.shipmentApi] + * @returns {Promise>} Flattened, correlated and sorted ledger entries + */ +export async function fetchOrderLedgerEntries(orderId, { + shipments = null, + lines = null, + ledgerApi = defaultLedgerApi, + shipmentApi = defaultShipmentApi, +} = {}) { + let shipmentList = shipments; + if (!shipmentList && orderId) { + try { + const res = await shipmentApi.listByOrder(orderId); + shipmentList = Array.isArray(res?.shipments) ? res.shipments : []; + } catch (err) { + console.warn('[orderLedger] Failed to fetch shipments for order:', err); + shipmentList = []; + } + } + + // Ensure shipments have their lines loaded + if (Array.isArray(shipmentList)) { + shipmentList = await Promise.all( + shipmentList.map(async (sh) => { + if (Array.isArray(sh.lines) && sh.lines.length > 0) return sh; + if (!sh.id) return sh; + try { + const detailRes = await shipmentApi.get(sh.id); + const detail = detailRes?.shipment ?? detailRes; + return detail?.lines ? detail : sh; + } catch { + return sh; + } + }) + ); + } + + // Collect target lines to check for ledger bookings + const targets = []; + const seenIds = new Set(); + + for (const sh of shipmentList || []) { + const docNum = sh.number || `Lieferschein #${sh.id}`; + for (const sLine of sh.lines || []) { + if (sLine?.id && !seenIds.has(sLine.id)) { + seenIds.add(sLine.id); + targets.push({ + manifestarticleId: sLine.id, + sku: sLine.sku || null, + name: sLine.name || null, + lineAmount: sLine.amount ?? null, + documentNumber: docNum, + documentType: 'shipment', + }); + } + } + } + + for (const oLine of lines || []) { + if (oLine?.id && !seenIds.has(oLine.id)) { + seenIds.add(oLine.id); + targets.push({ + manifestarticleId: oLine.id, + sku: oLine.sku || null, + name: oLine.name || null, + lineAmount: oLine.amount ?? null, + documentNumber: null, + documentType: 'order', + }); + } + } + + if (targets.length === 0) return []; + + // Query ledger entries for each manifestarticleId in parallel + const results = await Promise.all( + targets.map(async (t) => { + try { + const res = await ledgerApi.listByManifestarticleId(t.manifestarticleId); + const entries = Array.isArray(res?.ledger) ? res.ledger : []; + return { target: t, entries }; + } catch (err) { + console.warn(`[orderLedger] Failed to fetch ledger for line ${t.manifestarticleId}:`, err); + return { target: t, entries: [] }; + } + }) + ); + + const warehouses = warehouseAutomat.state.warehouses || []; + const allEntries = []; + + for (const { target, entries } of results) { + if (!entries || entries.length === 0) continue; + + const docEntries = entries.filter((e) => { + const type = String(e.transaction_type || '').toUpperCase(); + return type !== 'WAREHOUSE'; + }); + const warehouseEntries = entries.filter((e) => { + const type = String(e.transaction_type || '').toUpperCase(); + return type === 'WAREHOUSE'; + }); + + if (docEntries.length > 0) { + docEntries.forEach((docEntry, idx) => { + const matchingWh = + warehouseEntries.find( + (w) => + w.warehouse_id != null && + (w.transaction_text === docEntry.transaction_text || + Math.abs(new Date(w.created_at) - new Date(docEntry.created_at)) < 15000) + ) || warehouseEntries[idx] || null; + + const warehouseId = matchingWh?.warehouse_id ?? docEntry.warehouse_id ?? null; + const wh = warehouseId != null + ? warehouses.find((w) => Number(w.id) === Number(warehouseId)) + : null; + const warehouseName = wh?.name || (warehouseId != null ? `Lager #${warehouseId}` : '–'); + + const rawAmount = matchingWh?.amount ?? docEntry.amount; + const mengeVerbucht = rawAmount != null + ? (Number(rawAmount) > 0 ? `+${rawAmount}` : String(rawAmount)) + : '–'; + + allEntries.push({ + id: docEntry.id, + created_at: docEntry.created_at, + sku: docEntry.sku || target.sku || '–', + name: target.name || '–', + lineAmount: target.lineAmount != null ? String(target.lineAmount) : '–', + documentNumber: target.documentNumber, + warehouseName, + warehouseId, + mengeVerbucht, + rawAmount: rawAmount != null ? Number(rawAmount) : null, + transaction_type: docEntry.transaction_type, + transaction_text: docEntry.transaction_text || '–', + }); + }); + } else if (warehouseEntries.length > 0) { + for (const w of warehouseEntries) { + const wh = w.warehouse_id != null + ? warehouses.find((item) => Number(item.id) === Number(w.warehouse_id)) + : null; + const warehouseName = wh?.name || (w.warehouse_id != null ? `Lager #${w.warehouse_id}` : '–'); + const rawAmount = w.amount; + const mengeVerbucht = rawAmount != null + ? (Number(rawAmount) > 0 ? `+${rawAmount}` : String(rawAmount)) + : '–'; + + allEntries.push({ + id: w.id, + created_at: w.created_at, + sku: w.sku || target.sku || '–', + name: target.name || '–', + lineAmount: target.lineAmount != null ? String(target.lineAmount) : '–', + documentNumber: target.documentNumber, + warehouseName, + warehouseId: w.warehouse_id, + mengeVerbucht, + rawAmount: rawAmount != null ? Number(rawAmount) : null, + transaction_type: w.transaction_type, + transaction_text: w.transaction_text || '–', + }); + } + } + } + + allEntries.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || ''))); + return allEntries; +}