diff --git a/src/components/manifest/QuoteList.jsx b/src/components/manifest/QuoteList.jsx new file mode 100644 index 0000000..e474594 --- /dev/null +++ b/src/components/manifest/QuoteList.jsx @@ -0,0 +1,553 @@ +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 IconButton from '@mui/material/IconButton'; +import ListItemButton from '@mui/material/ListItemButton'; +import Typography from '@mui/material/Typography'; +import Skeleton from '@mui/material/Skeleton'; +import Tooltip from '@mui/material/Tooltip'; +import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; +import ViewModuleIcon from '@mui/icons-material/ViewModule'; +import { enqueueSnackbar } from 'notistack'; +import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; +import FillSpace from 'dashgui/FillSpace'; +import { PanelTile, PanelTileGrid } from 'dashgui/panels'; +import BackendError from '../BackendError'; +import quoteAutomat, { CHUNK_SIZE } from '../../automats/quoteAutomat'; +import { quote as quoteApi } from '../../api'; +import { backendErrorFrom } from '../../utils/backendError'; +import { notifyError } from '../../utils/notify'; +import { formatPrice } from '../../utils/price'; +import withNavigate from '../withNavigate'; +import { + formatDate, + loadParties, + manifestConfig, + numberDisplay, + partyDisplayName, +} from './manifestConfig'; + +// MARK: pure helpers +function quoteGrossTotal(lines) { + if (!Array.isArray(lines)) return 0; + return lines.reduce((acc, line) => { + const net = (Number(line.amount) || 0) * (Number(line.net) || 0) * (1 - (Number(line.rebate) || 0) / 100); + return acc + net * (1 + (Number(line.taxrate) || 0) / 100); + }, 0); +} + +const cfg = manifestConfig('quote'); + +const rowSx = { + alignItems: 'center', + height: 64, + minHeight: 64, + maxHeight: 64, + boxSizing: 'border-box', + py: 0, + gap: 1.5, +}; + +const ellipsisSx = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }; + +/** + * Ambient context for `QuoteRow`: FillSpace renders rows as bare elements + * (``), so the list publishes callbacks and the party map + * here on every render. + */ +const rowContext = { + partiesById: {}, + busyId: null, + onOpen: null, + onDelete: null, +}; + +// MARK: +class QuoteRow extends PureComponent { + partyName(partyId) { + if (partyId == null) return '–'; + const party = rowContext.partiesById[partyId]; + return partyDisplayName('quote', party) || `Kunde #${partyId}`; + } + + render() { + const { item: quote, disabled } = this.props; + + // Data-free probe (FillSpace measures the row height with `item` undefined). + if (!quote) { + if (disabled) { + return ( + +   + + ); + } + return ( + + + + + + + + + + + + + + + + + + + + ); + } + + const { busyId, onOpen, onDelete } = rowContext; + const posCount = quote.positions != null ? quote.positions : (quote.lines?.length ?? 0); + const gross = quote.grossTotal != null ? Number(quote.grossTotal) : quoteGrossTotal(quote.lines); + const customer = quote.customerName || this.partyName(quote.entityId); + const quantityInfo = quote.totalQuantity != null ? ` · ${quote.totalQuantity} Stück` : ''; + + return ( + onOpen?.(quote)}> + + + {quote.number} + + + {numberDisplay(quote.externalNumber)} + + + + + + {quote.date ? formatDate(quote.date) : '–'} + + + + + + {customer} + + + + 0 ? 'text.primary' : 'text.disabled', + '& .MuiChip-label': { px: 0.5 }, + }} + /> + + + {gross > 0 ? formatPrice(gross) : '—'} + + + { + e.stopPropagation(); + onDelete?.(quote); + }} + onMouseDown={(e) => e.stopPropagation()} + sx={{ width: 40, height: 40, flexShrink: 0 }} + > + + + + ); + } +} + +// MARK: +class QuoteSearchField extends PureComponent { + constructor(props) { + super(props); + const s = quoteAutomat.state; + const stats = quoteAutomat.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.unsub = quoteAutomat.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 = quoteAutomat.subscribeChunkStats?.((stats) => { + this.setState({ + lruChunksCount: stats.lruChunksCount, + maxLruChunks: stats.maxLruChunks, + filterCachesCount: stats.filterCaches?.length || 1, + }); + }); + } + } + + componentWillUnmount() { + this.unsub?.(); + this.unsubStats?.(); + } + + render() { + const { inputRef, onCreate } = 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 & create button */} + + quoteAutomat.actions.filter(next)} + /> + + + + {/* Filter Bar: Negativeseek (-1 Month, -1 Year, Reset) & Chunk-Cache Monitor */} + + {/* Negativeseek controls (-1 Month, -1 Year, Reset) */} + + + + + + + + + + {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: 'quote' } }))} + sx={{ + cursor: 'pointer', + height: 28, + fontWeight: 600, + fontSize: '0.75rem', + bgcolor: 'background.paper', + boxShadow: 1, + '&:hover': { bgcolor: 'action.hover' }, + }} + /> + + )} + + + + + ); + } +} + +// MARK: +export class QuoteList extends PureComponent { + constructor(props) { + super(props); + this.state = { + items: quoteAutomat.getItems(), + unfilteredTotal: quoteAutomat.state.unfilteredTotal ?? 0, + filterKey: quoteAutomat.getFilterKey?.() ?? '', + partiesById: {}, + busyId: null, + }; + this.searchRef = createRef(); + this._cancelled = false; + this.handleRetry = this.handleRetry.bind(this); + this.handleCreate = this.handleCreate.bind(this); + this.handleOpen = this.handleOpen.bind(this); + this.handleDelete = this.handleDelete.bind(this); + this.handlePageChange = this.handlePageChange.bind(this); + } + + componentDidMount() { + this._cancelled = false; + this.unsub = quoteAutomat.subscribe(this, (state) => ({ + items: state.items, + unfilteredTotal: state.unfilteredTotal ?? 0, + filterKey: quoteAutomat.getFilterKey(state), + })); + void this.loadParties(); + } + + componentWillUnmount() { + this._cancelled = true; + this.unsub?.(); + } + + async loadParties() { + try { + const parties = await loadParties('quote'); + if (!this._cancelled) { + this.setState({ partiesById: cfg.partiesByIdFromList(parties) }); + } + } catch { + // Non-fatal: names fall back to ids or customerName. + } + } + + 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); + + quoteAutomat.requireChunk(startChunk, endChunk); + } + + handleRetry() { + void quoteAutomat.actions.requireChunk(0, 0, { immediate: true }).catch(() => {}); + } + + handleCreate() { + this.props.navigate(cfg.createPath); + } + + handleOpen(quote) { + this.props.navigate(cfg.editPath(quote.id)); + } + + async handleDelete(quote) { + if (this.state.busyId != null) return; + if (!window.confirm(cfg.confirmDelete(quote.number))) return; + this.setState({ busyId: quote.id }); + try { + await quoteApi.delete(quote.id); + await quoteAutomat.actions.reload(); + enqueueSnackbar(cfg.deletedMsg, { variant: 'success' }); + } catch (err) { + notifyError(err); + } finally { + if (!this._cancelled) this.setState({ busyId: null }); + } + } + + render() { + const { items, unfilteredTotal, filterKey, partiesById, busyId } = this.state; + + rowContext.partiesById = partiesById; + rowContext.busyId = busyId; + rowContext.onOpen = this.handleOpen; + rowContext.onDelete = this.handleDelete; + + if (items === null && quoteAutomat.state.status === 'failed') { + return ( + <> + + + + + + + + ); + } + + return ( + <> + + + + + Nr. / Ref. + Datum + Kunde + Pos. + Gesamt + + + } + onPageChange={this.handlePageChange} + /> + {items === null && ( + + + + )} + + + + ); + } +} + +export default withNavigate(QuoteList); diff --git a/src/components/manifest/index.js b/src/components/manifest/index.js index 098ce8e..b39dd73 100644 --- a/src/components/manifest/index.js +++ b/src/components/manifest/index.js @@ -1,6 +1,7 @@ export { default as ManifestList } from './ManifestList'; export { default as DeliveryList } from './DeliveryList'; export { default as OrderList } from './OrderList'; +export { default as QuoteList } from './QuoteList'; export { default as ShipmentList } from './ShipmentList'; export { default as OrderPaymentList } from './OrderPaymentList'; diff --git a/src/devtools/ChunkCacheMonitor.jsx b/src/devtools/ChunkCacheMonitor.jsx index 9528b31..ada2dfe 100644 --- a/src/devtools/ChunkCacheMonitor.jsx +++ b/src/devtools/ChunkCacheMonitor.jsx @@ -37,10 +37,23 @@ import orderAutomat, { MAX_LRU_CHUNKS, isOrderPaid, isOrderDelivered, - subscribeChunkStats, } from '../automats/orderAutomat'; +import quoteAutomat from '../automats/quoteAutomat'; import { formatPrice } from '../utils/price'; +function getAutomatFor(target) { + return target === 'quote' ? quoteAutomat : orderAutomat; +} + +function getInitialTarget() { + if (typeof window !== 'undefined') { + const p = window.location.pathname; + if (p.startsWith('/auftrag/bestellung')) return 'order'; + if (p.startsWith('/auftrag')) return 'quote'; + } + return 'order'; +} + const PANEL_WIDTH = 680; const PANEL_HEIGHT = 520; @@ -72,7 +85,9 @@ function orderGrossTotal(lines) { class ChunkCacheMonitor extends PureComponent { constructor(props) { super(props); - const initialStats = orderAutomat.getChunkStats?.() || { + const target = getInitialTarget(); + const automat = getAutomatFor(target); + const initialStats = automat.getChunkStats?.() || { chunkSize: CHUNK_SIZE, maxLruChunks: MAX_LRU_CHUNKS, lruChunksCount: 0, @@ -100,6 +115,7 @@ class ChunkCacheMonitor extends PureComponent { open: false, position: null, tab: 'grid', // 'grid' | 'lru' | 'log' + target, // 'quote' | 'order' inspectFilterKey: null, // null means follow activeFilterKey selectedChunk: null, pulseCounter: 0, @@ -121,10 +137,12 @@ class ChunkCacheMonitor extends PureComponent { this.handleApplyFilter = this.handleApplyFilter.bind(this); this.handlePrefetchAll = this.handlePrefetchAll.bind(this); this.handleReload = this.handleReload.bind(this); + this.switchTarget = this.switchTarget.bind(this); } componentDidMount() { - this.unsubscribeStats = subscribeChunkStats((stats) => { + const automat = getAutomatFor(this.state.target); + this.unsubscribeStats = automat.subscribeChunkStats?.((stats) => { this.setState((prev) => ({ stats, pulseCounter: prev.pulseCounter + 1, @@ -132,10 +150,39 @@ class ChunkCacheMonitor extends PureComponent { }); window.addEventListener('keydown', this.handleKeyDown); - this.customEventListener = () => this.handleToggle(); + this.customEventListener = (event) => { + const reqTarget = event?.detail?.target; + if (reqTarget === 'quote' || reqTarget === 'order') { + this.switchTarget(reqTarget); + } else { + const detected = getInitialTarget(); + if (detected !== this.state.target) { + this.switchTarget(detected); + } + } + this.handleToggle(); + }; window.addEventListener('dash:toggle-chunk-monitor', this.customEventListener); } + switchTarget(newTarget) { + if (newTarget === this.state.target) return; + this.unsubscribeStats?.(); + const automat = getAutomatFor(newTarget); + this.unsubscribeStats = automat.subscribeChunkStats?.((stats) => { + this.setState((prev) => ({ + stats, + pulseCounter: prev.pulseCounter + 1, + })); + }); + this.setState({ + target: newTarget, + stats: automat.getChunkStats?.() || this.state.stats, + selectedChunk: null, + inspectFilterKey: null, + }); + } + componentWillUnmount() { this.unsubscribeStats?.(); window.removeEventListener('keydown', this.handleKeyDown); @@ -204,51 +251,60 @@ class ChunkCacheMonitor extends PureComponent { handleLoadChunk(chunkIndex, filterKey, e) { e?.stopPropagation(); + const automat = getAutomatFor(this.state.target); const targetFilter = filterKey || this.getInspectedFilterKey(); - void orderAutomat.actions.loadChunk(chunkIndex, orderAutomat.state.queryEpoch, targetFilter); + void automat.actions.loadChunk(chunkIndex, automat.state.queryEpoch, targetFilter); } handleEvictChunk(chunkIndex, filterKey, e) { e?.stopPropagation(); + const automat = getAutomatFor(this.state.target); const targetFilter = filterKey || this.getInspectedFilterKey(); - orderAutomat.evictChunk?.(chunkIndex, targetFilter); + automat.evictChunk?.(chunkIndex, targetFilter); if (this.state.selectedChunk === chunkIndex) { this.setState({ selectedChunk: null }); } } handleClearCache() { - orderAutomat.clearCache?.(); + const automat = getAutomatFor(this.state.target); + automat.clearCache?.(); this.setState({ selectedChunk: null }); } handleClearFilter(filterKey, e) { e?.stopPropagation(); - orderAutomat.clearFilterCache?.(filterKey); + const automat = getAutomatFor(this.state.target); + automat.clearFilterCache?.(filterKey); if (this.state.inspectFilterKey === filterKey) { this.setState({ inspectFilterKey: null, selectedChunk: null }); } } handleApplyFilter(filterKey) { + const automat = getAutomatFor(this.state.target); const entry = this.state.stats.filterCaches?.find((f) => f.key === filterKey); if (!entry) return; const p = entry.params || {}; - orderAutomat.actions.resetFilters(); - if (p.filter) orderAutomat.actions.filter(p.filter); - if (p.onlyUnpaid) orderAutomat.actions.setOnlyUnpaid(true); - if (p.onlyUndelivered) orderAutomat.actions.setOnlyUndelivered(true); - if (p.negativeSeekMonths) orderAutomat.actions.addNegativeSeek(p.negativeSeekMonths); + automat.actions.resetFilters(); + if (p.filter) automat.actions.filter(p.filter); + if (this.state.target === 'order') { + if (p.onlyUnpaid) automat.actions.setOnlyUnpaid(true); + if (p.onlyUndelivered) automat.actions.setOnlyUndelivered(true); + } + if (p.negativeSeekMonths) automat.actions.addNegativeSeek(p.negativeSeekMonths); this.setState({ inspectFilterKey: null, selectedChunk: null }); } handlePrefetchAll(filterKey) { + const automat = getAutomatFor(this.state.target); const targetFilter = filterKey || this.getInspectedFilterKey(); - orderAutomat.prefetchAllChunks?.(targetFilter); + automat.prefetchAllChunks?.(targetFilter); } handleReload() { - void orderAutomat.actions.reload(false); + const automat = getAutomatFor(this.state.target); + void automat.actions.reload(false); this.setState({ selectedChunk: null }); } @@ -658,9 +714,12 @@ class ChunkCacheMonitor extends PureComponent { ) : ( {chunkOrders.map(({ item: order, index }) => { - const paid = isOrderPaid(order); - const delivered = isOrderDelivered(order); - const gross = orderGrossTotal(order.lines); + const isOrd = this.state.target === 'order'; + const paid = isOrd ? isOrderPaid(order) : false; + const delivered = isOrd ? isOrderDelivered(order) : false; + const gross = order.grossTotal != null ? Number(order.grossTotal) : orderGrossTotal(order.lines); + const customer = order.customerName || (order.entityId ? `Kunde #${order.entityId}` : '–'); + const posCount = order.positions != null ? order.positions : (order.lines?.length ?? 0); return ( - Kunde #{order.entityId || '–'} + {customer} - + {isOrd && ( + <> + + + + )} - + + this.switchTarget('quote')} + sx={{ cursor: 'pointer', height: 24, fontSize: '0.72rem', fontWeight: this.state.target === 'quote' ? 700 : 500 }} + /> + this.switchTarget('order')} + sx={{ cursor: 'pointer', height: 24, fontSize: '0.72rem', fontWeight: this.state.target === 'order' ? 700 : 500 }} + /> + = maxLru ? 'warning' : 'success'} diff --git a/src/routes/auftrag.jsx b/src/routes/auftrag.jsx index 9954b57..8ed7ddb 100644 --- a/src/routes/auftrag.jsx +++ b/src/routes/auftrag.jsx @@ -1,9 +1,9 @@ import { PureComponent } from 'react'; -import { ManifestList } from '../components/manifest'; +import QuoteList from '../components/manifest/QuoteList'; class AuftragAngebotPage extends PureComponent { render() { - return ; + return ; } }