// #region Imports import { Component, createRef } from 'react'; import FillSpace from 'dashgui/FillSpace'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Chip from '@mui/material/Chip'; import IconButton from '@mui/material/IconButton'; 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 ClearIcon from '@mui/icons-material/Clear'; import FilterListIcon from '@mui/icons-material/FilterList'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import articleSearchAutomat from '../automats/articleSearchAutomat'; import BackendError from '../components/BackendError'; import { formatPrice, getArticleGross } from '../utils/price'; import { buildSearchUrl, queryFromParams, readArtikelQuery, writeArtikelQuery, } from '../components/article/artikelQuery'; import ResultsPagination from '../components/article/ResultsPagination'; import { buildFilter, formatCategoryList, formatStock, getCategoryDepth, getCategoryBreadcrumbLabel, parseFilter, } from '../components/article/searchFormat'; import { preserveSearchFocus } from '../components/article/searchFocus'; import { artikelDetailPath } from '../components/article/articleRoute'; import { buildCategoryPath } from '../components/category/categoryRoute'; import withSearchParams from '../components/article/withSearchParams'; import { setLastArticleSku, setLastSearchTerm, setLastCategoryId, navigationAutomat, categoryAutomat, companyAutomat, } from '../automats'; import { BrandLabel } from '../components/brand'; import { SCRIM_LABEL } from '../colors'; // #endregion // #region Styles & Measure Sample const resultRowSx = { alignItems: 'center', minHeight: 64, maxHeight: 64, height: 64, boxSizing: 'border-box', py: 0, // Keep every row one line so sample height matches real rows. '& .MuiListItemText-root': { minWidth: 0, my: 0 }, '& .MuiListItemText-primary': { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: '22px', height: '22px', display: 'flex', alignItems: 'center', }, '& .MuiListItemText-secondary': { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: '20px', height: '20px', display: 'flex', alignItems: 'center', }, }; function ResultRowMeasureSample() { return (   } slotProps={{ primary: { fontWeight: 500, noWrap: true } }} />   ); } // #endregion // #region Helpers function initialSearchState(searchParams) { const query = queryFromParams(searchParams); const stored = readArtikelQuery(); const navTerm = navigationAutomat.state.lastSearchTerm; const input = query.target.trim().length > 0 ? query.target : (navTerm?.trim().length > 0 ? navTerm : (stored?.target?.trim().length >= 3 ? stored.target : '')); const automatResult = articleSearchAutomat.state.result; return { input, categoryFilterOpen: false, error: automatResult.error, categoryFacets: automatResult.categoryFacets, totalCount: automatResult.totalCount, recordset: automatResult.recordset, pageSize: automatResult.pageSize, pageCount: automatResult.pageCount, currentPage: automatResult.currentPage, offset: automatResult.offset, }; } function getCategoryTreeOrder(categoriesMap, rootCategoryId) { const order = new Map(); let index = 0; function traverse(parentId) { const parentKey = String(parentId); const children = Object.values(categoriesMap || {}) .filter((item) => item && item.parentId != null && String(item.parentId) === parentKey) .sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || Number(a.id) - Number(b.id)); for (const child of children) { order.set(String(child.id), index++); traverse(child.id); } } const rootId = rootCategoryId ?? 1; traverse(rootId); return order; } // #endregion // #region ArtikelSearchPage class ArtikelSearchPage extends Component { // #region Constructor & State constructor(props) { super(props); this.state = initialSearchState(props.searchParams); this.paginationRef = createRef(); this.filterHeaderRef = createRef(); this.fillSpaceRef = createRef(); this.categoryFillSpaceRef = createRef(); } // #endregion // #region Measurement & Query Persistence toggleCategoryFilter = () => { this.setState((prev) => ({ categoryFilterOpen: !prev.categoryFilterOpen })); }; handleMeasurement = (count) => { if (!count || count === this.state.pageSize) return; this.setState({ pageSize: count }); const { filter, offset } = queryFromParams(this.props.searchParams); const activeTarget = this.state.input; setLastSearchTerm(activeTarget, buildSearchUrl(activeTarget, filter, offset)); void articleSearchAutomat.actions.search({ target: activeTarget, filter, offset, pageSize: count, }).catch(() => {}); }; persistQuery = () => { const query = queryFromParams(this.props.searchParams); const target = this.state.input; writeArtikelQuery({ target, filter: query.filter, offset: query.offset, }); }; // #endregion // #region Lifecycle componentDidMount() { document.addEventListener('mouseup', preserveSearchFocus, true); setLastArticleSku(null); // Push search results into local component state. this.unsubSearch = articleSearchAutomat.subscribe(this, (state) => state.result); // Initial search: kicks off index loading if idle and derives current items const { filter: urlFilter, offset } = queryFromParams(this.props.searchParams); const hasFilterInUrl = this.props.searchParams.has('filter'); const navCategory = navigationAutomat.state.lastCategoryId; const filter = hasFilterInUrl ? urlFilter : (navCategory || urlFilter); const activeTarget = this.state.input; if (!hasFilterInUrl && navCategory) { const syncedUrl = buildSearchUrl(activeTarget, filter, offset); setLastSearchTerm(activeTarget, syncedUrl); this.props.navigate(syncedUrl, { replace: true }); } else if (hasFilterInUrl) { const selectedCats = parseFilter(urlFilter); const primaryCatId = selectedCats.length > 0 ? selectedCats[0] : null; setLastCategoryId(primaryCatId); } setLastSearchTerm(activeTarget, buildSearchUrl(activeTarget, filter, offset)); void articleSearchAutomat.actions.search({ target: activeTarget, filter, offset, pageSize: this.state.pageSize, }).catch(() => {}); } componentWillUnmount() { document.removeEventListener('mouseup', preserveSearchFocus, true); this.unsubSearch?.(); this.unsubSearch = null; this.persistQuery(); const query = queryFromParams(this.props.searchParams); const target = this.state.input; setLastSearchTerm(target, buildSearchUrl(target, query.filter, query.offset)); } componentDidUpdate(prevProps, prevState) { const { searchParams } = this.props; const prev = prevProps.searchParams; const target = searchParams.get('target') ?? ''; const prevTarget = prev.get('target') ?? ''; if ( this.state.categoryFilterOpen !== prevState?.categoryFilterOpen || searchParams.get('filter') !== prev.get('filter') ) { requestAnimationFrame(() => { this.fillSpaceRef.current?.measure(); this.categoryFillSpaceRef.current?.measure(); }); } if (target !== prevTarget && target !== this.state.input) { this.setState({ input: target }); this.fetchResults(); return; } if ( searchParams.get('filter') !== prev.get('filter') || searchParams.get('offset') !== prev.get('offset') ) { this.fetchResults(); } } // #endregion // #region Search & Navigation Handlers updateParams = (nextTarget, nextFilter, nextOffset = 0) => { writeArtikelQuery({ target: nextTarget, filter: nextFilter, offset: nextOffset, }); const nextUrl = buildSearchUrl(nextTarget, nextFilter, nextOffset); setLastSearchTerm(nextTarget, nextUrl); // Synchronize category with navigationAutomat and /kategorien/ const selectedCats = parseFilter(nextFilter); const primaryCatId = selectedCats.length > 0 ? selectedCats[0] : null; const categoriesMap = categoryAutomat.state?.categories; const rootCategoryId = categoryAutomat.state?.rootCategoryId; const isRoot = !primaryCatId || (rootCategoryId != null && String(primaryCatId) === String(rootCategoryId)); const activeId = isRoot ? null : String(primaryCatId); const catPath = activeId && categoriesMap && rootCategoryId != null ? buildCategoryPath(categoriesMap, rootCategoryId, activeId) : '/kategorien'; setLastCategoryId(activeId, catPath); this.props.navigate(nextUrl, { replace: true, }); }; handleInputChange = (event) => { const value = event.target.value; this.setState({ input: value }); const { filter } = queryFromParams(this.props.searchParams); const { pageSize } = this.state; const currentUrl = buildSearchUrl(value, filter, 0); setLastSearchTerm(value, currentUrl); writeArtikelQuery({ target: value, filter, offset: 0 }); this.props.navigate(currentUrl, { replace: true }); void articleSearchAutomat.actions.search({ target: value, filter, offset: 0, pageSize: pageSize ?? undefined, }).catch(() => {}); }; handleClearInput = () => { this.handleInputChange({ target: { value: '' } }); }; fetchResults = () => { const { target, filter, offset } = queryFromParams(this.props.searchParams); const { pageSize } = this.state; writeArtikelQuery({ target, filter, offset }); void articleSearchAutomat.actions.search({ target: target.trim(), filter, offset, pageSize: pageSize ?? undefined, }).catch(() => {}); }; handlePageChange = (_e, page) => { const { pageSize } = this.state; const safePageSize = pageSize ?? 1; const nextOffset = (page - 1) * safePageSize; const filterParam = this.props.searchParams.get('filter') ?? ''; this.updateParams(this.state.input, filterParam, nextOffset); }; // #endregion // #region Render render() { const { input, categoryFilterOpen, error, categoryFacets, totalCount, recordset, pageSize, pageCount, currentPage, offset: stateOffset, } = this.state; const filterParam = this.props.searchParams.get('filter') ?? ''; const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || stateOffset || 0); const categories = parseFilter(filterParam); const rows = recordset ?? []; const categoriesMap = categoryAutomat.state?.categories; const rootCategoryId = categoryAutomat.state?.rootCategoryId; const treeOrder = getCategoryTreeOrder(categoriesMap, rootCategoryId); const facetEntries = categoryFacets ? Object.entries(categoryFacets) .filter(([id, meta]) => { if (!meta?.name) return false; if (String(id) === '1' || (rootCategoryId != null && String(id) === String(rootCategoryId))) { return false; } const lower = meta.name.trim().toLowerCase(); if (lower === 'kategorie 1' || lower === 'category 1' || lower === 'home') { return false; } return true; }) .map(([id, { count, name }]) => ({ id, count, name, depth: getCategoryDepth(id, categoryFacets), })) .sort((a, b) => { const orderA = treeOrder.get(String(a.id)); const orderB = treeOrder.get(String(b.id)); if (orderA != null && orderB != null) return orderA - orderB; if (orderA != null) return -1; if (orderB != null) return 1; return Number(a.id) - Number(b.id); }) : []; const showPagination = rows.length > 0 && pageSize != null && totalCount != null && totalCount > pageSize; return ( <> ), endAdornment: input ? ( e.preventDefault()} title="Suche löschen" aria-label="Suche löschen" > ) : null, }, }} /> {categories.map((catId) => { const label = getCategoryBreadcrumbLabel( catId, categoriesMap, rootCategoryId, categoryFacets, ); return ( { const next = categories.filter((id) => id !== catId); this.updateParams(input, buildFilter(next), 0); }} sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }} /> ); })} {error && ( )} {categoryFilterOpen && facetEntries.length > 0 && ( Kategorien {facetEntries.map(({ id, count, name, depth }) => { const selected = categories.includes(id); return ( e.preventDefault()} onClick={() => { const next = selected ? [] : [id]; this.setState({ categoryFilterOpen: false }); this.updateParams(input, buildFilter(next), 0); }} sx={{ pl: 2 + depth * 1.5 }} > {selected && ( )} ); })} )} {!error && input.trim().length > 0 && totalCount === 0 && ( Keine Artikel gefunden. )} {/* Always mount real pagination height (invisible until needed) so pageSize is correct. */} } > {rows.length > 0 && ( {rows.map((row, index) => { const isParent = Boolean(row.isParent || row.is_parent); const childCount = Number(row.childCount) || 0; const showChildBadge = isParent || childCount > 0; const price = getArticleGross(row, companyAutomat.state.current?.taxrate); return ( e.preventDefault()} onClick={() => { this.persistQuery(); this.props.navigate(artikelDetailPath(row)); }} sx={resultRowSx} > {row.name} {showChildBadge && ( {childCount} )} } secondary={ {(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 } }} /> {price != null ? formatPrice(price) : '—'} ); })} )} ); } // #endregion } // #endregion export default withSearchParams(ArtikelSearchPage);