This commit is contained in:
seb
2026-10-10 02:37:38 +02:00
parent 859fec808d
commit c5b46f11f1
7 changed files with 461 additions and 470 deletions

View File

@@ -8,7 +8,6 @@ import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment'; import InputAdornment from '@mui/material/InputAdornment';
import List from '@mui/material/List';
import ListItemAvatar from '@mui/material/ListItemAvatar'; import ListItemAvatar from '@mui/material/ListItemAvatar';
import ListItemButton from '@mui/material/ListItemButton'; import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText'; import ListItemText from '@mui/material/ListItemText';
@@ -27,7 +26,6 @@ import ArticlePhoto from './ArticlePhoto';
import { BrandLabel } from '../brand'; import { BrandLabel } from '../brand';
import { formatPrice } from '../../utils/price'; import { formatPrice } from '../../utils/price';
import { formatStock } from './searchFormat'; import { formatStock } from './searchFormat';
import ResultsPagination from './ResultsPagination';
import { BORDER, SCRIM_LABEL } from '../../colors'; import { BORDER, SCRIM_LABEL } from '../../colors';
const searchResultRowSx = { const searchResultRowSx = {
@@ -89,6 +87,186 @@ function SearchRowMeasureSample() {
); );
} }
/**
* Ambient context for the `renderItem` row. FillSpace renders rows as a bare
* element and cannot pass page callbacks, so `renderListContent` publishes the
* current selection handler / busy state here on every render.
*/
const searchRowsContext = { busySku: null, actionLabel: 'Auswählen', onSelect: 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 SearchResultRow extends PureComponent {
render() {
const { item: article } = this.props;
const { busySku, actionLabel, onSelect } = searchRowsContext;
// Data-free probe (`item` undefined): the fixed-height measure sample.
if (!article) return <SearchRowMeasureSample />;
const photo = article.photos?.[0] || article.articlepic?.[0];
const fullHash = photo?.hash || photo?.fullHash;
const thumbHash = photo?.thumbhash || photo?.thumbHash;
const thumbW = photo?.thumbw || photo?.thumbW;
const thumbH = photo?.thumbh || photo?.thumbH;
const hasPhoto = fullHash != null || thumbHash != null;
const primaryEan = Array.isArray(article.eans) ? article.eans[0] : article.eans;
const price = article.price ?? article.vknetto ?? article.net;
const isBusy = busySku === article.sku;
return (
<ListItemButton
divider
disabled={Boolean(busySku)}
onClick={() => onSelect?.(article)}
sx={searchResultRowSx}
>
<ListItemAvatar sx={{ minWidth: 48 }}>
<Box
sx={{
width: 38,
height: 38,
borderRadius: 1,
overflow: 'hidden',
bgcolor: BORDER,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid',
borderColor: 'divider',
}}
>
{hasPhoto ? (
<ArticlePhoto
photo={{ fullHash, thumbHash, thumbW, thumbH }}
size={38}
/>
) : (
<Inventory2OutlinedIcon sx={{ fontSize: 20, color: 'text.secondary' }} />
)}
</Box>
</ListItemAvatar>
<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',
}}
>
{article.name || '–'}
</Box>
{Boolean(article.isParent || article.is_parent || (Number(article.childCount) > 0)) && (
<Box
component="span"
title={`${Number(article.childCount) || 0} 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',
}}
>
{Number(article.childCount) || 0}
</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',
}}
>
{(article.brand?.name || article.brandName) && (
<>
<BrandLabel
brand={article.brand || { name: article.brandName, thumbHash: article.brandThumbHash }}
name={article.brand?.name || article.brandName}
size={14}
spacing={0.5}
typographyProps={{
variant: 'body2',
color: 'text.secondary',
noWrap: true,
}}
/>
{' · '}
</>
)}
{article.sku}
{article.stock != null && ` · Bestand ${formatStock(article.stock)}`}
{article.available != null && ` · Verfügbar ${formatStock(article.available)}`}
{primaryEan ? ` · EAN ${primaryEan}` : ''}
</Typography>
}
slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, ml: 2, flexShrink: 0 }}>
{price != null && (
<Typography variant="body2" fontWeight={700} color="text.primary">
{formatPrice(price)}
</Typography>
)}
<Button
size="small"
variant="outlined"
disabled={Boolean(busySku)}
onClick={(e) => {
e.stopPropagation();
onSelect?.(article);
}}
sx={{
fontSize: '0.75rem',
textTransform: 'none',
py: 0.5,
px: 1.75,
minWidth: 84,
}}
>
{isBusy ? 'Laden…' : actionLabel}
</Button>
</Box>
</ListItemButton>
);
}
}
/** /**
* Unified article search component based on articleSearchAutomat / articleIndexAutomat. * Unified article search component based on articleSearchAutomat / articleIndexAutomat.
* *
@@ -107,7 +285,6 @@ function SearchRowMeasureSample() {
* @param {Array<string>} [props.excludeSkus=[]] SKUs to exclude. * @param {Array<string>} [props.excludeSkus=[]] SKUs to exclude.
* @param {boolean} [props.allowParent=false] Whether parent articles are selectable. * @param {boolean} [props.allowParent=false] Whether parent articles are selectable.
* @param {Array<string>} [props.categories=[]] Category IDs to filter by. * @param {Array<string>} [props.categories=[]] Category IDs to filter by.
* @param {number} [props.pageSize] Initial results per page (measured dynamically by FillSpace).
* @param {boolean} [props.clearOnSelect=false] * @param {boolean} [props.clearOnSelect=false]
* @param {boolean} [props.disabled=false] * @param {boolean} [props.disabled=false]
* @param {boolean} [props.autoFocus=false] * @param {boolean} [props.autoFocus=false]
@@ -120,7 +297,6 @@ function SearchRowMeasureSample() {
*/ */
export default class ArticleSearch extends PureComponent { export default class ArticleSearch extends PureComponent {
headerRef = createRef(); headerRef = createRef();
paginationRef = createRef();
listContainerRef = createRef(); listContainerRef = createRef();
contentContainerRef = createRef(); contentContainerRef = createRef();
fillSpaceRef = createRef(); fillSpaceRef = createRef();
@@ -128,14 +304,9 @@ export default class ArticleSearch extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
const initialQuery = props.initialQuery ?? ''; const initialQuery = props.initialQuery ?? '';
const pageSize = props.pageSize ?? 8;
this.state = { this.state = {
query: initialQuery, query: initialQuery,
offset: 0, offset: 0,
pageSize,
totalCount: 0,
pageCount: 1,
currentPage: 1,
searchKey: 0, searchKey: 0,
results: [], results: [],
indexStatus: articleIndexAutomat.state.status, indexStatus: articleIndexAutomat.state.status,
@@ -164,7 +335,7 @@ export default class ArticleSearch extends PureComponent {
this.props.allowParent !== prevProps.allowParent || this.props.allowParent !== prevProps.allowParent ||
this.props.categories !== prevProps.categories this.props.categories !== prevProps.categories
) { ) {
this.runQuery(this.state.query, this.state.offset); this.runQuery(this.state.query);
} }
if (this.props.busySku !== prevProps.busySku) { if (this.props.busySku !== prevProps.busySku) {
@@ -173,7 +344,7 @@ export default class ArticleSearch extends PureComponent {
if (this.props.initialQuery !== prevProps.initialQuery && this.props.initialQuery != null) { if (this.props.initialQuery !== prevProps.initialQuery && this.props.initialQuery != null) {
this.setState({ query: this.props.initialQuery, offset: 0 }); this.setState({ query: this.props.initialQuery, offset: 0 });
this.runQuery(this.props.initialQuery, 0); this.runQuery(this.props.initialQuery);
} }
} }
@@ -181,52 +352,35 @@ export default class ArticleSearch extends PureComponent {
this.unsubIndex?.(); this.unsubIndex?.();
} }
handleMeasurement = (count) => { runQuery = (queryText) => {
if (!count || count <= 0 || count === this.state.pageSize) return;
this.setState({ pageSize: count });
this.runQuery(this.state.query, this.state.offset, count);
};
runQuery = (queryText, newOffset = 0, customPageSize) => {
const { const {
excludeSkus = [], excludeSkus = [],
allowParent = false, allowParent = false,
categories = [], categories = [],
mode,
} = this.props; } = this.props;
const pageSize = customPageSize ?? this.state.pageSize; // FillSpace paginates `results` itself, so the list/dialog modes load the
// full match set; the autocomplete popper keeps a small option cap.
const res = searchArticlesIndex(queryText, { const res = searchArticlesIndex(queryText, {
excludeSkus, excludeSkus,
allowParent, allowParent,
categories, categories,
offset: newOffset, offset: 0,
pageSize, limit: mode === 'autocomplete' ? 20 : Infinity,
}); });
this.setState({ this.setState({
results: Array.isArray(res) ? res : (res.recordset ?? []), results: Array.isArray(res) ? res : (res.recordset ?? []),
totalCount: res.totalCount ?? res.length, offset: 0,
pageCount: res.pageCount ?? 1,
currentPage: res.currentPage ?? 1,
offset: newOffset,
query: queryText, query: queryText,
pageSize,
}); });
}; };
handlePageChange = (_e, page) => {
const nextOffset = (page - 1) * this.state.pageSize;
this.runQuery(this.state.query, nextOffset);
};
handleOpenDialog = () => { handleOpenDialog = () => {
const initialQuery = this.props.initialQuery ?? ''; const initialQuery = this.props.initialQuery ?? '';
this.setState({ internalOpen: true, query: initialQuery, offset: 0 }); this.setState({ internalOpen: true, query: initialQuery, offset: 0 });
this.runQuery(initialQuery, 0); this.runQuery(initialQuery);
requestAnimationFrame(() => {
this.fillSpaceRef.current?.measure();
});
}; };
handleCloseDialog = () => { handleCloseDialog = () => {
@@ -340,19 +494,13 @@ export default class ArticleSearch extends PureComponent {
actionLabel = 'Auswählen', actionLabel = 'Auswählen',
} = this.props; } = this.props;
const { const { query, results, busySku, error } = this.state;
query,
results,
busySku,
error,
offset,
pageSize,
totalCount,
pageCount,
currentPage,
} = this.state;
const loading = this.state.indexStatus === 'loading'; const loading = this.state.indexStatus === 'loading';
const showPagination = totalCount > pageSize;
// Values the static `renderItem` row reads through the context.
searchRowsContext.busySku = busySku;
searchRowsContext.actionLabel = actionLabel;
searchRowsContext.onSelect = this.handleSelectArticle;
return ( return (
<Box <Box
@@ -430,207 +578,25 @@ export default class ArticleSearch extends PureComponent {
/> />
</Box> </Box>
<Box ref={this.paginationRef} sx={{ px: 0.5, flexShrink: 0 }}>
<ResultsPagination
invisible={!showPagination}
offset={showPagination ? offset : 0}
totalCount={showPagination ? totalCount : pageSize + 1}
pageSize={pageSize}
pageCount={showPagination ? pageCount : 1}
currentPage={showPagination ? currentPage : 1}
onPageChange={this.handlePageChange}
/>
</Box>
<FillSpace <FillSpace
ref={this.fillSpaceRef} ref={this.fillSpaceRef}
aboveRef={[this.headerRef, this.paginationRef]} aboveRef={this.headerRef}
containerRef={this.contentContainerRef.current ? this.contentContainerRef : this.listContainerRef} containerRef={this.contentContainerRef}
onMeasurement={this.handleMeasurement} items={results}
measureElement={<SearchRowMeasureSample />} renderItem={SearchResultRow}
minPageSize={6}
minHeight={180} minHeight={180}
sx={{ overflow: 'auto', flex: 1, minHeight: 0 }} sx={{ overflow: 'auto', flex: 1, minHeight: 0 }}
> />
{loading ? (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', py: 6, gap: 1.5 }}>
<CircularProgress size={22} />
<Typography variant="body2" color="text.secondary">
Artikel werden geladen…
</Typography>
</Box>
) : results.length === 0 ? null : (
<List
dense
disablePadding
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
}}
>
{results.map((article) => {
const photo = article.photos?.[0] || article.articlepic?.[0];
const fullHash = photo?.hash || photo?.fullHash;
const thumbHash = photo?.thumbhash || photo?.thumbHash;
const thumbW = photo?.thumbw || photo?.thumbW;
const thumbH = photo?.thumbh || photo?.thumbH;
const hasPhoto = fullHash != null || thumbHash != null;
const primaryEan = Array.isArray(article.eans) ? article.eans[0] : article.eans;
const price = article.price ?? article.vknetto ?? article.net;
const isBusy = busySku === article.sku;
return ( {loading && results.length === 0 ? (
<ListItemButton <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', py: 3, gap: 1.5 }}>
key={article.id || article.sku} <CircularProgress size={22} />
divider <Typography variant="body2" color="text.secondary">
disabled={Boolean(busySku)} Artikel werden geladen…
onClick={() => this.handleSelectArticle(article)} </Typography>
sx={searchResultRowSx} </Box>
> ) : null}
<ListItemAvatar sx={{ minWidth: 48 }}>
<Box
sx={{
width: 38,
height: 38,
borderRadius: 1,
overflow: 'hidden',
bgcolor: BORDER,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid',
borderColor: 'divider',
}}
>
{hasPhoto ? (
<ArticlePhoto
photo={{ fullHash, thumbHash, thumbW, thumbH }}
size={38}
/>
) : (
<Inventory2OutlinedIcon sx={{ fontSize: 20, color: 'text.secondary' }} />
)}
</Box>
</ListItemAvatar>
<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',
}}
>
{article.name || '–'}
</Box>
{Boolean(article.isParent || article.is_parent || (Number(article.childCount) > 0)) && (
<Box
component="span"
title={`${Number(article.childCount) || 0} 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',
}}
>
{Number(article.childCount) || 0}
</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',
}}
>
{(article.brand?.name || article.brandName) && (
<>
<BrandLabel
brand={article.brand || { name: article.brandName, thumbHash: article.brandThumbHash }}
name={article.brand?.name || article.brandName}
size={14}
spacing={0.5}
typographyProps={{
variant: 'body2',
color: 'text.secondary',
noWrap: true,
}}
/>
{' · '}
</>
)}
{article.sku}
{article.stock != null && ` · Bestand ${formatStock(article.stock)}`}
{article.available != null && ` · Verfügbar ${formatStock(article.available)}`}
{primaryEan ? ` · EAN ${primaryEan}` : ''}
</Typography>
}
slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, ml: 2, flexShrink: 0 }}>
{price != null && (
<Typography variant="body2" fontWeight={700} color="text.primary">
{formatPrice(price)}
</Typography>
)}
<Button
size="small"
variant="outlined"
disabled={Boolean(busySku)}
onClick={(e) => {
e.stopPropagation();
this.handleSelectArticle(article);
}}
sx={{
fontSize: '0.75rem',
textTransform: 'none',
py: 0.5,
px: 1.75,
minWidth: 84,
}}
>
{isBusy ? 'Laden…' : actionLabel}
</Button>
</Box>
</ListItemButton>
);
})}
</List>
)}
</FillSpace>
</Box> </Box>
); );
} }

