u
This commit is contained in:
@@ -8,7 +8,6 @@ import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import List from '@mui/material/List';
|
||||
import ListItemAvatar from '@mui/material/ListItemAvatar';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
@@ -27,7 +26,6 @@ import ArticlePhoto from './ArticlePhoto';
|
||||
import { BrandLabel } from '../brand';
|
||||
import { formatPrice } from '../../utils/price';
|
||||
import { formatStock } from './searchFormat';
|
||||
import ResultsPagination from './ResultsPagination';
|
||||
import { BORDER, SCRIM_LABEL } from '../../colors';
|
||||
|
||||
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.
|
||||
*
|
||||
@@ -107,7 +285,6 @@ function SearchRowMeasureSample() {
|
||||
* @param {Array<string>} [props.excludeSkus=[]] SKUs to exclude.
|
||||
* @param {boolean} [props.allowParent=false] Whether parent articles are selectable.
|
||||
* @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.disabled=false]
|
||||
* @param {boolean} [props.autoFocus=false]
|
||||
@@ -120,7 +297,6 @@ function SearchRowMeasureSample() {
|
||||
*/
|
||||
export default class ArticleSearch extends PureComponent {
|
||||
headerRef = createRef();
|
||||
paginationRef = createRef();
|
||||
listContainerRef = createRef();
|
||||
contentContainerRef = createRef();
|
||||
fillSpaceRef = createRef();
|
||||
@@ -128,14 +304,9 @@ export default class ArticleSearch extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const initialQuery = props.initialQuery ?? '';
|
||||
const pageSize = props.pageSize ?? 8;
|
||||
this.state = {
|
||||
query: initialQuery,
|
||||
offset: 0,
|
||||
pageSize,
|
||||
totalCount: 0,
|
||||
pageCount: 1,
|
||||
currentPage: 1,
|
||||
searchKey: 0,
|
||||
results: [],
|
||||
indexStatus: articleIndexAutomat.state.status,
|
||||
@@ -164,7 +335,7 @@ export default class ArticleSearch extends PureComponent {
|
||||
this.props.allowParent !== prevProps.allowParent ||
|
||||
this.props.categories !== prevProps.categories
|
||||
) {
|
||||
this.runQuery(this.state.query, this.state.offset);
|
||||
this.runQuery(this.state.query);
|
||||
}
|
||||
|
||||
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) {
|
||||
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?.();
|
||||
}
|
||||
|
||||
handleMeasurement = (count) => {
|
||||
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) => {
|
||||
runQuery = (queryText) => {
|
||||
const {
|
||||
excludeSkus = [],
|
||||
allowParent = false,
|
||||
categories = [],
|
||||
mode,
|
||||
} = 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, {
|
||||
excludeSkus,
|
||||
allowParent,
|
||||
categories,
|
||||
offset: newOffset,
|
||||
pageSize,
|
||||
offset: 0,
|
||||
limit: mode === 'autocomplete' ? 20 : Infinity,
|
||||
});
|
||||
|
||||
this.setState({
|
||||
results: Array.isArray(res) ? res : (res.recordset ?? []),
|
||||
totalCount: res.totalCount ?? res.length,
|
||||
pageCount: res.pageCount ?? 1,
|
||||
currentPage: res.currentPage ?? 1,
|
||||
offset: newOffset,
|
||||
offset: 0,
|
||||
query: queryText,
|
||||
pageSize,
|
||||
});
|
||||
};
|
||||
|
||||
handlePageChange = (_e, page) => {
|
||||
const nextOffset = (page - 1) * this.state.pageSize;
|
||||
this.runQuery(this.state.query, nextOffset);
|
||||
};
|
||||
|
||||
handleOpenDialog = () => {
|
||||
const initialQuery = this.props.initialQuery ?? '';
|
||||
this.setState({ internalOpen: true, query: initialQuery, offset: 0 });
|
||||
this.runQuery(initialQuery, 0);
|
||||
requestAnimationFrame(() => {
|
||||
this.fillSpaceRef.current?.measure();
|
||||
});
|
||||
this.runQuery(initialQuery);
|
||||
};
|
||||
|
||||
handleCloseDialog = () => {
|
||||
@@ -340,19 +494,13 @@ export default class ArticleSearch extends PureComponent {
|
||||
actionLabel = 'Auswählen',
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
query,
|
||||
results,
|
||||
busySku,
|
||||
error,
|
||||
offset,
|
||||
pageSize,
|
||||
totalCount,
|
||||
pageCount,
|
||||
currentPage,
|
||||
} = this.state;
|
||||
const { query, results, busySku, error } = this.state;
|
||||
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 (
|
||||
<Box
|
||||
@@ -430,207 +578,25 @@ export default class ArticleSearch extends PureComponent {
|
||||
/>
|
||||
</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
|
||||
ref={this.fillSpaceRef}
|
||||
aboveRef={[this.headerRef, this.paginationRef]}
|
||||
containerRef={this.contentContainerRef.current ? this.contentContainerRef : this.listContainerRef}
|
||||
onMeasurement={this.handleMeasurement}
|
||||
measureElement={<SearchRowMeasureSample />}
|
||||
aboveRef={this.headerRef}
|
||||
containerRef={this.contentContainerRef}
|
||||
items={results}
|
||||
renderItem={SearchResultRow}
|
||||
minPageSize={6}
|
||||
minHeight={180}
|
||||
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 (
|
||||
<ListItemButton
|
||||
key={article.id || article.sku}
|
||||
divider
|
||||
disabled={Boolean(busySku)}
|
||||
onClick={() => this.handleSelectArticle(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();
|
||||
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>
|
||||
{loading && results.length === 0 ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', py: 3, gap: 1.5 }}>
|
||||
<CircularProgress size={22} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Artikel werden geladen…
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user