import { Component, createRef } from 'react'; import FillSpace from 'dashgui/FillSpace'; import Box from '@mui/material/Box'; import InputAdornment from '@mui/material/InputAdornment'; import List from '@mui/material/List'; import ListItemButton from '@mui/material/ListItemButton'; import ListItemText from '@mui/material/ListItemText'; import Paper from '@mui/material/Paper'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; import SearchIcon from '@mui/icons-material/Search'; import { StorageManagerContext, getStorageManager } from '../../api/storageManagerContext'; import BackendError from '../../components/BackendError'; import { formatPrice } from '../../utils/price'; import { buildSearchUrl, queryFromParams, readArtikelQuery, writeArtikelQuery, } from './artikelQuery'; import ResultsPagination from './ResultsPagination'; import { buildFilter, formatCategoryList, formatStock, getCategoryDepth, parseFilter, } from './searchFormat'; import { preserveSearchFocus } from './searchFocus'; import { artikelDetailPath } from './articleRoute'; import withSearchParams from './withSearchParams'; import { setLastArticleSku } from '../../automats'; import { BrandLabel } from '../../components/brand'; const resultRowSx = { alignItems: 'center', py: 1.5, // Keep every row one line so sample height matches real rows. '& .MuiListItemText-root': { minWidth: 0 }, '& .MuiListItemText-primary, & .MuiListItemText-secondary': { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', }, }; function ResultRowMeasureSample() { return ( SKU · Bestand 0 · Verfügbar 0 · Kategorie } slotProps={{ primary: { fontWeight: 500, noWrap: true } }} /> 0,00 € ); } function initialSearchState(searchParams) { const query = queryFromParams(searchParams); const stored = readArtikelQuery(); const input = query.target.trim().length >= 3 ? query.target : (stored?.target?.trim().length >= 3 ? stored.target : query.target); return { input, error: null, categoryFacets: null, totalCount: null, recordset: null, pageSize: null, }; } class ArtikelSearchPage extends Component { static contextType = StorageManagerContext; get storageManager() { return this.context || getStorageManager(); } state = initialSearchState(this.props.searchParams); listSlotRef = { current: null }; rowMeasureRef = { current: null }; paginationRef = createRef(); setListSlotRef = (node) => { if (this.listSlotRef.current === node) return; this.listSlotRef.current = node; if (!node) return; this.updatePageSize(); }; setRowMeasureRef = (node) => { if (this.rowMeasureRef.current === node) return; this.rowMeasureRef.current = node; if (!node) return; this.updatePageSize(); }; measureRowHeight = () => { // Always use the sample — real rows used to report a different height after paint // (wrapped text), which made resize "fix" a wrong first measure. const sample = this.rowMeasureRef.current; if (!sample) return { height: 0, source: 'none' }; return { height: sample.getBoundingClientRect().height, source: 'sample', }; }; updatePageSize = ({ fromResize = false } = {}) => { const slot = this.listSlotRef.current; if (!slot) return; // After the first measure, ignore layout churn from painting results. // Only window resize may change pageSize and refetch. if (this.state.pageSize != null && !fromResize) return; const slotTop = slot.getBoundingClientRect().top; const available = window.innerHeight - slotTop; const { height: rowHeight, source: rowSource } = this.measureRowHeight(); if (available < 1 || rowHeight < 1) return; // Outlined Paper around the list is 1px top + 1px bottom — not in row height. const listChrome = 2; const next = Math.max(1, Math.floor((available - listChrome) / rowHeight)); console.log('[pageSize] measure', { fromResize, prevPageSize: this.state.pageSize, next, available, rowHeight, rowSource, listChrome, slotTop, realRowHeight: slot.querySelector('[data-result-row]')?.getBoundingClientRect().height ?? null, }); if (next === this.state.pageSize) return; this.setState({ pageSize: next }, () => { const target = this.props.searchParams.get('target') ?? ''; if (target) this.fetchResults(); }); }; handleWindowResize = () => { this.updatePageSize({ fromResize: true }); }; persistQuery = () => { const query = queryFromParams(this.props.searchParams); const target = query.target || this.state.input; writeArtikelQuery({ target, filter: query.filter, offset: query.offset, }); }; componentDidMount() { document.addEventListener('mousedown', preserveSearchFocus, true); window.addEventListener('resize', this.handleWindowResize); setLastArticleSku(null); // Kick off initial search (empty input = show all products). const { input } = this.state; this.storageManager?.doSearch({ target: input, filter: this.props.searchParams.get('filter') || undefined, offset: 0, maxCount: this.state.pageSize ?? 20, onUpdate: (result) => this.setState(result), }); } componentWillUnmount() { document.removeEventListener('mousedown', preserveSearchFocus, true); window.removeEventListener('resize', this.handleWindowResize); this.persistQuery(); } componentDidUpdate(prevProps) { const { searchParams } = this.props; const prev = prevProps.searchParams; const target = searchParams.get('target') ?? ''; const prevTarget = prev.get('target') ?? ''; if (target !== prevTarget && target !== this.state.input) { this.setState({ input: target }); } if ( target !== prevTarget || searchParams.get('filter') !== prev.get('filter') || searchParams.get('offset') !== prev.get('offset') ) { this.fetchResults(); } } updateParams = (nextTarget, nextFilter, nextOffset = 0) => { writeArtikelQuery({ target: nextTarget, filter: nextFilter, offset: nextOffset, }); this.props.navigate(buildSearchUrl(nextTarget, nextFilter, nextOffset), { replace: true, }); }; handleInputChange = (event) => { const value = event.target.value; this.setState({ input: value }); this.storageManager?.doSearch({ target: value, filter: this.props.searchParams.get('filter') || undefined, offset: 0, maxCount: this.state.pageSize ?? 20, onUpdate: (result) => this.setState(result), }); }; fetchResults = () => { const { target, filter, offset } = queryFromParams(this.props.searchParams); const trimmed = target.trim(); const { pageSize } = this.state; if (pageSize == null) return; writeArtikelQuery({ target, filter, offset }); // Delegate fetch + debounce to the global StorageManager. // The callback pushes results into local component state. this.storageManager?.doSearch({ target: trimmed, filter: filter || undefined, offset, maxCount: pageSize, onUpdate: (result) => this.setState(result), }); }; render() { const { input, error, categoryFacets, totalCount, recordset, pageSize } = this.state; const filterParam = this.props.searchParams.get('filter') ?? ''; const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || 0); const categories = parseFilter(filterParam); // Drive UI from input so hint vs results switch in one paint (URL lags one tick). const searchActive = input.trim().length > 0 || (recordset ?? []).length > 0; const rows = recordset ?? []; const facetEntries = categoryFacets ? Object.entries(categoryFacets) .filter(([, meta]) => meta?.name) .map(([id, { count, name }]) => ({ id, count, name, depth: getCategoryDepth(id, categoryFacets), })) .sort((a, b) => Number(a.id) - Number(b.id)) : []; const safePageSize = pageSize ?? 1; const pageCount = Math.max(1, Math.ceil((totalCount ?? 0) / safePageSize)); const currentPage = Math.floor(offset / safePageSize) + 1; const showPagination = rows.length > 0 && pageSize != null && totalCount != null && totalCount > pageSize; const handlePageChange = (_e, page) => { this.updateParams(input, filterParam, (page - 1) * safePageSize); }; return ( <> ), }, }} /> {error && ( )} {searchActive && ( {facetEntries.length > 0 && ( Kategorien {facetEntries.map(({ id, count, name, depth }) => { const selected = categories.includes(id); return ( e.preventDefault()} onClick={() => { const next = selected ? [] : [id]; this.updateParams(input, buildFilter(next), 0); }} sx={{ pl: 2 + depth * 1.5 }} > {selected && ( )} ); })} )} {/* Same column width as the list; invisible, zero layout footprint. */} {searchActive && !error && totalCount === 0 && ( Keine Artikel gefunden. )} {/* Always mount real pagination height (invisible until needed) so pageSize is correct. */} Einheit } > {rows.length > 0 && ( {rows.map((row, index) => ( e.preventDefault()} onClick={() => { this.persistQuery(); this.props.navigate(artikelDetailPath(row)); }} sx={resultRowSx} > {(row.brand?.name || row.brandName) && ( <> {' · '} )} {row.sku} {' · '} Bestand {formatStock(row.stock)} {' · '} Verfügbar {formatStock(row.available)} {row.categories ? ` · ${formatCategoryList(row.categories, categoryFacets ?? {})}` : ''} } slotProps={{ primary: { fontWeight: 500, noWrap: true } }} /> {row.price != null ? formatPrice(row.price) : '—'} ))} )} )} ); } } export default withSearchParams(ArtikelSearchPage);