View File

@@ -128,7 +128,7 @@ class ArtikelMarke extends PureComponent {
'&:hover': disabled ? undefined : { opacity: 0.8 }, '&:hover': disabled ? undefined : { opacity: 0.8 },
}} }}
> >
<BrandThumb brand={brand} size={40} /> <BrandThumb brand={brand}/>
</Box> </Box>
) : ( ) : (
<IconButton <IconButton

View File

@@ -10,7 +10,7 @@ export default class BrandThumb extends PureComponent {
render() { render() {
const { const {
brand, brand,
size = 20, size = 40,
fallback = null, fallback = null,
sx, sx,
} = this.props; } = this.props;

View File

@@ -5,6 +5,12 @@ import ButtonBase from '@mui/material/ButtonBase';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { TILE_SIZE, Tile, tileBaseSx } from 'dashgui/tiles'; import { TILE_SIZE, Tile, tileBaseSx } from 'dashgui/tiles';
import { formatPrice, getArticleGross } from '../../utils/price'; import { formatPrice, getArticleGross } from '../../utils/price';
import {
getCachedImage,
getThumbHashDataUrl,
isImageLoaded,
markImageLoaded,
} from '../../utils/thumbHashCache';
import companyAutomat from '../../automats/companyAutomat'; import companyAutomat from '../../automats/companyAutomat';
import { articleSku } from '../article/articleRoute'; import { articleSku } from '../article/articleRoute';
import { BrandLabel } from '../brand'; import { BrandLabel } from '../brand';

View File

@@ -12,6 +12,11 @@ import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import DeleteForeverOutlinedIcon from '@mui/icons-material/DeleteForeverOutlined'; import DeleteForeverOutlinedIcon from '@mui/icons-material/DeleteForeverOutlined';
import { thumb } from '../../api'; import { thumb } from '../../api';
import { tileBaseSx } from 'dashgui/tiles'; import { tileBaseSx } from 'dashgui/tiles';
import {
getThumbHashDataUrl,
isImageLoaded,
markImageLoaded,
} from '../../utils/thumbHashCache';
import { import {
RED_TINT, RED_TINT,
SCRIM_BADGE, SCRIM_BADGE,

View File

@@ -27,7 +27,6 @@ import {
readArtikelQuery, readArtikelQuery,
writeArtikelQuery, writeArtikelQuery,
} from '../components/article/artikelQuery'; } from '../components/article/artikelQuery';
import ResultsPagination from '../components/article/ResultsPagination';
import { import {
buildFilter, buildFilter,
formatCategoryList, formatCategoryList,
@@ -82,36 +81,161 @@ const resultRowSx = {
}, },
}; };
function ResultRowMeasureSample() { /**
return ( * Ambient context for the `renderItem` row. FillSpace renders rows as a bare
<ListItemButton * element (`<Row brand={item} item={item} index={index} />`, probe: `item`
tabIndex={-1} * undefined) and cannot pass page callbacks, so the page publishes them here
divider * on every render.
disabled */
sx={{ const resultRowsContext = { categoryFacets: {}, onOpenArticle: null };
...resultRowSx,
pointerEvents: 'none', /**
userSelect: 'none', * One search-result row rendered by FillSpace via `renderItem`. A class
opacity: 0.2, * component so FillSpace renders it as an element (and can probe it with
}} * `item` undefined to measure the row height).
> */
<ListItemText class ResultRow extends Component {
primary={'\u00A0'} render() {
secondary={ const { item: row } = this.props;
<Typography component="span" variant="body2" sx={{ visibility: 'hidden', lineHeight: '20px', height: '20px' }} noWrap> 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' }}
>
&nbsp; &nbsp;
</Typography> </Typography>
} </ListItemButton>
slotProps={{ primary: { fontWeight: 500, noWrap: true } }} );
/> }
<Typography
variant="body2" const isParent = Boolean(row.isParent || row.is_parent);
sx={{ fontWeight: 600, flexShrink: 0, ml: 2, visibility: 'hidden' }} 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}
> >
&nbsp; <ListItemText
</Typography> primary={
</ListItemButton> <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 // #endregion
@@ -172,30 +296,19 @@ class ArtikelSearchPage extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = initialSearchState(props.searchParams); this.state = initialSearchState(props.searchParams);
this.paginationRef = createRef();
this.filterHeaderRef = createRef(); this.filterHeaderRef = createRef();
this.fillSpaceRef = createRef(); this.fillSpaceRef = createRef();
this.categoryFillSpaceRef = createRef();
} }
// #endregion // #endregion
// #region Measurement & Query Persistence // #region Query Persistence
toggleCategoryFilter = () => { toggleCategoryFilter = () => {
this.setState((prev) => ({ categoryFilterOpen: !prev.categoryFilterOpen })); this.setState((prev) => ({ categoryFilterOpen: !prev.categoryFilterOpen }));
}; };
handleMeasurement = (count) => { handleOpenArticle = (row) => {
if (!count || count === this.state.pageSize) return; this.persistQuery();
this.setState({ pageSize: count }); this.props.navigate(artikelDetailPath(row));
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(() => {});
}; };
persistQuery = () => { persistQuery = () => {
@@ -238,8 +351,7 @@ class ArtikelSearchPage extends Component {
void articleSearchAutomat.actions.search({ void articleSearchAutomat.actions.search({
target: activeTarget, target: activeTarget,
filter, filter,
offset, offset: 0,
pageSize: this.state.pageSize,
}).catch(() => {}); }).catch(() => {});
} }
@@ -253,22 +365,12 @@ class ArtikelSearchPage extends Component {
setLastSearchTerm(target, buildSearchUrl(target, query.filter, query.offset)); setLastSearchTerm(target, buildSearchUrl(target, query.filter, query.offset));
} }
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps) {
const { searchParams } = this.props; const { searchParams } = this.props;
const prev = prevProps.searchParams; const prev = prevProps.searchParams;
const target = searchParams.get('target') ?? ''; const target = searchParams.get('target') ?? '';
const prevTarget = prev.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) { if (target !== prevTarget && target !== this.state.input) {
this.setState({ input: target }); this.setState({ input: target });
this.fetchResults(); this.fetchResults();
@@ -316,7 +418,6 @@ class ArtikelSearchPage extends Component {
const value = event.target.value; const value = event.target.value;
this.setState({ input: value }); this.setState({ input: value });
const { filter } = queryFromParams(this.props.searchParams); const { filter } = queryFromParams(this.props.searchParams);
const { pageSize } = this.state;
const currentUrl = buildSearchUrl(value, filter, 0); const currentUrl = buildSearchUrl(value, filter, 0);
setLastSearchTerm(value, currentUrl); setLastSearchTerm(value, currentUrl);
writeArtikelQuery({ target: value, filter, offset: 0 }); writeArtikelQuery({ target: value, filter, offset: 0 });
@@ -325,7 +426,6 @@ class ArtikelSearchPage extends Component {
target: value, target: value,
filter, filter,
offset: 0, offset: 0,
pageSize: pageSize ?? undefined,
}).catch(() => {}); }).catch(() => {});
}; };
@@ -335,7 +435,6 @@ class ArtikelSearchPage extends Component {
fetchResults = () => { fetchResults = () => {
const { target, filter, offset } = queryFromParams(this.props.searchParams); const { target, filter, offset } = queryFromParams(this.props.searchParams);
const { pageSize } = this.state;
writeArtikelQuery({ target, filter, offset }); writeArtikelQuery({ target, filter, offset });
@@ -343,17 +442,8 @@ class ArtikelSearchPage extends Component {
target: target.trim(), target: target.trim(),
filter, filter,
offset, offset,
pageSize: pageSize ?? undefined,
}).catch(() => {}); }).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 // #endregion
// #region Render // #region Render
@@ -362,16 +452,11 @@ class ArtikelSearchPage extends Component {
input, input,
categoryFilterOpen, categoryFilterOpen,
error, error,
categoryFacets,
totalCount, totalCount,
categoryFacets,
recordset, recordset,
pageSize,
pageCount,
currentPage,
offset: stateOffset,
} = this.state; } = this.state;
const filterParam = this.props.searchParams.get('filter') ?? ''; 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 categories = parseFilter(filterParam);
const rows = recordset ?? []; const rows = recordset ?? [];
@@ -408,8 +493,9 @@ class ArtikelSearchPage extends Component {
}) })
: []; : [];
const showPagination = // Per-render values the static `renderItem` row reads through the context.
rows.length > 0 && pageSize != null && totalCount != null && totalCount > pageSize; resultRowsContext.categoryFacets = categoryFacets ?? {};
resultRowsContext.onOpenArticle = this.handleOpenArticle;
return ( return (
<> <>
@@ -502,10 +588,13 @@ class ArtikelSearchPage extends Component {
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start', mb: -4 }}> <Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start', mb: -4 }}>
{categoryFilterOpen && facetEntries.length > 0 && ( {categoryFilterOpen && facetEntries.length > 0 && (
<FillSpace <Box
ref={this.categoryFillSpaceRef} sx={{
aboveRef={this.filterHeaderRef} width: 240,
sx={{ width: 240, flexShrink: 0 }} flexShrink: 0,
alignSelf: 'stretch',
maxHeight: 'calc(100vh - 220px)',
}}
> >
<Paper <Paper
variant="outlined" variant="outlined"
@@ -567,7 +656,7 @@ class ArtikelSearchPage extends Component {
})} })}
</List> </List>
</Paper> </Paper>
</FillSpace> </Box>
)} )}
<Box sx={{ flexGrow: 1, minWidth: 0 }}> <Box sx={{ flexGrow: 1, minWidth: 0 }}>
@@ -575,150 +664,26 @@ class ArtikelSearchPage extends Component {
<Typography color="text.secondary">Keine Artikel gefunden.</Typography> <Typography color="text.secondary">Keine Artikel gefunden.</Typography>
)} )}
{/* Always mount real pagination height (invisible until needed) so pageSize is correct. */} {/* Paper surface like /artikel/marken (PanelTile): the FillSpace
<Box ref={this.paginationRef} sx={{ mb: 2 }}> pagination header + rows render on an elevated card. */}
<ResultsPagination <Paper
invisible={!showPagination} variant="outlined"
offset={showPagination ? offset : 0} sx={{
totalCount={showPagination ? totalCount : (pageSize ?? 1) + 1} bgcolor: 'background.paper',
pageSize={pageSize ?? 1} overflow: 'hidden',
pageCount={showPagination ? pageCount : 5} display: 'flex',
currentPage={showPagination ? currentPage : 1} flexDirection: 'column',
onPageChange={this.handlePageChange} }}
/>
</Box>
<FillSpace
ref={this.fillSpaceRef}
aboveRef={[this.filterHeaderRef, this.paginationRef]}
onMeasurement={this.handleMeasurement}
sx={{ overflow: 'auto' }}
measureElement={<ResultRowMeasureSample />}
> >
<Box> <FillSpace
{rows.length > 0 && ( ref={this.fillSpaceRef}
<List component={Paper} variant="outlined" disablePadding> aboveRef={[this.filterHeaderRef]}
{rows.map((row, index) => { items={rows}
const isParent = Boolean(row.isParent || row.is_parent); renderItem={ResultRow}
const childCount = Number(row.childCount) || 0; minPageSize={8}
const showChildBadge = isParent || childCount > 0; sx={{ overflow: 'auto' }}
const price = getArticleGross(row, companyAutomat.state.current?.taxrate); />
return ( </Paper>
<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>
</Box> </Box>
</Box> </Box>
</> </>

View File

@@ -0,0 +1,49 @@
import { thumbHashToDataURL } from 'thumbhash';
// Module-scoped caches are also stashed on `globalThis` so they survive Vite
// HMR reloads instead of re-decoding / re-fetching every image.
const dataUrlCache = (globalThis.__THUMB_DATA_URL_CACHE__ ??= new Map());
const loadedImageHashes = (globalThis.__LOADED_IMAGE_HASHES__ ??= new Set());
export const imageCache = (globalThis.__IMAGE_CACHE__ ??= new Map());
/** True once the full image for `fullHash` has been loaded (data URL or `<img>`). */
export function isImageLoaded(fullHash) {
if (!fullHash) return false;
return loadedImageHashes.has(fullHash) || imageCache.has(fullHash);
}
/** Records that `fullHash` is loaded, optionally caching the decoded `<img>`. */
export function markImageLoaded(fullHash, img) {
if (!fullHash) return;
loadedImageHashes.add(fullHash);
if (img) {
imageCache.set(fullHash, img);
}
}
/** Returns the cached, already-decoded `<img>` for `fullHash`, or `null`. */
export function getCachedImage(fullHash) {
if (!fullHash) return null;
const img = imageCache.get(fullHash);
if (img && img.complete && img.naturalWidth) {
return img;
}
return null;
}
/** Decodes a ThumbHash string into a data URL (cached per hash). */
export function getThumbHashDataUrl(thumbHash) {
if (!thumbHash || typeof thumbHash !== 'string') return null;
let url = dataUrlCache.get(thumbHash);
if (!url) {
try {
const bytes = Uint8Array.from(atob(thumbHash), (c) => c.charCodeAt(0));
url = thumbHashToDataURL(bytes);
dataUrlCache.set(thumbHash, url);
} catch (err) {
console.warn('[thumbHashCache] thumbHashToDataURL failed:', err);
return null;
}
}
return url;
}