1042 lines
34 KiB
JavaScript
1042 lines
34 KiB
JavaScript
// #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 (`<Row brand={item} item={item} index={index} />`, 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 (
|
|
<ListItemButton
|
|
tabIndex={-1}
|
|
divider
|
|
disabled
|
|
sx={{ ...resultRowSx, pointerEvents: 'none', userSelect: 'none', opacity: 0 }}
|
|
>
|
|
<Box sx={{ width: 28, height: 28, flexShrink: 0 }} />
|
|
<Typography variant="body2"> </Typography>
|
|
</ListItemButton>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<ListItemButton
|
|
type="button"
|
|
divider
|
|
data-result-row
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => onOpenArticle?.(row)}
|
|
sx={resultRowSx}
|
|
>
|
|
<Box
|
|
sx={{
|
|
width: 28,
|
|
height: 28,
|
|
flexShrink: 0,
|
|
borderRadius: 0.5,
|
|
overflow: 'hidden',
|
|
bgcolor: 'action.hover',
|
|
}}
|
|
>
|
|
{row.smallThumbBinary && (
|
|
<Box
|
|
component="img"
|
|
alt=""
|
|
src={`data:image/avif;base64,${row.smallThumbBinary}`}
|
|
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
|
/>
|
|
)}
|
|
</Box>
|
|
<Typography
|
|
variant="body2"
|
|
color="text.secondary"
|
|
sx={{ ...resultCellSx, minWidth: 28, textAlign: 'right' }}
|
|
title={
|
|
row.warehouses?.length > 0
|
|
? row.warehouses.map((w) => `${w.name || 'Lager'}: ${formatStock(w.stock)}`).join(' · ')
|
|
: undefined
|
|
}
|
|
>
|
|
{formatStock(row.stock)}
|
|
</Typography>
|
|
<Typography
|
|
variant="body2"
|
|
color="text.secondary"
|
|
sx={{ ...resultCellSx, minWidth: 84 }}
|
|
>
|
|
{row.sku}
|
|
</Typography>
|
|
{row.categories ? (
|
|
<Typography
|
|
variant="body2"
|
|
sx={{ ...resultCellSx, maxWidth: '20%', color: '#9aa0a6' }}
|
|
>
|
|
{formatCategoryList(row.categories, categoryFacets ?? {})}
|
|
</Typography>
|
|
) : null}
|
|
{(row.brand?.name || row.brandName) ? (
|
|
<Box
|
|
sx={{
|
|
...resultCellSx,
|
|
maxWidth: '16%',
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
color: '#1976d2',
|
|
fontWeight: 500,
|
|
'& *': { color: '#1976d2 !important' },
|
|
}}
|
|
>
|
|
<BrandLabel
|
|
brand={row.brand || { name: row.brandName, thumbHash: row.brandThumbHash }}
|
|
name={row.brand?.name || row.brandName}
|
|
size={14}
|
|
spacing={0.5}
|
|
typographyProps={{ variant: 'body2', color: 'primary.main', noWrap: true }}
|
|
/>
|
|
</Box>
|
|
) : null}
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 0.75,
|
|
minWidth: 0,
|
|
flex: 1,
|
|
}}
|
|
>
|
|
<Typography
|
|
variant="body2"
|
|
noWrap
|
|
sx={{ fontWeight: 500, minWidth: 0 }}
|
|
>
|
|
{row.name}
|
|
</Typography>
|
|
{showChildBadge && (
|
|
<Box
|
|
component="span"
|
|
title={`${childCount} Varianten`}
|
|
sx={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
px: 0.75,
|
|
py: 0.25,
|
|
borderRadius: 999,
|
|
bgcolor: SCRIM_LABEL,
|
|
color: 'common.white',
|
|
fontSize: 11,
|
|
fontWeight: 600,
|
|
lineHeight: 1.2,
|
|
whiteSpace: 'nowrap',
|
|
flexShrink: 0,
|
|
pointerEvents: 'none',
|
|
}}
|
|
>
|
|
{childCount}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
<Typography
|
|
variant="body2"
|
|
sx={{ fontWeight: 600, flexShrink: 0 }}
|
|
>
|
|
{price != null ? formatPrice(price) : '—'}
|
|
</Typography>
|
|
</ListItemButton>
|
|
);
|
|
}
|
|
}
|
|
// #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 (
|
|
<>
|
|
<Box
|
|
ref={this.filterHeaderRef}
|
|
sx={{
|
|
mb: 2,
|
|
display: 'flex',
|
|
gap: 1.5,
|
|
alignItems: 'center',
|
|
minHeight: 40,
|
|
flexWrap: 'wrap',
|
|
}}
|
|
>
|
|
<TextField
|
|
sx={{ maxWidth: 520, flexGrow: 1 }}
|
|
autoFocus
|
|
autoComplete="off"
|
|
placeholder="(EAN, ArtNr, Name)"
|
|
value={input}
|
|
onChange={this.handleInputChange}
|
|
slotProps={{
|
|
htmlInput: {
|
|
autoCapitalize: 'off',
|
|
autoCorrect: 'off',
|
|
},
|
|
input: {
|
|
sx: { height: 40 },
|
|
startAdornment: (
|
|
<InputAdornment position="start">
|
|
<SearchIcon color="action" />
|
|
</InputAdornment>
|
|
),
|
|
endAdornment: input ? (
|
|
<InputAdornment position="end">
|
|
<IconButton
|
|
size="small"
|
|
onClick={this.handleClearInput}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
title="Suche löschen"
|
|
aria-label="Suche löschen"
|
|
>
|
|
<ClearIcon fontSize="small" />
|
|
</IconButton>
|
|
</InputAdornment>
|
|
) : null,
|
|
},
|
|
}}
|
|
/>
|
|
<Button
|
|
variant={categoryFilterOpen ? 'contained' : 'outlined'}
|
|
color="inherit"
|
|
disabled={facetEntries.length === 0 && categories.length === 0}
|
|
onClick={this.toggleCategoryFilter}
|
|
startIcon={<FilterListIcon />}
|
|
endIcon={categoryFilterOpen ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
|
sx={{ height: 40, whiteSpace: 'nowrap', flexShrink: 0 }}
|
|
>
|
|
Kategorien
|
|
</Button>
|
|
<Button
|
|
variant={brandFilterOpen ? 'contained' : 'outlined'}
|
|
color="inherit"
|
|
disabled={brandFacetEntries.length === 0 && selectedBrands.length === 0}
|
|
onClick={this.toggleBrandFilter}
|
|
startIcon={<FilterListIcon />}
|
|
endIcon={brandFilterOpen ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
|
sx={{ height: 40, whiteSpace: 'nowrap', flexShrink: 0 }}
|
|
>
|
|
Marken
|
|
</Button>
|
|
<Button
|
|
variant={supplierFilterOpen ? 'contained' : 'outlined'}
|
|
color="inherit"
|
|
disabled={supplierFacetEntries.length === 0 && selectedSuppliers.length === 0}
|
|
onClick={this.toggleSupplierFilter}
|
|
startIcon={<FilterListIcon />}
|
|
endIcon={supplierFilterOpen ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
|
sx={{ height: 40, whiteSpace: 'nowrap', flexShrink: 0 }}
|
|
>
|
|
Lieferanten
|
|
</Button>
|
|
|
|
{categories.map((catId) => {
|
|
const label = getCategoryBreadcrumbLabel(
|
|
catId,
|
|
categoriesMap,
|
|
rootCategoryId,
|
|
categoryFacets,
|
|
);
|
|
return (
|
|
<Chip
|
|
key={`cat-${catId}`}
|
|
label={label}
|
|
color="primary"
|
|
variant="outlined"
|
|
onDelete={() => {
|
|
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 (
|
|
<Chip
|
|
key={`brand-${brandId}`}
|
|
label={label}
|
|
color="primary"
|
|
variant="outlined"
|
|
onDelete={() => {
|
|
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 (
|
|
<Chip
|
|
key={`sup-${supplierId}`}
|
|
label={label}
|
|
color="primary"
|
|
variant="outlined"
|
|
onDelete={() => {
|
|
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 }}
|
|
/>
|
|
);
|
|
})}
|
|
</Box>
|
|
|
|
{error && (
|
|
<BackendError
|
|
error={error}
|
|
sx={{ mb: 2 }}
|
|
onRetry={this.fetchResults}
|
|
/>
|
|
)}
|
|
|
|
<Box sx={{ display: 'flex', gap: 3, alignItems: 'stretch', mb: -4 }}>
|
|
{categoryFilterOpen && facetEntries.length > 0 && (
|
|
<Box
|
|
sx={{
|
|
width: 240,
|
|
flexShrink: 0,
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
<Paper
|
|
variant="outlined"
|
|
sx={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
boxSizing: 'border-box',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
overflow: 'hidden',
|
|
borderRadius: 4,
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
px: 2,
|
|
py: 1.5,
|
|
bgcolor: 'action.hover',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
|
Kategorien
|
|
</Typography>
|
|
<IconButton
|
|
size="small"
|
|
onClick={this.toggleCategoryFilter}
|
|
title="Kategorien einklappen"
|
|
aria-label="Kategorien einklappen"
|
|
>
|
|
<ChevronLeftIcon fontSize="small" />
|
|
</IconButton>
|
|
</Box>
|
|
<List dense disablePadding sx={{ flexGrow: 1, overflowY: 'auto' }}>
|
|
{facetEntries.map(({ id, count, name, depth }) => {
|
|
const selected = categories.includes(id);
|
|
return (
|
|
<ListItemButton
|
|
key={id}
|
|
selected={selected}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => {
|
|
const next = selected ? [] : [id];
|
|
this.updateParams(
|
|
input,
|
|
buildFilter(next),
|
|
0,
|
|
buildFilter(selectedBrands),
|
|
buildFilter(selectedSuppliers),
|
|
);
|
|
}}
|
|
sx={{ pl: 2 + depth * 1.5 }}
|
|
>
|
|
<ListItemText
|
|
primary={`${name} (${count})`}
|
|
slotProps={{ primary: { variant: 'body2' } }}
|
|
/>
|
|
{selected && (
|
|
<ClearIcon fontSize="small" color="action" sx={{ ml: 1, flexShrink: 0 }} />
|
|
)}
|
|
</ListItemButton>
|
|
);
|
|
})}
|
|
</List>
|
|
</Paper>
|
|
</Box>
|
|
)}
|
|
|
|
{brandFilterOpen && brandFacetEntries.length > 0 && (
|
|
<Box
|
|
sx={{
|
|
width: 240,
|
|
flexShrink: 0,
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
<Paper
|
|
variant="outlined"
|
|
sx={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
boxSizing: 'border-box',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
overflow: 'hidden',
|
|
borderRadius: 4,
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
px: 2,
|
|
py: 1.5,
|
|
bgcolor: 'action.hover',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
|
Marken
|
|
</Typography>
|
|
<IconButton
|
|
size="small"
|
|
onClick={this.toggleBrandFilter}
|
|
title="Marken einklappen"
|
|
aria-label="Marken einklappen"
|
|
>
|
|
<ChevronLeftIcon fontSize="small" />
|
|
</IconButton>
|
|
</Box>
|
|
<List dense disablePadding sx={{ flexGrow: 1, overflowY: 'auto' }}>
|
|
{brandFacetEntries.map(({ id, count, name, brand }) => {
|
|
const selected = selectedBrands.includes(String(id));
|
|
return (
|
|
<ListItemButton
|
|
key={id}
|
|
selected={selected}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => {
|
|
const next = selected ? [] : [String(id)];
|
|
this.updateParams(
|
|
input,
|
|
buildFilter(categories),
|
|
0,
|
|
buildFilter(next),
|
|
buildFilter(selectedSuppliers),
|
|
);
|
|
}}
|
|
sx={{ pl: 2 }}
|
|
>
|
|
{brand ? (
|
|
<Box sx={{ mr: 1, display: 'inline-flex', alignItems: 'center' }}>
|
|
<BrandThumb brand={brand} size={18} />
|
|
</Box>
|
|
) : null}
|
|
<ListItemText
|
|
primary={`${name} (${count})`}
|
|
slotProps={{ primary: { variant: 'body2', noWrap: true } }}
|
|
/>
|
|
{selected && (
|
|
<ClearIcon fontSize="small" color="action" sx={{ ml: 1, flexShrink: 0 }} />
|
|
)}
|
|
</ListItemButton>
|
|
);
|
|
})}
|
|
</List>
|
|
</Paper>
|
|
</Box>
|
|
)}
|
|
|
|
{supplierFilterOpen && supplierFacetEntries.length > 0 && (
|
|
<Box
|
|
sx={{
|
|
width: 240,
|
|
flexShrink: 0,
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
<Paper
|
|
variant="outlined"
|
|
sx={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
boxSizing: 'border-box',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
overflow: 'hidden',
|
|
borderRadius: 4,
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
px: 2,
|
|
py: 1.5,
|
|
bgcolor: 'action.hover',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
|
Lieferanten
|
|
</Typography>
|
|
<IconButton
|
|
size="small"
|
|
onClick={this.toggleSupplierFilter}
|
|
title="Lieferanten einklappen"
|
|
aria-label="Lieferanten einklappen"
|
|
>
|
|
<ChevronLeftIcon fontSize="small" />
|
|
</IconButton>
|
|
</Box>
|
|
<List dense disablePadding sx={{ flexGrow: 1, overflowY: 'auto' }}>
|
|
{supplierFacetEntries.map(({ id, count, name }) => {
|
|
const selected = selectedSuppliers.includes(String(id));
|
|
return (
|
|
<ListItemButton
|
|
key={id}
|
|
selected={selected}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => {
|
|
const next = selected ? [] : [String(id)];
|
|
this.updateParams(
|
|
input,
|
|
buildFilter(categories),
|
|
0,
|
|
buildFilter(selectedBrands),
|
|
buildFilter(next),
|
|
);
|
|
}}
|
|
sx={{ pl: 2 }}
|
|
>
|
|
<ListItemText
|
|
primary={`${name} (${count})`}
|
|
slotProps={{ primary: { variant: 'body2', noWrap: true } }}
|
|
/>
|
|
{selected && (
|
|
<ClearIcon fontSize="small" color="action" sx={{ ml: 1, flexShrink: 0 }} />
|
|
)}
|
|
</ListItemButton>
|
|
);
|
|
})}
|
|
</List>
|
|
</Paper>
|
|
</Box>
|
|
)}
|
|
|
|
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
|
{!error && input.trim().length > 0 && totalCount === 0 && (
|
|
<Typography color="text.secondary">Keine Artikel gefunden.</Typography>
|
|
)}
|
|
|
|
{/* Same tile pattern as /artikel/marken: the FillSpace pagination
|
|
header + rows render on a PanelTile surface. */}
|
|
<PanelTile minHeight={0} sx={{ pb: 0 }}>
|
|
<FillSpace
|
|
ref={this.fillSpaceRef}
|
|
aboveRef={this.filterHeaderRef}
|
|
items={rows}
|
|
renderItem={ResultRow}
|
|
reserveBottom={32}
|
|
minPageSize={8}
|
|
minHeight={0}
|
|
virtualScrollbar
|
|
/>
|
|
</PanelTile>
|
|
</Box>
|
|
</Box>
|
|
</>
|
|
);
|
|
}
|
|
// #endregion
|
|
}
|
|
// #endregion
|
|
|
|
export default withSearchParams(ArtikelSearchPage);
|
|
|