u
This commit is contained in:
@@ -27,7 +27,6 @@ import {
|
||||
readArtikelQuery,
|
||||
writeArtikelQuery,
|
||||
} from '../components/article/artikelQuery';
|
||||
import ResultsPagination from '../components/article/ResultsPagination';
|
||||
import {
|
||||
buildFilter,
|
||||
formatCategoryList,
|
||||
@@ -82,36 +81,161 @@ const resultRowSx = {
|
||||
},
|
||||
};
|
||||
|
||||
function ResultRowMeasureSample() {
|
||||
return (
|
||||
<ListItemButton
|
||||
tabIndex={-1}
|
||||
divider
|
||||
disabled
|
||||
sx={{
|
||||
...resultRowSx,
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
opacity: 0.2,
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={'\u00A0'}
|
||||
secondary={
|
||||
<Typography component="span" variant="body2" sx={{ visibility: 'hidden', lineHeight: '20px', height: '20px' }} noWrap>
|
||||
/**
|
||||
* 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 }}
|
||||
>
|
||||
<ListItemText
|
||||
primary={'\u00A0'}
|
||||
slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 600, flexShrink: 0, ml: 2, visibility: 'hidden' }}
|
||||
>
|
||||
|
||||
</Typography>
|
||||
}
|
||||
slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 600, flexShrink: 0, ml: 2, visibility: 'hidden' }}
|
||||
</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}
|
||||
>
|
||||
|
||||
</Typography>
|
||||
</ListItemButton>
|
||||
);
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{row.name}
|
||||
</Box>
|
||||
{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>
|
||||
}
|
||||
secondary={
|
||||
<Typography
|
||||
component="span"
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
noWrap
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
maxWidth: '100%',
|
||||
lineHeight: '20px',
|
||||
height: '20px',
|
||||
}}
|
||||
>
|
||||
{(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 }}
|
||||
>
|
||||
{price != null ? formatPrice(price) : '—'}
|
||||
</Typography>
|
||||
</ListItemButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
// #endregion
|
||||
|
||||
@@ -172,30 +296,19 @@ class ArtikelSearchPage extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = initialSearchState(props.searchParams);
|
||||
this.paginationRef = createRef();
|
||||
this.filterHeaderRef = createRef();
|
||||
this.fillSpaceRef = createRef();
|
||||
this.categoryFillSpaceRef = createRef();
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region Measurement & Query Persistence
|
||||
// #region Query Persistence
|
||||
toggleCategoryFilter = () => {
|
||||
this.setState((prev) => ({ categoryFilterOpen: !prev.categoryFilterOpen }));
|
||||
};
|
||||
|
||||
handleMeasurement = (count) => {
|
||||
if (!count || count === this.state.pageSize) return;
|
||||
this.setState({ pageSize: count });
|
||||
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: count,
|
||||
}).catch(() => {});
|
||||
handleOpenArticle = (row) => {
|
||||
this.persistQuery();
|
||||
this.props.navigate(artikelDetailPath(row));
|
||||
};
|
||||
|
||||
persistQuery = () => {
|
||||
@@ -238,8 +351,7 @@ class ArtikelSearchPage extends Component {
|
||||
void articleSearchAutomat.actions.search({
|
||||
target: activeTarget,
|
||||
filter,
|
||||
offset,
|
||||
pageSize: this.state.pageSize,
|
||||
offset: 0,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -253,22 +365,12 @@ class ArtikelSearchPage extends Component {
|
||||
setLastSearchTerm(target, buildSearchUrl(target, query.filter, query.offset));
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
componentDidUpdate(prevProps) {
|
||||
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();
|
||||
@@ -316,7 +418,6 @@ class ArtikelSearchPage extends Component {
|
||||
const value = event.target.value;
|
||||
this.setState({ input: value });
|
||||
const { filter } = queryFromParams(this.props.searchParams);
|
||||
const { pageSize } = this.state;
|
||||
const currentUrl = buildSearchUrl(value, filter, 0);
|
||||
setLastSearchTerm(value, currentUrl);
|
||||
writeArtikelQuery({ target: value, filter, offset: 0 });
|
||||
@@ -325,7 +426,6 @@ class ArtikelSearchPage extends Component {
|
||||
target: value,
|
||||
filter,
|
||||
offset: 0,
|
||||
pageSize: pageSize ?? undefined,
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
@@ -335,7 +435,6 @@ class ArtikelSearchPage extends Component {
|
||||
|
||||
fetchResults = () => {
|
||||
const { target, filter, offset } = queryFromParams(this.props.searchParams);
|
||||
const { pageSize } = this.state;
|
||||
|
||||
writeArtikelQuery({ target, filter, offset });
|
||||
|
||||
@@ -343,17 +442,8 @@ class ArtikelSearchPage extends Component {
|
||||
target: target.trim(),
|
||||
filter,
|
||||
offset,
|
||||
pageSize: pageSize ?? undefined,
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
handlePageChange = (_e, page) => {
|
||||
const { pageSize } = this.state;
|
||||
const safePageSize = pageSize ?? 1;
|
||||
const nextOffset = (page - 1) * safePageSize;
|
||||
const filterParam = this.props.searchParams.get('filter') ?? '';
|
||||
this.updateParams(this.state.input, filterParam, nextOffset);
|
||||
};
|
||||
// #endregion
|
||||
|
||||
// #region Render
|
||||
@@ -362,16 +452,11 @@ class ArtikelSearchPage extends Component {
|
||||
input,
|
||||
categoryFilterOpen,
|
||||
error,
|
||||
categoryFacets,
|
||||
totalCount,
|
||||
categoryFacets,
|
||||
recordset,
|
||||
pageSize,
|
||||
pageCount,
|
||||
currentPage,
|
||||
offset: stateOffset,
|
||||
} = this.state;
|
||||
const filterParam = this.props.searchParams.get('filter') ?? '';
|
||||
const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || stateOffset || 0);
|
||||
const categories = parseFilter(filterParam);
|
||||
const rows = recordset ?? [];
|
||||
|
||||
@@ -408,8 +493,9 @@ class ArtikelSearchPage extends Component {
|
||||
})
|
||||
: [];
|
||||
|
||||
const showPagination =
|
||||
rows.length > 0 && pageSize != null && totalCount != null && totalCount > pageSize;
|
||||
// Per-render values the static `renderItem` row reads through the context.
|
||||
resultRowsContext.categoryFacets = categoryFacets ?? {};
|
||||
resultRowsContext.onOpenArticle = this.handleOpenArticle;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -502,10 +588,13 @@ class ArtikelSearchPage extends Component {
|
||||
|
||||
<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 }}
|
||||
<Box
|
||||
sx={{
|
||||
width: 240,
|
||||
flexShrink: 0,
|
||||
alignSelf: 'stretch',
|
||||
maxHeight: 'calc(100vh - 220px)',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
variant="outlined"
|
||||
@@ -567,7 +656,7 @@ class ArtikelSearchPage extends Component {
|
||||
})}
|
||||
</List>
|
||||
</Paper>
|
||||
</FillSpace>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||
@@ -575,150 +664,26 @@ class ArtikelSearchPage extends Component {
|
||||
<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
|
||||
ref={this.fillSpaceRef}
|
||||
aboveRef={[this.filterHeaderRef, this.paginationRef]}
|
||||
onMeasurement={this.handleMeasurement}
|
||||
sx={{ overflow: 'auto' }}
|
||||
measureElement={<ResultRowMeasureSample />}
|
||||
{/* Paper surface like /artikel/marken (PanelTile): the FillSpace
|
||||
pagination header + rows render on an elevated card. */}
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
{rows.length > 0 && (
|
||||
<List component={Paper} variant="outlined" disablePadding>
|
||||
{rows.map((row, index) => {
|
||||
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
|
||||
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={
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{row.name}
|
||||
</Box>
|
||||
{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>
|
||||
}
|
||||
secondary={
|
||||
<Typography
|
||||
component="span"
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
noWrap
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
maxWidth: '100%',
|
||||
lineHeight: '20px',
|
||||
height: '20px',
|
||||
}}
|
||||
>
|
||||
{(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 }}
|
||||
>
|
||||
{price != null ? formatPrice(price) : '—'}
|
||||
</Typography>
|
||||
</ListItemButton>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
</Box>
|
||||
</FillSpace>
|
||||
<FillSpace
|
||||
ref={this.fillSpaceRef}
|
||||
aboveRef={[this.filterHeaderRef]}
|
||||
items={rows}
|
||||
renderItem={ResultRow}
|
||||
minPageSize={8}
|
||||
sx={{ overflow: 'auto' }}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user