Files
dashfront/src/components/article/ArticleSearch.jsx
2026-10-10 02:37:38 +02:00

908 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { PureComponent, createRef } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import AppDialog from '../AppDialog';
import DialogActions from '@mui/material/DialogActions';
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 ListItemAvatar from '@mui/material/ListItemAvatar';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import TextInput from '../TextInput';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import Alert from '@mui/material/Alert';
import Autocomplete from '@mui/material/Autocomplete';
import CloseIcon from '@mui/icons-material/Close';
import SearchIcon from '@mui/icons-material/Search';
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import FillSpace from 'dashgui/FillSpace';
import articleIndexAutomat from '../../automats/articleIndexAutomat';
import { searchArticlesIndex } from '../../automats/articleSearchAutomat';
import ArticlePhoto from './ArticlePhoto';
import { BrandLabel } from '../brand';
import { formatPrice } from '../../utils/price';
import { formatStock } from './searchFormat';
import { BORDER, SCRIM_LABEL } from '../../colors';
const searchResultRowSx = {
alignItems: 'center',
minHeight: 64,
maxHeight: 64,
height: 64,
boxSizing: 'border-box',
py: 0,
px: 2,
'& .MuiListItemText-root': { minWidth: 0, my: 0 },
'& .MuiListItemText-primary': {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
lineHeight: '22px',
height: '22px',
display: 'flex',
alignItems: 'center',
},
'& .MuiListItemText-secondary': {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
lineHeight: '20px',
height: '20px',
display: 'flex',
alignItems: 'center',
},
};
/** Measure row that precisely matches the rendered result row height. */
function SearchRowMeasureSample() {
return (
<ListItemButton
tabIndex={-1}
divider
disabled
sx={{
...searchResultRowSx,
pointerEvents: 'none',
userSelect: 'none',
opacity: 0,
}}
>
<ListItemAvatar sx={{ minWidth: 48 }}>
<Box sx={{ width: 38, height: 38 }} />
</ListItemAvatar>
<ListItemText
primary={'\u00A0'}
secondary={
<Typography component="span" variant="body2" sx={{ visibility: 'hidden', lineHeight: '20px', height: '20px' }} noWrap>
&nbsp;
</Typography>
}
/>
<Box sx={{ width: 140, height: 28, visibility: 'hidden' }} />
</ListItemButton>
);
}
/**
* 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.
*
* Supported Modes:
* 1. 'dialog' (default): Inline trigger field that opens a spacious modal dialog.
* 2. 'list': Standalone search input with rich results list and dynamic FillSpace pagination.
* 3. 'autocomplete': Standard Autocomplete with drop-down popper.
*
* @param {object} props
* @param {'dialog'|'list'|'autocomplete'} [props.mode='dialog']
* @param {string} [props.placeholder='Artikel suchen…']
* @param {string} [props.label]
* @param {object|null} [props.value] Currently selected article.
* @param {Function} [props.onSelect] Callback when article selected: (article) => void.
* @param {Function} [props.onChange] Alternative callback for selection.
* @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 {boolean} [props.clearOnSelect=false]
* @param {boolean} [props.disabled=false]
* @param {boolean} [props.autoFocus=false]
* @param {'small'|'medium'} [props.size='small']
* @param {object} [props.sx]
* @param {boolean} [props.open] For controlled dialog: whether dialog is open.
* @param {Function} [props.onClose] For controlled dialog: close callback.
* @param {string} [props.title] Dialog title.
* @param {string} [props.actionLabel='Auswählen'] Label on selection button.
*/
export default class ArticleSearch extends PureComponent {
headerRef = createRef();
listContainerRef = createRef();
contentContainerRef = createRef();
fillSpaceRef = createRef();
constructor(props) {
super(props);
const initialQuery = props.initialQuery ?? '';
this.state = {
query: initialQuery,
offset: 0,
searchKey: 0,
results: [],
indexStatus: articleIndexAutomat.state.status,
busySku: props.busySku || null,
error: null,
internalOpen: false,
};
}
componentDidMount() {
this.unsubIndex = articleIndexAutomat.subscribe(this, (state) => ({
indexStatus: state.status,
}));
if (articleIndexAutomat.state.status === 'idle') {
void articleIndexAutomat.actions.ensureLoaded().catch(() => {});
}
this.runQuery(this.state.query, 0);
}
componentDidUpdate(prevProps, prevState) {
if (
this.state.indexStatus !== prevState.indexStatus ||
this.props.excludeSkus !== prevProps.excludeSkus ||
this.props.allowParent !== prevProps.allowParent ||
this.props.categories !== prevProps.categories
) {
this.runQuery(this.state.query);
}
if (this.props.busySku !== prevProps.busySku) {
this.setState({ busySku: this.props.busySku });
}
if (this.props.initialQuery !== prevProps.initialQuery && this.props.initialQuery != null) {
this.setState({ query: this.props.initialQuery, offset: 0 });
this.runQuery(this.props.initialQuery);
}
}
componentWillUnmount() {
this.unsubIndex?.();
}
runQuery = (queryText) => {
const {
excludeSkus = [],
allowParent = false,
categories = [],
mode,
} = this.props;
// 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: 0,
limit: mode === 'autocomplete' ? 20 : Infinity,
});
this.setState({
results: Array.isArray(res) ? res : (res.recordset ?? []),
offset: 0,
query: queryText,
});
};
handleOpenDialog = () => {
const initialQuery = this.props.initialQuery ?? '';
this.setState({ internalOpen: true, query: initialQuery, offset: 0 });
this.runQuery(initialQuery);
};
handleCloseDialog = () => {
this.setState({ internalOpen: false });
this.props.onClose?.();
};
handleSelectArticle = (article) => {
const { onSelect, onChange, clearOnSelect } = this.props;
if (article) {
this.setState({ internalOpen: false });
this.props.onClose?.();
}
if (clearOnSelect) {
this.setState((prev) => ({
query: '',
offset: 0,
searchKey: prev.searchKey + 1,
}));
this.runQuery('', 0);
}
onSelect?.(article);
onChange?.(article);
};
renderTrigger() {
const {
placeholder = '',
value = null,
disabled = false,
size = 'small',
sx,
} = this.props;
const displayValue = value
? typeof value === 'string'
? value
: (value.sku ? `${value.sku} - ${value.name || ''}` : (value.name || ''))
: '';
return (
<TextInput
fullWidth
size={size}
placeholder={placeholder || ''}
value={displayValue}
disabled={disabled}
onClick={() => {
if (!disabled) this.handleOpenDialog();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
e.preventDefault();
if (!disabled) this.handleOpenDialog();
}
}}
readOnly
slotProps={{
input: {
notched: false,
readOnly: true,
sx: {
'& .MuiOutlinedInput-notchedOutline legend': {
display: 'none',
},
cursor: disabled ? 'default' : 'pointer',
textOverflow: 'ellipsis',
'& input': {
cursor: disabled ? 'default' : 'pointer',
textOverflow: 'ellipsis',
},
},
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" color="action" />
</InputAdornment>
),
endAdornment: value ? (
<InputAdornment position="end">
<IconButton
size="small"
aria-label="Auswahl aufheben"
disabled={disabled}
onClick={(e) => {
e.stopPropagation();
this.handleSelectArticle(null);
}}
edge="end"
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</InputAdornment>
) : null,
},
}}
sx={{
cursor: disabled ? 'default' : 'pointer',
...sx,
}}
/>
);
}
renderListContent() {
const {
placeholder = '',
autoFocus = true,
actionLabel = 'Auswählen',
} = this.props;
const { query, results, busySku, error } = this.state;
const loading = this.state.indexStatus === 'loading';
// Values the static `renderItem` row reads through the context.
searchRowsContext.busySku = busySku;
searchRowsContext.actionLabel = actionLabel;
searchRowsContext.onSelect = this.handleSelectArticle;
return (
<Box
ref={this.listContainerRef}
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1.5,
flex: 1,
minHeight: 0,
height: '100%',
overflow: 'hidden',
}}
>
{error && (
<Alert severity="error" sx={{ mb: 1, flexShrink: 0 }}>
{error}
</Alert>
)}
<Box ref={this.headerRef} sx={{ flexShrink: 0 }}>
<TextInput
autoFocus={autoFocus}
fullWidth
size="small"
readOnly={false}
placeholder={placeholder || ''}
value={query}
onChange={(e) => {
const val = e.target.value;
this.setState({ query: val, offset: 0 });
this.runQuery(val, 0);
}}
onKeyDown={(e) => {
if (e.key === 'Escape' && query) {
e.preventDefault();
this.setState({ query: '', offset: 0 });
this.runQuery('', 0);
} else if (e.key === 'Enter' && results.length > 0) {
e.preventDefault();
this.handleSelectArticle(results[0]);
}
}}
disabled={Boolean(busySku)}
slotProps={{
input: {
notched: false,
sx: {
'& .MuiOutlinedInput-notchedOutline legend': {
display: 'none',
},
},
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" color="action" />
</InputAdornment>
),
endAdornment: query ? (
<InputAdornment position="end">
<IconButton
size="small"
aria-label="Eingabe leeren"
onClick={() => {
this.setState({ query: '', offset: 0 });
this.runQuery('', 0);
}}
edge="end"
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</InputAdornment>
) : null,
},
}}
/>
</Box>
<FillSpace
ref={this.fillSpaceRef}
aboveRef={this.headerRef}
containerRef={this.contentContainerRef}
items={results}
renderItem={SearchResultRow}
minPageSize={6}
minHeight={180}
sx={{ overflow: 'auto', flex: 1, minHeight: 0 }}
/>
{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>
);
}
renderDialogModal(isControlled) {
const {
open: controlledOpen,
title = this.props.label || 'Artikel suchen',
} = this.props;
const { internalOpen } = this.state;
const isDialogOpen = isControlled ? Boolean(controlledOpen) : internalOpen;
return (
<AppDialog
open={isDialogOpen}
onClose={this.handleCloseDialog}
maxWidth="md"
fullWidth
slotProps={{
paper: {
sx: {
height: '80vh',
minHeight: '80vh',
maxHeight: '80vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
},
},
}}
>
<DialogTitle
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
pb: 1,
flexShrink: 0,
}}
>
<Typography variant="h6" component="span" fontWeight={600}>
{title}
</Typography>
<IconButton size="small" onClick={this.handleCloseDialog} aria-label="Schließen">
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent
ref={this.contentContainerRef}
dividers
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
overflow: 'hidden',
}}
>
{this.renderListContent()}
</DialogContent>
<DialogActions sx={{ px: 2, py: 1.5, flexShrink: 0 }}>
<Button size="small" onClick={this.handleCloseDialog}>
Schließen
</Button>
</DialogActions>
</AppDialog>
);
}
renderAutocomplete() {
const {
placeholder = 'Artikel suchen…',
label,
value = null,
disabled = false,
autoFocus = false,
size = 'small',
clearOnSelect = false,
sx,
} = this.props;
const { results } = this.state;
const loading = this.state.indexStatus === 'loading';
const options = value && !results.some((r) => r.sku === value.sku) ? [value, ...results] : results;
return (
<Autocomplete
key={this.state.searchKey}
size={size}
disabled={disabled}
options={options}
value={clearOnSelect ? null : (value || null)}
onInputChange={(_e, newInputValue, reason) => {
if (reason === 'input' || reason === 'clear') {
this.runQuery(newInputValue, 0);
} else if (reason === 'reset' && !newInputValue) {
this.runQuery('', 0);
}
}}
onChange={(_e, selected) => {
this.handleSelectArticle(selected);
}}
getOptionLabel={(opt) =>
typeof opt === 'string'
? opt
: (opt ? `${opt.sku} - ${opt.name || ''}` : '')
}
isOptionEqualToValue={(opt, val) => opt?.sku === val?.sku}
filterOptions={(x) => x}
loading={loading}
renderOption={(props, article) => {
const { key, ...otherProps } = props;
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 price = article.price ?? article.vknetto ?? article.net;
return (
<Box
component="li"
key={article.id || article.sku || key}
{...otherProps}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.25,
minHeight: 52,
maxHeight: 52,
height: 52,
boxSizing: 'border-box',
py: 0,
px: 1.5,
}}
>
<Box
sx={{
width: 32,
height: 32,
borderRadius: 1,
overflow: 'hidden',
bgcolor: BORDER,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
border: '1px solid',
borderColor: 'divider',
}}
>
{hasPhoto ? (
<ArticlePhoto
photo={{ fullHash, thumbHash, thumbW, thumbH }}
size={32}
/>
) : (
<Inventory2OutlinedIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
)}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Chip
size="small"
label={article.sku}
sx={{
height: 18,
fontSize: '0.68rem',
fontWeight: 600,
bgcolor: 'action.hover',
}}
/>
<Typography variant="body2" fontWeight={600} noWrap sx={{ flex: 1 }}>
{article.name || '–'}
</Typography>
{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.2,
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>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.25, height: 18, minHeight: 18 }}>
{(article.brand?.name || article.brandName) && (
<BrandLabel
brand={article.brand || { name: article.brandName, thumbHash: article.brandThumbHash }}
name={article.brand?.name || article.brandName}
size={13}
typographyProps={{ variant: 'caption', color: 'text.secondary', noWrap: true }}
/>
)}
{article.stock != null && (
<Typography variant="caption" color="text.secondary">
Bestand: {formatStock(article.stock)}
</Typography>
)}
</Box>
</Box>
{price != null && (
<Typography variant="body2" fontWeight={600} sx={{ flexShrink: 0, ml: 1 }}>
{formatPrice(price)}
</Typography>
)}
</Box>
);
}}
renderInput={(params) => {
const inputProps = params.InputProps ?? params.slotProps?.input ?? {};
const startAdornment = (
<>
<InputAdornment position="start">
<SearchIcon fontSize="small" color="action" />
</InputAdornment>
{inputProps.startAdornment}
</>
);
const endAdornment = (
<>
{loading ? <CircularProgress color="inherit" size={16} /> : null}
{inputProps.endAdornment}
</>
);
if (params.InputProps) {
return (
<TextInput
{...params}
label={label}
autoFocus={autoFocus}
placeholder={placeholder || ''}
InputProps={{
...params.InputProps,
startAdornment,
endAdornment,
}}
/>
);
}
return (
<TextInput
{...params}
label={label}
autoFocus={autoFocus}
placeholder={placeholder || ''}
slotProps={{
...params.slotProps,
input: {
...params.slotProps?.input,
startAdornment,
endAdornment,
},
}}
/>
);
}}
sx={sx}
/>
);
}
render() {
const { mode = 'dialog', open } = this.props;
if (mode === 'list') {
return this.renderListContent();
}
if (mode === 'autocomplete') {
return this.renderAutocomplete();
}
const isControlled = open !== undefined;
if (isControlled) {
return this.renderDialogModal(true);
}
return (
<>
{this.renderTrigger()}
{this.renderDialogModal(false)}
</>
);
}
}