import { PureComponent, createRef } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import Link from '@mui/material/Link';
import Skeleton from '@mui/material/Skeleton';
import Typography from '@mui/material/Typography';
import ViewModuleIcon from '@mui/icons-material/ViewModule';
import { enqueueSnackbar } from 'notistack';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import FillSpace from 'dashgui/FillSpace';
import BackendError from '../../BackendError';
import { getArticleHistoryAutomat } from '../../../automats/articleDetailAutomat';
import sseAutomat from '../../../automats/sseAutomat';
import shipmentAutomat from '../../../automats/shipmentAutomat';
import orderAutomat from '../../../automats/orderAutomat';
import entityAutomat from '../../../automats/entityAutomat';
import companyAutomat from '../../../automats/companyAutomat';
import { shipment as shipmentApi, order as orderApi } from '../../../api';
import { ShipmentDetailDialog } from '../../manifest';
import SkeleHashImage from '../../settings/SkeleHashImage';
import withNavigate from '../../withNavigate';
import { backendErrorFrom } from '../../../utils/backendError';
import { notifyError } from '../../../utils/notify';
import { DEFAULT_CHUNK_SIZE as CHUNK_SIZE } from '../../../automats/createChunkListAutomat';
function formatDateTime(val) {
if (!val) return '–';
try {
const d = new Date(val);
if (isNaN(d.getTime())) return String(val);
return d.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
} catch {
return String(val);
}
}
function decodeMeta(meta) {
if (!meta) return null;
if (typeof meta === 'object') return meta;
try {
return JSON.parse(meta);
} catch {
return null;
}
}
// Dummy history entry for layout and FillSpace row measurement
export function createDummyTestArticleHistory() {
return {
id: 999999,
date: '2030-10-30T12:00:00Z',
text: 'Preis geändert von 10,00 € auf 12,50 € durch Benutzer Max Mustermann für Lieferschein LS-123456',
};
}
const rowGridSx = {
display: 'grid',
gridTemplateColumns: 'minmax(150px, max-content) minmax(260px, 1fr)',
gridTemplateRows: '0px 1fr',
rowGap: 0,
columnGap: 1.5,
alignItems: 'center',
width: '100%',
boxSizing: 'border-box',
};
const ellipsisSx = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
const measuringCellSx = {
visibility: 'hidden',
height: 0,
boxSizing: 'border-box',
pointerEvents: 'none',
textTransform: 'none',
letterSpacing: 'normal',
};
const headerCellSx = {
color: 'text.secondary',
typography: 'caption',
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.04em',
userSelect: 'none',
};
function renderInvisibleDummyRow(dummy) {
const d = dummy || createDummyTestArticleHistory();
return (
<>
{formatDateTime(d.date)}
{d.text}
>
);
}
/**
* Ambient row context for `ArticleHistoryRow` callbacks, mirroring the pattern in `QuoteList`.
*/
const rowContext = {
headerItem: null,
onOpenShipment: null,
onOpenOrder: null,
onOpenCustomer: null,
onOpenBrand: null,
};
// MARK:
class ArticleHistoryRow extends PureComponent {
renderLink(label, onClick) {
return (
{
e.stopPropagation();
const selection = window.getSelection()?.toString();
if (selection && selection.length > 0) return;
onClick?.(e);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onClick?.(e);
}
}}
sx={{
fontWeight: 600,
verticalAlign: 'baseline',
textAlign: 'left',
cursor: 'pointer',
color: 'primary.main',
textDecoration: 'underline',
fontFamily: 'inherit',
fontSize: 'inherit',
userSelect: 'text',
p: 0,
m: 0,
border: 'none',
background: 'none',
display: 'inline',
'&:hover': {
color: 'primary.dark',
},
}}
>
{label}
);
}
collectSpans(text, meta) {
const spans = [];
if (!text) return spans;
const { onOpenShipment, onOpenOrder, onOpenCustomer, onOpenBrand } = rowContext;
// 1. Lieferschein
const shipmentNumber =
meta?.shipmentNumber || (meta?.type === 'shipment' ? meta?.manifestNumber : null);
const regexShipment =
!shipmentNumber && text ? text.match(/Lieferschein\s+([A-Za-z0-9\-_]+)/) : null;
const effectiveShipmentNumber = shipmentNumber || regexShipment?.[1] || null;
if (effectiveShipmentNumber) {
const shipmentId = meta?.shipmentId ?? meta?.manifestId ?? meta?.id ?? null;
const targetPattern = `Lieferschein ${effectiveShipmentNumber}`;
let idx = text.indexOf(targetPattern);
if (idx !== -1) {
spans.push({
start: idx,
end: idx + targetPattern.length,
node: this.renderLink(targetPattern, () =>
onOpenShipment?.(effectiveShipmentNumber, shipmentId),
),
});
} else {
idx = text.indexOf(effectiveShipmentNumber);
if (idx !== -1) {
spans.push({
start: idx,
end: idx + effectiveShipmentNumber.length,
node: this.renderLink(effectiveShipmentNumber, () =>
onOpenShipment?.(effectiveShipmentNumber, shipmentId),
),
});
}
}
}
// 2. Auftrag (Order)
const orderNumber =
meta?.orderNumber || (meta?.type === 'order' ? meta?.number : null);
const regexOrder =
!orderNumber && text ? text.match(/Auftrag\s+([A-Za-z0-9\-_]+)/) : null;
const effectiveOrderNumber = orderNumber || regexOrder?.[1] || null;
if (effectiveOrderNumber) {
const orderId = meta?.orderId ?? null;
const targetPattern = `Auftrag ${effectiveOrderNumber}`;
let idx = text.indexOf(targetPattern);
if (idx !== -1) {
spans.push({
start: idx,
end: idx + targetPattern.length,
node: this.renderLink(targetPattern, () =>
onOpenOrder?.(orderId, effectiveOrderNumber),
),
});
} else {
idx = text.indexOf(effectiveOrderNumber);
if (idx !== -1) {
spans.push({
start: idx,
end: idx + effectiveOrderNumber.length,
node: this.renderLink(effectiveOrderNumber, () =>
onOpenOrder?.(orderId, effectiveOrderNumber),
),
});
}
}
}
// 3. Kunde (Customer)
const customerName =
meta?.customerName || (text.match(/Kunde:\s*"([^"]+)"/)?.[1] ?? null);
if (customerName) {
const inQuotes = `"${customerName}"`;
let idx = text.indexOf(inQuotes);
if (idx !== -1) {
spans.push({
start: idx,
end: idx + inQuotes.length,
node: this.renderLink(inQuotes, () => onOpenCustomer?.(customerName)),
});
} else {
idx = text.indexOf(customerName);
if (idx !== -1) {
spans.push({
start: idx,
end: idx + customerName.length,
node: this.renderLink(customerName, () => onOpenCustomer?.(customerName)),
});
}
}
}
// 4. Marke (Brand)
const brandNames = [meta?.newBrandName, meta?.oldBrandName].filter(Boolean);
for (const bName of brandNames) {
const inQuotes = `"${bName}"`;
let idx = text.indexOf(inQuotes);
if (idx !== -1) {
spans.push({
start: idx,
end: idx + inQuotes.length,
node: this.renderLink(inQuotes, () => onOpenBrand?.(bName)),
});
} else {
idx = text.indexOf(bName);
if (idx !== -1) {
spans.push({
start: idx,
end: idx + bName.length,
node: this.renderLink(bName, () => onOpenBrand?.(bName)),
});
}
}
}
return spans;
}
renderEntrySpans(text, meta) {
if (!text) return null;
const allSpans = this.collectSpans(text, meta);
if (allSpans.length === 0) return text;
allSpans.sort((a, b) => a.start - b.start || (b.end - b.start) - (a.end - a.start));
const nonOverlapping = [];
let lastEnd = 0;
for (const span of allSpans) {
if (span.start >= lastEnd) {
nonOverlapping.push(span);
lastEnd = span.end;
}
}
const fragments = [];
let cur = 0;
for (let i = 0; i < nonOverlapping.length; i++) {
const span = nonOverlapping[i];
if (span.start > cur) {
fragments.push(text.slice(cur, span.start));
}
fragments.push({span.node});
cur = span.end;
}
if (cur < text.length) {
fragments.push(text.slice(cur));
}
return fragments;
}
renderSkelehashThumb(meta) {
if (!meta?.skelehash) return null;
return (
);
}
render() {
const { item, disabled, headerItem } = this.props;
const dummy = headerItem || rowContext.headerItem;
if (!item) {
if (disabled) {
return (
);
}
return (
{renderInvisibleDummyRow(dummy)}
);
}
const meta = decodeMeta(item.meta);
return (
{renderInvisibleDummyRow(dummy)}
{formatDateTime(item.date || item.createdAt)}
{this.renderSkelehashThumb(meta)}
{this.renderEntrySpans(item.text, meta)}
);
}
}
// MARK:
class ArticleHistorySearchField extends PureComponent {
constructor(props) {
super(props);
const { automat } = props;
const s = automat.state;
const stats = automat.getChunkStats?.();
this.state = {
input: s.filter ?? '',
seekMonths: s.seekMonths ?? 0,
seekYears: s.seekYears ?? 0,
negativeSeekMonths: s.negativeSeekMonths ?? 0,
lruChunksCount: stats?.lruChunksCount || 0,
maxLruChunks: stats?.maxLruChunks || 50,
filterCachesCount: stats?.filterCaches?.length || 1,
};
}
componentDidMount() {
this.subscribeAutomat(this.props.automat);
}
componentDidUpdate(prevProps) {
if (prevProps.automat !== this.props.automat) {
this.unsubscribeAutomat();
const { automat } = this.props;
const s = automat.state;
const stats = automat.getChunkStats?.();
this.setState({
input: s.filter ?? '',
seekMonths: s.seekMonths ?? 0,
seekYears: s.seekYears ?? 0,
negativeSeekMonths: s.negativeSeekMonths ?? 0,
lruChunksCount: stats?.lruChunksCount || 0,
maxLruChunks: stats?.maxLruChunks || 50,
filterCachesCount: stats?.filterCaches?.length || 1,
});
this.subscribeAutomat(automat);
}
}
componentWillUnmount() {
this.unsubscribeAutomat();
}
subscribeAutomat(automat) {
this.unsub = automat.subscribe(this, (state) => ({
input: state.filter ?? '',
seekMonths: state.seekMonths ?? 0,
seekYears: state.seekYears ?? 0,
negativeSeekMonths: state.negativeSeekMonths ?? 0,
}));
if (import.meta.env.DEV) {
this.unsubStats = automat.subscribeChunkStats?.((stats) => {
this.setState({
lruChunksCount: stats.lruChunksCount,
maxLruChunks: stats.maxLruChunks,
filterCachesCount: stats.filterCaches?.length || 1,
});
});
}
}
unsubscribeAutomat() {
this.unsub?.();
this.unsub = null;
this.unsubStats?.();
this.unsubStats = null;
}
render() {
const { inputRef, automat } = this.props;
const { input, seekMonths, seekYears } = this.state;
const monthActive = seekMonths > 0;
const monthLabel = !monthActive ? '-1 Month' : `-${seekMonths} Month${seekMonths > 1 ? 's' : ''}`;
const yearActive = seekYears > 0;
const yearLabel = !yearActive ? '-1 Year' : `-${seekYears} Year${seekYears > 1 ? 's' : ''}`;
return (
{/* Top row: search field */}
automat.actions.filter(next)}
/>
{/* Filter Bar: Negativeseek (-1 Month, -1 Year, Reset) & Chunk-Cache Monitor */}
{import.meta.env.DEV && (
}
label={
this.state.lruChunksCount > 0
? `Chunk-Cache (${this.state.lruChunksCount}/${this.state.maxLruChunks || 50})`
: 'Chunk-Cache'
}
onClick={() =>
window.dispatchEvent(
new CustomEvent('dash:toggle-chunk-monitor', {
detail: { target: automat.name },
}),
)
}
sx={{
cursor: 'pointer',
height: 28,
fontWeight: 600,
fontSize: '0.75rem',
bgcolor: 'background.paper',
boxShadow: 1,
'&:hover': { bgcolor: 'action.hover' },
}}
/>
)}
);
}
}
// MARK:
class ArticleHistoryTab extends PureComponent {
constructor(props) {
super(props);
this.headerItem = createDummyTestArticleHistory();
this.automat = getArticleHistoryAutomat(props.sku);
this.state = {
items: this.automat.getItems(),
unfilteredTotal: this.automat.state.unfilteredTotal ?? 0,
filterKey: this.automat.getFilterKey?.() ?? '',
status: this.automat.state.status,
error: this.automat.state.error,
selectedShipment: null,
loadingShipment: false,
};
this.searchRef = createRef();
this.handlePageChange = this.handlePageChange.bind(this);
this.handleRetry = this.handleRetry.bind(this);
this.handleCloseShipment = this.handleCloseShipment.bind(this);
this.handleOpenShipment = this.handleOpenShipment.bind(this);
this.handleOpenOrder = this.handleOpenOrder.bind(this);
this.handleOpenCustomer = this.handleOpenCustomer.bind(this);
this.handleOpenBrand = this.handleOpenBrand.bind(this);
}
componentDidMount() {
this.watch(this.props.sku);
if (entityAutomat.state.status === 'idle') {
void entityAutomat.actions.load().catch(() => {});
}
if (companyAutomat.state.brandsStatus === 'idle') {
void companyAutomat.actions.loadBrands().catch(() => {});
}
}
componentDidUpdate(prevProps) {
if (prevProps.sku !== this.props.sku) {
this.watch(this.props.sku);
}
}
componentWillUnmount() {
this.unsub?.();
this.unsub = null;
this.unwatchSignal?.();
this.unwatchSignal = null;
}
watch(sku) {
this.unsub?.();
this.unwatchSignal?.();
this.automat = getArticleHistoryAutomat(sku);
this.setState({
items: this.automat.getItems(),
unfilteredTotal: this.automat.state.unfilteredTotal ?? 0,
filterKey: this.automat.getFilterKey?.() ?? '',
status: this.automat.state.status,
error: this.automat.state.error,
});
this.unsub = this.automat.subscribe(this, (state) => ({
items: state.items,
unfilteredTotal: state.unfilteredTotal ?? 0,
filterKey: this.automat.getFilterKey(state),
status: state.status,
error: state.error,
}));
this.unwatchSignal = sseAutomat.actions.watchArticle(String(sku), () => {
void this.automat.actions.reload().catch(() => {});
});
}
handlePageChange(page, meta) {
const pageSize = meta?.pageSize ?? 10;
const startIndex = (page - 1) * pageSize;
const items = this.state.items;
const maxIndex = items ? items.length - 1 : startIndex;
const endIndex = Math.min(maxIndex, startIndex + pageSize - 1);
const startChunk = Math.floor(startIndex / CHUNK_SIZE);
const endChunk = Math.floor(endIndex / CHUNK_SIZE);
this.automat.requireChunk(startChunk, endChunk);
}
handleRetry() {
void this.automat.actions.requireChunk(0, 0, { immediate: true }).catch(() => {});
}
handleCloseShipment() {
this.setState({ selectedShipment: null });
}
async handleOpenShipment(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);
}
}
async handleOpenOrder(orderId, orderNumber) {
if (orderId) {
this.props.navigate(`/auftrag/bestellung/${orderId}`);
return;
}
if (!orderNumber) return;
const cached = orderAutomat.state.items?.find(
(o) => o && String(o.number).toLowerCase() === String(orderNumber).toLowerCase(),
);
if (cached?.id) {
this.props.navigate(`/auftrag/bestellung/${cached.id}`);
return;
}
try {
const res = await orderApi.list({ search: orderNumber });
const orders = Array.isArray(res?.orders) ? res.orders : [];
const match = orders.find(
(o) => String(o.number).toLowerCase() === String(orderNumber).toLowerCase(),
) || orders[0];
if (match?.id) {
this.props.navigate(`/auftrag/bestellung/${match.id}`);
} else {
this.props.navigate(`/auftrag/bestellung?search=${encodeURIComponent(orderNumber)}`);
}
} catch {
this.props.navigate(`/auftrag/bestellung?search=${encodeURIComponent(orderNumber)}`);
}
}
async handleOpenCustomer(customerName) {
if (!customerName) return;
const target = String(customerName).trim().toLowerCase();
const findInItems = (items) => (items || []).find((c) => {
const full = [c.firstName, c.name].filter(Boolean).join(' ').trim().toLowerCase();
const nameOnly = String(c.name || '').trim().toLowerCase();
return full === target || nameOnly === target;
});
let match = findInItems(entityAutomat.state.items);
if (!match && entityAutomat.state.status !== 'loading') {
try {
const loaded = await entityAutomat.actions.load();
match = findInItems(loaded || entityAutomat.state.items);
} catch {}
}
if (match?.id) {
this.props.navigate(`/kunde/${match.id}`);
} else {
this.props.navigate(`/kunde?search=${encodeURIComponent(customerName)}`);
}
}
handleOpenBrand(brandName) {
if (!brandName) return;
const match = (companyAutomat.state.brands || []).find(
(b) => b && (b.name?.toLowerCase() === brandName.toLowerCase() || String(b.id) === String(brandName)),
);
const brandParam = match?.id != null ? String(match.id) : brandName;
this.props.navigate(`/artikel?brand=${encodeURIComponent(brandParam)}`);
}
render() {
const { items, unfilteredTotal, filterKey, status, error, selectedShipment } = this.state;
const isFailed = items === null && status === 'failed';
rowContext.onOpenShipment = this.handleOpenShipment;
rowContext.onOpenOrder = this.handleOpenOrder;
rowContext.onOpenCustomer = this.handleOpenCustomer;
rowContext.onOpenBrand = this.handleOpenBrand;
rowContext.headerItem = this.headerItem;
return (
{isFailed ? (
) : (
{renderInvisibleDummyRow(this.headerItem)}
Datum
Änderung / Ereignis
}
onPageChange={this.handlePageChange}
/>
{items === null && (
)}
)}
{selectedShipment && (
)}
);
}
}
export default withNavigate(ArticleHistoryTab);