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)
-
);
@@ -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