// #region Imports import { Component, createRef } from 'react'; import FillSpace from 'dashgui/FillSpace'; import { PanelTile } from 'dashgui/panels'; 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 { 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, brandAutomat, supplierAutomat, } from '../automats'; import { BrandLabel, BrandThumb } from '../components/brand'; import { SCRIM_LABEL } from '../colors'; // #endregion // #region Styles & Measure Sample const resultRowSx = { alignItems: 'center', minHeight: 40, maxHeight: 40, height: 40, boxSizing: 'border-box', py: 0, gap: 1.25, // Keep every row one line so sample height matches real rows. '& .MuiListItemText-root': { minWidth: 0, my: 0 }, }; const resultCellSx = { flexShrink: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', }; /** * Ambient context for the `renderItem` row. FillSpace renders rows as a bare * element (``, probe: `item` * undefined) and cannot pass page callbacks, so the page publishes them here * on every render. */ const resultRowsContext = { categoryFacets: {}, onOpenArticle: null }; /** * One search-result row rendered by FillSpace via `renderItem`. A class * component so FillSpace renders it as an element (and can probe it with * `item` undefined to measure the row height). */ class ResultRow extends Component { render() { const { item: row } = this.props; const { categoryFacets, onOpenArticle } = resultRowsContext; // Data-free probe (`item` undefined): fixed-height placeholder so FillSpace // can measure the row without touching real data. if (!row) { return (   ); } 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={() => onOpenArticle?.(row)} sx={resultRowSx} title={row.sku} > {row.smallThumbBinary && ( )} 0 ? row.warehouses.map((w) => `${w.name || 'Lager'}: ${formatStock(w.stock)}`).join(' · ') : undefined } > {formatStock(row.stock)} {row.categories ? ( {formatCategoryList(row.categories, categoryFacets ?? {})} ) : null} {(row.brand?.name || row.brandName) ? ( ) : null} {row.name} {showChildBadge && ( {childCount} )} {price != null ? formatPrice(price) : '—'} ); } } // #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, brandFilterOpen: false, supplierFilterOpen: false, error: automatResult.error, categoryFacets: automatResult.categoryFacets, brandFacets: automatResult.brandFacets, supplierFacets: automatResult.supplierFacets, 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.filterHeaderRef = createRef(); this.fillSpaceRef = createRef(); } // #endregion // #region Query Persistence toggleCategoryFilter = () => { this.setState((prev) => ({ categoryFilterOpen: !prev.categoryFilterOpen, brandFilterOpen: false, supplierFilterOpen: false, })); }; toggleBrandFilter = () => { this.setState((prev) => ({ brandFilterOpen: !prev.brandFilterOpen, categoryFilterOpen: false, supplierFilterOpen: false, })); }; toggleSupplierFilter = () => { this.setState((prev) => ({ supplierFilterOpen: !prev.supplierFilterOpen, categoryFilterOpen: false, brandFilterOpen: false, })); }; handleOpenArticle = (row) => { this.persistQuery(); this.props.navigate(artikelDetailPath(row)); }; persistQuery = () => { const query = queryFromParams(this.props.searchParams); const target = this.state.input; writeArtikelQuery({ target, filter: query.filter, brand: query.brand, supplier: query.supplier, 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); this.unsubBrand = brandAutomat.subscribe(this, () => ({})); this.unsubSupplier = supplierAutomat.subscribe(this, () => ({})); void brandAutomat.actions.load().catch(() => {}); void supplierAutomat.actions.load().catch(() => {}); void supplierAutomat.actions.loadAllSupplierArticles().catch(() => {}); // Initial search: kicks off index loading if idle and derives current items const { filter: urlFilter, offset, brand: urlBrand, supplier: urlSupplier, } = 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, { brand: urlBrand, supplier: urlSupplier, }); 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, { brand: urlBrand, supplier: urlSupplier, }), ); void articleSearchAutomat.actions.search({ target: activeTarget, filter, brand: urlBrand, supplier: urlSupplier, offset: 0, }).catch(() => {}); } componentWillUnmount() { document.removeEventListener('mouseup', preserveSearchFocus, true); this.unsubSearch?.(); this.unsubSearch = null; this.unsubBrand?.(); this.unsubBrand = null; this.unsubSupplier?.(); this.unsubSupplier = null; this.persistQuery(); const query = queryFromParams(this.props.searchParams); const target = this.state.input; setLastSearchTerm( target, buildSearchUrl(target, query.filter, query.offset, { brand: query.brand, supplier: query.supplier, }), ); } 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 }); this.fetchResults(); return; } if ( searchParams.get('filter') !== prev.get('filter') || searchParams.get('brand') !== prev.get('brand') || searchParams.get('supplier') !== prev.get('supplier') || searchParams.get('offset') !== prev.get('offset') ) { this.fetchResults(); } } // #endregion // #region Search & Navigation Handlers updateParams = ( nextTarget, nextFilter, nextOffset = 0, nextBrand = this.props.searchParams.get('brand') ?? '', nextSupplier = this.props.searchParams.get('supplier') ?? '', ) => { writeArtikelQuery({ target: nextTarget, filter: nextFilter, brand: nextBrand, supplier: nextSupplier, offset: nextOffset, }); const nextUrl = buildSearchUrl(nextTarget, nextFilter, nextOffset, { brand: nextBrand, supplier: nextSupplier, }); 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, brand, supplier } = queryFromParams(this.props.searchParams); const currentUrl = buildSearchUrl(value, filter, 0, { brand, supplier }); setLastSearchTerm(value, currentUrl); writeArtikelQuery({ target: value, filter, brand, supplier, offset: 0 }); this.props.navigate(currentUrl, { replace: true }); void articleSearchAutomat.actions.search({ target: value, filter, brand, supplier, offset: 0, }).catch(() => {}); }; handleClearInput = () => { this.handleInputChange({ target: { value: '' } }); }; fetchResults = () => { const { target, filter, brand, supplier, offset } = queryFromParams(this.props.searchParams); writeArtikelQuery({ target, filter, brand, supplier, offset }); void articleSearchAutomat.actions.search({ target: target.trim(), filter, brand, supplier, offset, }).catch(() => {}); }; // #endregion // #region Render render() { const { input, categoryFilterOpen, brandFilterOpen, supplierFilterOpen, error, totalCount, categoryFacets, brandFacets, supplierFacets, recordset, } = this.state; const filterParam = this.props.searchParams.get('filter') ?? ''; const brandParam = this.props.searchParams.get('brand') ?? ''; const supplierParam = this.props.searchParams.get('supplier') ?? ''; const categories = parseFilter(filterParam); const selectedBrands = parseFilter(brandParam); const selectedSuppliers = parseFilter(supplierParam); 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 brandFacetEntries = brandFacets ? Object.values(brandFacets).sort((a, b) => { if (b.count !== a.count) return b.count - a.count; return String(a.name).localeCompare(String(b.name), 'de'); }) : []; const supplierFacetEntries = supplierFacets ? Object.values(supplierFacets).sort((a, b) => { if (b.count !== a.count) return b.count - a.count; return String(a.name).localeCompare(String(b.name), 'de'); }) : []; // Per-render values the static `renderItem` row reads through the context. resultRowsContext.categoryFacets = categoryFacets ?? {}; resultRowsContext.onOpenArticle = this.handleOpenArticle; 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, buildFilter(selectedBrands), buildFilter(selectedSuppliers), ); }} sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }} /> ); })} {selectedBrands.map((brandId) => { const label = brandFacets?.[brandId]?.name || brandAutomat.state.brands?.find((b) => String(b.id) === String(brandId) || b.name === brandId)?.name || brandId; return ( { const next = selectedBrands.filter((id) => id !== brandId); this.updateParams( input, buildFilter(categories), 0, buildFilter(next), buildFilter(selectedSuppliers), ); }} sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }} /> ); })} {selectedSuppliers.map((supplierId) => { const label = supplierFacets?.[supplierId]?.name || supplierAutomat.state.suppliers?.find((s) => String(s.id) === String(supplierId))?.name || `Lieferant #${supplierId}`; return ( { const next = selectedSuppliers.filter((id) => id !== supplierId); this.updateParams( input, buildFilter(categories), 0, buildFilter(selectedBrands), buildFilter(next), ); }} 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.updateParams( input, buildFilter(next), 0, buildFilter(selectedBrands), buildFilter(selectedSuppliers), ); }} sx={{ pl: 2 + depth * 1.5 }} > {selected && ( )} ); })} )} {brandFilterOpen && brandFacetEntries.length > 0 && ( Marken {brandFacetEntries.map(({ id, count, name, brand }) => { const selected = selectedBrands.includes(String(id)); return ( e.preventDefault()} onClick={() => { const next = selected ? [] : [String(id)]; this.updateParams( input, buildFilter(categories), 0, buildFilter(next), buildFilter(selectedSuppliers), ); }} sx={{ pl: 2 }} > {brand ? ( ) : null} {selected && ( )} ); })} )} {supplierFilterOpen && supplierFacetEntries.length > 0 && ( Lieferanten {supplierFacetEntries.map(({ id, count, name }) => { const selected = selectedSuppliers.includes(String(id)); return ( e.preventDefault()} onClick={() => { const next = selected ? [] : [String(id)]; this.updateParams( input, buildFilter(categories), 0, buildFilter(selectedBrands), buildFilter(next), ); }} sx={{ pl: 2 }} > {selected && ( )} ); })} )} {!error && input.trim().length > 0 && totalCount === 0 && ( Keine Artikel gefunden. )} {/* Same tile pattern as /artikel/marken: the FillSpace pagination header + rows render on a PanelTile surface. */} ); } // #endregion } // #endregion export default withSearchParams(ArtikelSearchPage);