908 lines
28 KiB
JavaScript
908 lines
28 KiB
JavaScript
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>
|
||
|
||
</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)}
|
||
</>
|
||
);
|
||
}
|
||
}
|