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 PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; 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 SkeleHashImage from '../settings/SkeleHashImage'; import quoteAutomat, { CHUNK_SIZE } from '../../automats/quoteAutomat'; import numberAutomat from '../../automats/numberAutomat'; import { quote as quoteApi } from '../../api'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; import { formatPrice } from '../../utils/price'; import { getMaxDocumentNumber } from '../../utils/documentNumber'; import withNavigate from '../withNavigate'; import { formatDate, loadParties, manifestConfig, 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'); // Dummy quote generator using the active sequence scheme with max digits for layout testing export function createDummyTestQuote() { const maxNumber = getMaxDocumentNumber('quote'); return { id: 999999, number: maxNumber, date: '2030-10-30T12:00:00Z', customerName: 'Kunde', positions: 999, grossTotal: 30000.90, files: [ { id: 99991, manifestId: 999999, fileId: 99991, name: `angebot-${maxNumber}.pdf`, mimetype: 'application/pdf', size: 2701, thumbx: 141, thumby: 200, skeledash: '6w5VujjmxhSpoO8VSwebe2yXU3Tj4108PysbXPqoHmyYcs9oXH8z6npbrjwK90StUBOXY0LLPiDgZy9qjYjkFDVKSjUp3dcWQ', }, { id: 99992, manifestId: 999999, fileId: 99992, name: 'datenblatt-muster.pdf', mimetype: 'application/pdf', size: 15420, thumbx: 141, thumby: 200, skeledash: '6w5VujjmxhSpoO8VSwebe2yXU3Tj4108PysbXPqoHmyYcs9oXH8z6npbrjwK90StUBOXY0LLPiDgZy9qjYjkFDVKSjUp3dcWQ', }, ], }; } const rowGridSx = { display: 'grid', gridTemplateColumns: 'max-content max-content 1fr max-content max-content max-content auto', 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', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', userSelect: 'none', }; function renderInvisibleDummyRow(dummy) { const d = dummy || createDummyTestQuote(); return ( <> {d.number} {d.date ? formatDate(d.date) : '–'} {d.customerName} {(d.files || []).map((file, i) => ( ))} {formatPrice(d.grossTotal)} ); } /** * Ambient context for `QuoteRow`: FillSpace renders rows as bare elements * (``), so the list publishes callbacks and the dummy quote here on every render. */ const rowContext = { partiesById: {}, busyId: null, onOpen: null, onDelete: null, headerItem: 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; const dummy = this.props.headerItem || rowContext.headerItem; // Data-free probe (FillSpace measures the row height with `item` undefined). if (!quote) { if (disabled) { return (   ); } return ( {renderInvisibleDummyRow(dummy)} ); } 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` : ''; const files = Array.isArray(quote.files) ? quote.files : []; return ( onOpen?.(quote)} disabled={disabled} > {renderInvisibleDummyRow(dummy)} {quote.number} {quote.date ? formatDate(quote.date) : '–'} {customer} 0 ? 'text.primary' : 'text.disabled', '& .MuiChip-label': { px: 0.5 }, }} /> {/* Files / Attachments column after Pos.: space for 2 per row, 2 lines max (up to 4 thumbnails) */} {files.slice(0, 4).map((file, index) => { const fileId = file.fileId ?? file.id; const title = file.name ? `${file.name} (Öffnen)` : 'Datei öffnen'; return ( { e.stopPropagation(); if (quote.id != null && fileId != null) { window.open(`/api/quote/${encodeURIComponent(quote.id)}/file/${encodeURIComponent(fileId)}`, '_blank'); } else { onOpen?.(quote); } }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); e.preventDefault(); if (quote.id != null && fileId != null) { window.open(`/api/quote/${encodeURIComponent(quote.id)}/file/${encodeURIComponent(fileId)}`, '_blank'); } } }} sx={{ width: 15, height: 20, flexShrink: 0, borderRadius: 0.5, overflow: 'hidden', bgcolor: 'background.paper', border: '1px solid', borderColor: 'divider', boxShadow: '0 1px 2px rgba(0,0,0,0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', transition: 'transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease', '&:hover': { boxShadow: '0 2px 6px rgba(0,0,0,0.25)', borderColor: 'primary.main', transform: 'scale(1.3)', zIndex: 3, }, }} > {file.skeledash ? ( ) : ( )} ); })} {gross > 0 ? formatPrice(gross) : '—'} { e.stopPropagation(); onDelete?.(quote); }} onMouseDown={(e) => e.stopPropagation()} sx={{ width: 40, height: 40 }} > ); } } // 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.headerItem = createDummyTestQuote(); 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), })); this.unsubNumber = numberAutomat.subscribe(this, () => { this.headerItem = createDummyTestQuote(); this.forceUpdate(); }); void numberAutomat.actions.ensureLoaded().catch(() => { }); void this.loadParties(); } componentWillUnmount() { this._cancelled = true; this.unsub?.(); this.unsubNumber?.(); } 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) { if (quote.id === 999999) { enqueueSnackbar(`Test-Angebot „${quote.number}“ angeklickt.`, { variant: 'info' }); return; } this.props.navigate(cfg.editPath(quote.id)); } async handleDelete(quote) { if (quote.id === 999999) { enqueueSnackbar('Test-Angebot kann nicht gelöscht werden.', { variant: 'info' }); return; } 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; rowContext.headerItem = this.headerItem; if (items === null && quoteAutomat.state.status === 'failed') { return ( <> ); } return ( <> {renderInvisibleDummyRow(this.headerItem)} Nr. Datum Kunde Pos. Gesamt } onPageChange={this.handlePageChange} /> {items === null && ( )} ); } } export default withNavigate(QuoteList);