u
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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' }}
|
||||||
|
>
|
||||||
|
|
||||||
</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}
|
||||||
>
|
>
|
||||||
|
<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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
49
src/utils/thumbHashCache.js
Normal file
49
src/utils/thumbHashCache.js
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user