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 (   } /> ); } /** * 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 ; 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 ( onSelect?.(article)} sx={searchResultRowSx} > {hasPhoto ? ( ) : ( )} {article.name || '–'} {Boolean(article.isParent || article.is_parent || (Number(article.childCount) > 0)) && ( {Number(article.childCount) || 0} )} } secondary={ {(article.brand?.name || article.brandName) && ( <> {' · '} )} {article.sku} {article.stock != null && ` · Bestand ${formatStock(article.stock)}`} {article.available != null && ` · Verfügbar ${formatStock(article.available)}`} {primaryEan ? ` · EAN ${primaryEan}` : ''} } slotProps={{ primary: { fontWeight: 500, noWrap: true } }} /> {price != null && ( {formatPrice(price)} )} ); } } /** * 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} [props.excludeSkus=[]] SKUs to exclude. * @param {boolean} [props.allowParent=false] Whether parent articles are selectable. * @param {Array} [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 ( { 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: ( ), endAdornment: value ? ( { e.stopPropagation(); this.handleSelectArticle(null); }} edge="end" > ) : 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 ( {error && ( {error} )} { 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: ( ), endAdornment: query ? ( { this.setState({ query: '', offset: 0 }); this.runQuery('', 0); }} edge="end" > ) : null, }, }} /> {loading && results.length === 0 ? ( Artikel werden geladen… ) : null} ); } renderDialogModal(isControlled) { const { open: controlledOpen, title = this.props.label || 'Artikel suchen', } = this.props; const { internalOpen } = this.state; const isDialogOpen = isControlled ? Boolean(controlledOpen) : internalOpen; return ( {title} {this.renderListContent()} ); } 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 ( { 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 ( {hasPhoto ? ( ) : ( )} {article.name || '–'} {Boolean(article.isParent || article.is_parent || (Number(article.childCount) > 0)) && ( {Number(article.childCount) || 0} )} {(article.brand?.name || article.brandName) && ( )} {article.stock != null && ( Bestand: {formatStock(article.stock)} )} {price != null && ( {formatPrice(price)} )} ); }} renderInput={(params) => { const inputProps = params.InputProps ?? params.slotProps?.input ?? {}; const startAdornment = ( <> {inputProps.startAdornment} ); const endAdornment = ( <> {loading ? : null} {inputProps.endAdornment} ); if (params.InputProps) { return ( ); } return ( ); }} 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)} ); } }