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