// #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}
>
{row.smallThumbBinary && (
)}
0
? row.warehouses.map((w) => `${w.name || 'Lager'}: ${formatStock(w.stock)}`).join(' · ')
: undefined
}
>
{formatStock(row.stock)}
{row.sku}
{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,
},
}}
/>
}
endIcon={categoryFilterOpen ? : }
sx={{ height: 40, whiteSpace: 'nowrap', flexShrink: 0 }}
>
Kategorien
}
endIcon={brandFilterOpen ? : }
sx={{ height: 40, whiteSpace: 'nowrap', flexShrink: 0 }}
>
Marken
}
endIcon={supplierFilterOpen ? : }
sx={{ height: 40, whiteSpace: 'nowrap', flexShrink: 0 }}
>
Lieferanten
{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);