u
This commit is contained in:
@@ -47,8 +47,10 @@ import {
|
||||
navigationAutomat,
|
||||
categoryAutomat,
|
||||
companyAutomat,
|
||||
brandAutomat,
|
||||
supplierAutomat,
|
||||
} from '../automats';
|
||||
import { BrandLabel } from '../components/brand';
|
||||
import { BrandLabel, BrandThumb } from '../components/brand';
|
||||
import { SCRIM_LABEL } from '../colors';
|
||||
// #endregion
|
||||
|
||||
@@ -266,8 +268,12 @@ function initialSearchState(searchParams) {
|
||||
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,
|
||||
@@ -313,7 +319,27 @@ class ArtikelSearchPage extends Component {
|
||||
|
||||
// #region Query Persistence
|
||||
toggleCategoryFilter = () => {
|
||||
this.setState((prev) => ({ categoryFilterOpen: !prev.categoryFilterOpen }));
|
||||
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) => {
|
||||
@@ -327,6 +353,8 @@ class ArtikelSearchPage extends Component {
|
||||
writeArtikelQuery({
|
||||
target,
|
||||
filter: query.filter,
|
||||
brand: query.brand,
|
||||
supplier: query.supplier,
|
||||
offset: query.offset,
|
||||
});
|
||||
};
|
||||
@@ -339,16 +367,30 @@ class ArtikelSearchPage extends Component {
|
||||
|
||||
// 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 } = queryFromParams(this.props.searchParams);
|
||||
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);
|
||||
const syncedUrl = buildSearchUrl(activeTarget, filter, offset, {
|
||||
brand: urlBrand,
|
||||
supplier: urlSupplier,
|
||||
});
|
||||
setLastSearchTerm(activeTarget, syncedUrl);
|
||||
this.props.navigate(syncedUrl, { replace: true });
|
||||
} else if (hasFilterInUrl) {
|
||||
@@ -357,10 +399,18 @@ class ArtikelSearchPage extends Component {
|
||||
setLastCategoryId(primaryCatId);
|
||||
}
|
||||
|
||||
setLastSearchTerm(activeTarget, buildSearchUrl(activeTarget, filter, offset));
|
||||
setLastSearchTerm(
|
||||
activeTarget,
|
||||
buildSearchUrl(activeTarget, filter, offset, {
|
||||
brand: urlBrand,
|
||||
supplier: urlSupplier,
|
||||
}),
|
||||
);
|
||||
void articleSearchAutomat.actions.search({
|
||||
target: activeTarget,
|
||||
filter,
|
||||
brand: urlBrand,
|
||||
supplier: urlSupplier,
|
||||
offset: 0,
|
||||
}).catch(() => {});
|
||||
}
|
||||
@@ -369,10 +419,20 @@ class ArtikelSearchPage extends Component {
|
||||
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));
|
||||
setLastSearchTerm(
|
||||
target,
|
||||
buildSearchUrl(target, query.filter, query.offset, {
|
||||
brand: query.brand,
|
||||
supplier: query.supplier,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
@@ -389,6 +449,8 @@ class ArtikelSearchPage extends Component {
|
||||
|
||||
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();
|
||||
@@ -397,13 +459,24 @@ class ArtikelSearchPage extends Component {
|
||||
// #endregion
|
||||
|
||||
// #region Search & Navigation Handlers
|
||||
updateParams = (nextTarget, nextFilter, nextOffset = 0) => {
|
||||
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);
|
||||
const nextUrl = buildSearchUrl(nextTarget, nextFilter, nextOffset, {
|
||||
brand: nextBrand,
|
||||
supplier: nextSupplier,
|
||||
});
|
||||
setLastSearchTerm(nextTarget, nextUrl);
|
||||
|
||||
// Synchronize category with navigationAutomat and /kategorien/
|
||||
@@ -427,14 +500,16 @@ class ArtikelSearchPage extends Component {
|
||||
handleInputChange = (event) => {
|
||||
const value = event.target.value;
|
||||
this.setState({ input: value });
|
||||
const { filter } = queryFromParams(this.props.searchParams);
|
||||
const currentUrl = buildSearchUrl(value, filter, 0);
|
||||
const { filter, brand, supplier } = queryFromParams(this.props.searchParams);
|
||||
const currentUrl = buildSearchUrl(value, filter, 0, { brand, supplier });
|
||||
setLastSearchTerm(value, currentUrl);
|
||||
writeArtikelQuery({ target: value, filter, offset: 0 });
|
||||
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(() => {});
|
||||
};
|
||||
@@ -444,13 +519,15 @@ class ArtikelSearchPage extends Component {
|
||||
};
|
||||
|
||||
fetchResults = () => {
|
||||
const { target, filter, offset } = queryFromParams(this.props.searchParams);
|
||||
const { target, filter, brand, supplier, offset } = queryFromParams(this.props.searchParams);
|
||||
|
||||
writeArtikelQuery({ target, filter, offset });
|
||||
writeArtikelQuery({ target, filter, brand, supplier, offset });
|
||||
|
||||
void articleSearchAutomat.actions.search({
|
||||
target: target.trim(),
|
||||
filter,
|
||||
brand,
|
||||
supplier,
|
||||
offset,
|
||||
}).catch(() => {});
|
||||
};
|
||||
@@ -461,13 +538,22 @@ class ArtikelSearchPage extends Component {
|
||||
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;
|
||||
@@ -503,6 +589,20 @@ class ArtikelSearchPage extends Component {
|
||||
})
|
||||
: [];
|
||||
|
||||
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;
|
||||
@@ -517,6 +617,7 @@ class ArtikelSearchPage extends Component {
|
||||
gap: 1.5,
|
||||
alignItems: 'center',
|
||||
minHeight: 40,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
@@ -557,7 +658,7 @@ class ArtikelSearchPage extends Component {
|
||||
<Button
|
||||
variant={categoryFilterOpen ? 'contained' : 'outlined'}
|
||||
color="inherit"
|
||||
disabled={facetEntries.length === 0}
|
||||
disabled={facetEntries.length === 0 && categories.length === 0}
|
||||
onClick={this.toggleCategoryFilter}
|
||||
startIcon={<FilterListIcon />}
|
||||
endIcon={categoryFilterOpen ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
@@ -565,6 +666,29 @@ class ArtikelSearchPage extends Component {
|
||||
>
|
||||
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,
|
||||
@@ -574,13 +698,71 @@ class ArtikelSearchPage extends Component {
|
||||
);
|
||||
return (
|
||||
<Chip
|
||||
key={catId}
|
||||
key={`cat-${catId}`}
|
||||
label={label}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
onDelete={() => {
|
||||
const next = categories.filter((id) => id !== catId);
|
||||
this.updateParams(input, buildFilter(next), 0);
|
||||
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 }}
|
||||
/>
|
||||
@@ -602,8 +784,6 @@ class ArtikelSearchPage extends Component {
|
||||
sx={{
|
||||
width: 240,
|
||||
flexShrink: 0,
|
||||
// No intrinsic height: the results tile drives the row height
|
||||
// and the absolutely-filled panel matches it exactly.
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
@@ -616,8 +796,6 @@ class ArtikelSearchPage extends Component {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
// Match PanelTile (PANEL_TILE_BORDER_RADIUS: borderRadius 4 →
|
||||
// theme.shape.borderRadius × 4 = 16px) so both columns line up.
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
@@ -654,7 +832,13 @@ class ArtikelSearchPage extends Component {
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
const next = selected ? [] : [id];
|
||||
this.updateParams(input, buildFilter(next), 0);
|
||||
this.updateParams(
|
||||
input,
|
||||
buildFilter(next),
|
||||
0,
|
||||
buildFilter(selectedBrands),
|
||||
buildFilter(selectedSuppliers),
|
||||
);
|
||||
}}
|
||||
sx={{ pl: 2 + depth * 1.5 }}
|
||||
>
|
||||
@@ -673,6 +857,167 @@ class ArtikelSearchPage extends Component {
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user