This commit is contained in:
seb
2026-10-05 09:17:04 +02:00
parent 8010436157
commit 5b4cf1683a
7 changed files with 493 additions and 221 deletions

View File

@@ -1,6 +1,9 @@
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';
@@ -10,6 +13,10 @@ 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 } from '../utils/price';
@@ -30,7 +37,7 @@ import {
import { preserveSearchFocus } from '../components/article/searchFocus';
import { artikelDetailPath } from '../components/article/articleRoute';
import withSearchParams from '../components/article/withSearchParams';
import { setLastArticleSku } from '../automats';
import { setLastArticleSku, setLastSearchTerm, navigationAutomat } from '../automats';
import { BrandLabel } from '../components/brand';
const resultRowSx = {
@@ -80,15 +87,19 @@ function ResultRowMeasureSample() {
function initialSearchState(searchParams) {
const query = queryFromParams(searchParams);
const stored = readArtikelQuery();
const navTerm = navigationAutomat.state.lastSearchTerm;
const input =
query.target.trim().length >= 3
query.target.trim().length > 0
? query.target
: (stored?.target?.trim().length >= 3 ? stored.target : 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,
@@ -103,13 +114,22 @@ function initialSearchState(searchParams) {
class ArtikelSearchPage extends Component {
state = initialSearchState(this.props.searchParams);
paginationRef = createRef();
filterHeaderRef = createRef();
fillSpaceRef = createRef();
categoryFillSpaceRef = createRef();
toggleCategoryFilter = () => {
this.setState((prev) => ({ categoryFilterOpen: !prev.categoryFilterOpen }));
};
handleMeasurement = (count) => {
if (!count || count === this.state.pageSize) return;
this.setState({ pageSize: count });
const { target, filter, offset } = queryFromParams(this.props.searchParams);
const { filter, offset } = queryFromParams(this.props.searchParams);
const activeTarget = this.state.input;
setLastSearchTerm(activeTarget, buildSearchUrl(activeTarget, filter, offset));
void articleSearchAutomat.actions.search({
target: target || this.state.input,
target: activeTarget,
filter,
offset,
pageSize: count,
@@ -118,7 +138,7 @@ class ArtikelSearchPage extends Component {
persistQuery = () => {
const query = queryFromParams(this.props.searchParams);
const target = query.target || this.state.input;
const target = this.state.input;
writeArtikelQuery({
target,
filter: query.filter,
@@ -132,6 +152,17 @@ class ArtikelSearchPage extends Component {
// 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, 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: this.state.pageSize,
}).catch(() => {});
}
componentWillUnmount() {
@@ -139,21 +170,35 @@ class ArtikelSearchPage extends Component {
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) {
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 (
target !== prevTarget
|| searchParams.get('filter') !== prev.get('filter')
searchParams.get('filter') !== prev.get('filter')
|| searchParams.get('offset') !== prev.get('offset')
) {
this.fetchResults();
@@ -166,7 +211,9 @@ class ArtikelSearchPage extends Component {
filter: nextFilter,
offset: nextOffset,
});
this.props.navigate(buildSearchUrl(nextTarget, nextFilter, nextOffset), {
const nextUrl = buildSearchUrl(nextTarget, nextFilter, nextOffset);
setLastSearchTerm(nextTarget, nextUrl);
this.props.navigate(nextUrl, {
replace: true,
});
};
@@ -176,29 +223,29 @@ class ArtikelSearchPage extends Component {
this.setState({ input: value });
const { filter } = queryFromParams(this.props.searchParams);
const { pageSize } = this.state;
if (pageSize != null) {
void articleSearchAutomat.actions.search({
target: value,
filter,
offset: 0,
pageSize,
}).catch(() => {});
}
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(() => {});
};
fetchResults = () => {
const { target, filter, offset } = queryFromParams(this.props.searchParams);
const { pageSize } = this.state;
if (pageSize == null) return;
writeArtikelQuery({ target, filter, offset });
void articleSearchAutomat.actions.search({
target: target.trim(),
filter,
offset,
pageSize,
pageSize: pageSize ?? undefined,
}).catch(() => {});
};
@@ -213,6 +260,7 @@ class ArtikelSearchPage extends Component {
render() {
const {
input,
categoryFilterOpen,
error,
categoryFacets,
totalCount,
@@ -225,8 +273,6 @@ class ArtikelSearchPage extends Component {
const filterParam = this.props.searchParams.get('filter') ?? '';
const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || stateOffset || 0);
const categories = parseFilter(filterParam);
// Drive UI from input so hint vs results switch in one paint (URL lags one tick).
const searchActive = input.trim().length > 0 || (recordset ?? []).length > 0;
const rows = recordset ?? [];
const facetEntries = categoryFacets
@@ -246,9 +292,18 @@ class ArtikelSearchPage extends Component {
return (
<>
<Box sx={{ mb: 3, maxWidth: 720 }}>
<Box
ref={this.filterHeaderRef}
sx={{
mb: 2,
display: 'flex',
gap: 1.5,
alignItems: 'center',
minHeight: 40,
}}
>
<TextField
fullWidth
sx={{ maxWidth: 520, flexGrow: 1 }}
autoFocus
type="search"
autoComplete="off"
@@ -261,6 +316,7 @@ class ArtikelSearchPage extends Component {
autoCorrect: 'off',
},
input: {
sx: { height: 40 },
startAdornment: (
<InputAdornment position="start">
<SearchIcon color="action" />
@@ -269,6 +325,34 @@ class ArtikelSearchPage extends Component {
},
}}
/>
{facetEntries.length > 0 && (
<Button
variant={categoryFilterOpen ? 'contained' : 'outlined'}
color="inherit"
onClick={this.toggleCategoryFilter}
startIcon={<FilterListIcon />}
endIcon={categoryFilterOpen ? <ExpandLessIcon /> : <ExpandMoreIcon />}
sx={{ height: 40, whiteSpace: 'nowrap', flexShrink: 0 }}
>
Kategorien
</Button>
)}
{categories.map((catId) => {
const catName = categoryFacets?.[catId]?.name ?? `Kategorie ${catId}`;
return (
<Chip
key={catId}
label={catName}
color="primary"
variant="outlined"
onDelete={() => {
const next = categories.filter((id) => id !== catId);
this.updateParams(input, buildFilter(next), 0);
}}
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
/>
);
})}
</Box>
{error && (
@@ -279,17 +363,47 @@ class ArtikelSearchPage extends Component {
/>
)}
{searchActive && (
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start' }}>
{facetEntries.length > 0 && (
<Paper variant="outlined" sx={{ width: 220, flexShrink: 0, overflow: 'auto' }}>
<Typography
variant="subtitle2"
sx={{ px: 2, py: 1.5, bgcolor: 'action.hover', fontWeight: 600 }}
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start', mb: -4 }}>
{categoryFilterOpen && facetEntries.length > 0 && (
<FillSpace
ref={this.categoryFillSpaceRef}
aboveRef={this.filterHeaderRef}
sx={{ width: 240, flexShrink: 0 }}
>
<Paper
variant="outlined"
sx={{
height: '100%',
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<Box
sx={{
px: 2,
py: 1.5,
bgcolor: 'action.hover',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexShrink: 0,
}}
>
Kategorien
</Typography>
<List dense disablePadding>
<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 (
@@ -299,6 +413,7 @@ class ArtikelSearchPage extends Component {
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
const next = selected ? [] : [id];
this.setState({ categoryFilterOpen: false });
this.updateParams(input, buildFilter(next), 0);
}}
sx={{ pl: 2 + depth * 1.5 }}
@@ -315,101 +430,102 @@ class ArtikelSearchPage extends Component {
})}
</List>
</Paper>
</FillSpace>
)}
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
{!error && input.trim().length > 0 && totalCount === 0 && (
<Typography color="text.secondary">Keine Artikel gefunden.</Typography>
)}
<Box sx={{ flexGrow: 1, minWidth: 0, mb: -4 }}>
{searchActive && !error && totalCount === 0 && (
<Typography color="text.secondary">Keine Artikel gefunden.</Typography>
)}
{/* Always mount real pagination height (invisible until needed) so pageSize is correct. */}
<Box ref={this.paginationRef} sx={{ mb: 2 }}>
<ResultsPagination
invisible={!showPagination}
offset={showPagination ? offset : 0}
totalCount={showPagination ? totalCount : (pageSize ?? 1) + 1}
pageSize={pageSize ?? 1}
pageCount={showPagination ? pageCount : 5}
currentPage={showPagination ? currentPage : 1}
onPageChange={this.handlePageChange}
/>
</Box>
<FillSpace
aboveRef={this.paginationRef}
onMeasurement={this.handleMeasurement}
sx={{ overflow: 'auto' }}
measureElement={<ResultRowMeasureSample />}
>
<Box>
{rows.length > 0 && (
<List component={Paper} variant="outlined" disablePadding>
{rows.map((row, index) => (
<ListItemButton
key={row.id}
type="button"
divider={index < rows.length - 1}
data-result-row
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
this.persistQuery();
this.props.navigate(artikelDetailPath(row));
}}
sx={resultRowSx}
>
<ListItemText
primary={row.name}
secondary={
<Typography
component="span"
variant="body2"
color="text.secondary"
noWrap
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, maxWidth: '100%' }}
>
{(row.brand?.name || row.brandName) && (
<>
<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: 'text.secondary',
noWrap: true,
}}
/>
{' · '}
</>
)}
{row.sku}
{' · '}
Bestand {formatStock(row.stock)}
{' · '}
Verfügbar {formatStock(row.available)}
{row.categories
? ` · ${formatCategoryList(row.categories, categoryFacets ?? {})}`
: ''}
</Typography>
}
slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
/>
<Typography
variant="body2"
sx={{ fontWeight: 600, flexShrink: 0, ml: 2 }}
>
{row.price != null ? formatPrice(row.price) : '—'}
</Typography>
</ListItemButton>
))}
</List>
)}
</Box>
</FillSpace>
{/* Always mount real pagination height (invisible until needed) so pageSize is correct. */}
<Box ref={this.paginationRef} sx={{ mb: 2 }}>
<ResultsPagination
invisible={!showPagination}
offset={showPagination ? offset : 0}
totalCount={showPagination ? totalCount : (pageSize ?? 1) + 1}
pageSize={pageSize ?? 1}
pageCount={showPagination ? pageCount : 5}
currentPage={showPagination ? currentPage : 1}
onPageChange={this.handlePageChange}
/>
</Box>
<FillSpace
ref={this.fillSpaceRef}
aboveRef={[this.filterHeaderRef, this.paginationRef]}
onMeasurement={this.handleMeasurement}
sx={{ overflow: 'auto' }}
measureElement={<ResultRowMeasureSample />}
>
<Box>
{rows.length > 0 && (
<List component={Paper} variant="outlined" disablePadding>
{rows.map((row, index) => (
<ListItemButton
key={row.id}
type="button"
divider={index < rows.length - 1}
data-result-row
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
this.persistQuery();
this.props.navigate(artikelDetailPath(row));
}}
sx={resultRowSx}
>
<ListItemText
primary={row.name}
secondary={
<Typography
component="span"
variant="body2"
color="text.secondary"
noWrap
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, maxWidth: '100%' }}
>
{(row.brand?.name || row.brandName) && (
<>
<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: 'text.secondary',
noWrap: true,
}}
/>
{' · '}
</>
)}
{row.sku}
{' · '}
Bestand {formatStock(row.stock)}
{' · '}
Verfügbar {formatStock(row.available)}
{row.categories
? ` · ${formatCategoryList(row.categories, categoryFacets ?? {})}`
: ''}
</Typography>
}
slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
/>
<Typography
variant="body2"
sx={{ fontWeight: 600, flexShrink: 0, ml: 2 }}
>
{row.price != null ? formatPrice(row.price) : '—'}
</Typography>
</ListItemButton>
))}
</List>
)}
</Box>
</FillSpace>
</Box>
)}
</Box>
</>
);
}

View File

@@ -28,7 +28,7 @@ import {
import categoryAutomat from '../../automats/categoryAutomat';
import posterminalAutomat from '../../automats/posterminalAutomat';
import companyAutomat from '../../automats/companyAutomat';
import { setLastArticleSku } from '../../automats';
import { setLastArticleSku, setLastCategorySlug } from '../../automats';
import {
categoryDisplayName,
getCategoryBreadcrumbs,
@@ -98,6 +98,7 @@ class ArtikelKategorienPage extends PureComponent {
componentDidMount() {
setLastArticleSku(null);
setLastCategorySlug(this.props.categorySlugPath);
this._unsubCat = categoryAutomat.subscribe(this, selectFromCategory);
this._unsubTerm = posterminalAutomat.subscribe(this, selectTerminals);
this._unsubCompany = companyAutomat.subscribe(this, (s) => ({
@@ -110,6 +111,9 @@ class ArtikelKategorienPage extends PureComponent {
}
componentDidUpdate(prevProps, prevState) {
if (this.props.categorySlugPath !== prevProps.categorySlugPath) {
setLastCategorySlug(this.props.categorySlugPath);
}
if (this.state.companyId !== prevState.companyId) {
this.ensureTreeLoaded();
}
@@ -132,6 +136,7 @@ class ArtikelKategorienPage extends PureComponent {
}
componentWillUnmount() {
setLastCategorySlug(this.props.categorySlugPath);
this._unsubCat?.();
this._unsubTerm?.();
this._unsubCompany?.();