This commit is contained in:
seb
2026-10-23 20:31:34 +02:00
parent 57347bdef9
commit b9bd0380cb
7 changed files with 533 additions and 78 deletions

View File

@@ -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) => (
<Link
component="button"
type="button"
variant="body2"
onClick={() => 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}
</Link>
);
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 (
<Table
size="small"
sx={{
'& td': { borderBottom: 'none', bgcolor: 'transparent' },
'& th': { bgcolor: 'transparent' },
}}
>
<TableHead>
<TableRow>
<TableCell
sx={{
...dateDividerSx,
width: '1%',
whiteSpace: 'nowrap',
verticalAlign: 'middle',
}}
>
Datum
</TableCell>
<TableCell
sx={dateCellSx}
>
{showPagination ? (
<ResultsPagination
offset={skip}
totalCount={total}
pageSize={PAGE_SIZE}
pageCount={pageCount}
currentPage={currentPage}
onPageChange={this.handlePageChange}
/>
) : null}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{history.map((entry) => (
<TableRow key={entry.id}>
<>
<Table
size="small"
sx={{
'& td': { borderBottom: 'none', bgcolor: 'transparent' },
'& th': { bgcolor: 'transparent' },
}}
>
<TableHead>
<TableRow>
<TableCell
sx={{ verticalAlign: 'top', whiteSpace: 'nowrap', width: '1%' }}
sx={{
...dateDividerSx,
width: '1%',
whiteSpace: 'nowrap',
verticalAlign: 'middle',
}}
>
{formatDate(entry.date)}
Datum
</TableCell>
<TableCell
sx={dateCellSx}
>
{showPagination ? (
<ResultsPagination
offset={skip}
totalCount={total}
pageSize={PAGE_SIZE}
pageCount={pageCount}
currentPage={currentPage}
onPageChange={this.handlePageChange}
/>
) : null}
</TableCell>
<TableCell sx={{ whiteSpace: 'pre-wrap' }}>{entry.text}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableHead>
<TableBody>
{history.map((entry) => (
<TableRow key={entry.id}>
<TableCell
sx={{ verticalAlign: 'top', whiteSpace: 'nowrap', width: '1%' }}
>
{formatDate(entry.date)}
</TableCell>
<TableCell sx={{ whiteSpace: 'pre-wrap' }}>
{this.renderEntryText(entry)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{selectedShipment && (
<ShipmentDetailDialog
open={Boolean(selectedShipment)}
shipment={selectedShipment}
onClose={this.handleCloseShipment}
onNavigate={this.props.navigate}
/>
)}
</>
);
}
}
export default withNavigate(ArticleHistoryTab);

View File

@@ -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' ? (
<OrderLedgerPanel entries={this.state.ledgerEntries} />
) : null}
</PanelTileGrid>
<ManifestPdfDialog
open={pdfDialogOpen}

View File

@@ -0,0 +1,96 @@
import { PureComponent } from 'react';
import Paper from '@mui/material/Paper';
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 Typography from '@mui/material/Typography';
import { PanelTile } from 'dashgui/panels';
import { SURFACE_BASE, SURFACE_RAISED } from '../../colors';
export default class OrderLedgerPanel extends PureComponent {
render() {
const { entries = [] } = this.props;
if (!Array.isArray(entries) || entries.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' }}>ArtNr</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Name</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5, whiteSpace: 'nowrap' }}>Lieferschein / Beleg</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5, whiteSpace: 'nowrap' }}>Lager</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, py: 0.5, whiteSpace: 'nowrap' }}>Menge verbucht</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Referenz</TableCell>
</TableRow>
</TableHead>
<TableBody>
{entries.map((entry) => {
const isNegative = entry.rawAmount != null && entry.rawAmount < 0;
return (
<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', fontWeight: 600 }}>
{entry.sku}
</Typography>
</TableCell>
<TableCell sx={{ py: 0.5 }}>
<Typography variant="caption">
{entry.name}
</Typography>
</TableCell>
<TableCell sx={{ py: 0.5, whiteSpace: 'nowrap' }}>
<Typography variant="caption">
{entry.documentNumber || '–'}
</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: isNegative ? 'error.main' : 'success.main',
whiteSpace: 'nowrap',
}}
>
{entry.mengeVerbucht}
</TableCell>
<TableCell sx={{ py: 0.5 }}>
<Typography variant="caption">
{entry.transaction_text || '–'}
</Typography>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</PanelTile>
);
}
}

View File

@@ -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 {
</Button>
)}
{shipment.state === 'delivered' && (
<Button
variant="contained"
color="warning"
disabled={busyAction}
onClick={() => this.handleStateChange('returned')}
sx={{ textTransform: 'none', fontWeight: 700 }}
>
{busyAction ? 'Wird gebucht…' : 'Retoure'}
</Button>
)}
{shipment.state === 'returned' && (
<Chip
label="Retoure"
color="error"
label="Versendet"
color="success"
variant="outlined"
sx={{ fontWeight: 700, height: 28, fontSize: '0.8rem' }}
/>

View File

@@ -424,12 +424,6 @@ class DeliveryStateMenu extends PureComponent {
>
Geliefert (Zugestellt)
</MenuItem>
<MenuItem
selected={currentState === 'returned'}
onClick={() => this.handleSelectState('returned')}
>
Retoure (Retourniert)
</MenuItem>
</Menu>
</Box>
);
@@ -824,14 +818,6 @@ class ShipmentSearchField extends PureComponent {
onClick={() => shipmentAutomat.actions.setStateFilter('delivered')}
sx={{ fontWeight: 600, cursor: 'pointer', height: 26 }}
/>
<Chip
size="small"
label="Retoure"
color={stateFilter === 'returned' ? 'error' : 'default'}
variant={stateFilter === 'returned' ? 'filled' : 'outlined'}
onClick={() => shipmentAutomat.actions.setStateFilter('returned')}
sx={{ fontWeight: 600, cursor: 'pointer', height: 26 }}
/>
</Box>
{/* Right: Negativeseek controls (-1 Month, -1 Year, Reset) */}

View File

@@ -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 {

184
src/utils/orderLedger.js Normal file
View File

@@ -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<object>} [options.shipments] Preloaded shipments
* @param {Array<object>} [options.lines] Order lines
* @param {Object} [options.ledgerApi]
* @param {Object} [options.shipmentApi]
* @returns {Promise<Array<object>>} 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;
}