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