This commit is contained in:
seb
2026-10-05 10:33:18 +02:00
parent 5b4cf1683a
commit 8c5419c89e
15 changed files with 1613 additions and 320 deletions

View File

@@ -0,0 +1,878 @@
import { PureComponent, createRef } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import Dialog from '@mui/material/Dialog';
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 List from '@mui/material/List';
import ListItemAvatar from '@mui/material/ListItemAvatar';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import TextField from '@mui/material/TextField';
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 ResultsPagination from './ResultsPagination';
import { BORDER } 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',
},
'& .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>
);
}
/**
* 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 {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]
* @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();
paginationRef = createRef();
listContainerRef = createRef();
contentContainerRef = createRef();
fillSpaceRef = createRef();
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,
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, this.state.offset);
}
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, 0);
}
}
componentWillUnmount() {
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) => {
const {
excludeSkus = [],
allowParent = false,
categories = [],
} = this.props;
const pageSize = customPageSize ?? this.state.pageSize;
const res = searchArticlesIndex(queryText, {
excludeSkus,
allowParent,
categories,
offset: newOffset,
pageSize,
});
this.setState({
results: Array.isArray(res) ? res : (res.recordset ?? []),
totalCount: res.totalCount ?? res.length,
pageCount: res.pageCount ?? 1,
currentPage: res.currentPage ?? 1,
offset: newOffset,
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();
});
};
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 = 'Artikel suchen…',
label,
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 (
<TextField
fullWidth
type="search"
autoComplete="off"
size={size}
label={label}
placeholder={placeholder || 'Artikel suchen (EAN, Artikelnr., Name, Barcode)…'}
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();
}
}}
slotProps={{
htmlInput: {
autoCapitalize: 'off',
autoCorrect: 'off',
},
input: {
readOnly: true,
sx: {
height: 40,
cursor: disabled ? 'default' : 'pointer',
textOverflow: 'ellipsis',
'& input': {
cursor: disabled ? 'default' : 'pointer',
textOverflow: 'ellipsis',
},
},
startAdornment: (
<InputAdornment position="start">
<SearchIcon 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: 18 }} />
</IconButton>
</InputAdornment>
) : null,
},
}}
sx={{
cursor: disabled ? 'default' : 'pointer',
...sx,
}}
/>
);
}
renderListContent() {
const {
placeholder = 'Artikel nach SKU, Name oder EAN suchen…',
autoFocus = true,
actionLabel = 'Auswählen',
} = this.props;
const {
query,
results,
busySku,
error,
offset,
pageSize,
totalCount,
pageCount,
currentPage,
} = this.state;
const loading = this.state.indexStatus === 'loading';
const showPagination = totalCount > pageSize;
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 }}>
<TextField
autoFocus={autoFocus}
fullWidth
type="search"
autoComplete="off"
placeholder={placeholder || 'Artikel suchen (EAN, Artikelnr., Name, Barcode)…'}
value={query}
onChange={(e) => {
const val = e.target.value;
this.setState({ query: val, offset: 0 });
this.runQuery(val, 0);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && results.length > 0) {
e.preventDefault();
this.handleSelectArticle(results[0]);
}
}}
disabled={Boolean(busySku)}
slotProps={{
htmlInput: {
autoCapitalize: 'off',
autoCorrect: 'off',
},
input: {
sx: { height: 40 },
startAdornment: (
<InputAdornment position="start">
<SearchIcon color="action" />
</InputAdornment>
),
endAdornment: query ? (
<InputAdornment position="end">
<IconButton
size="small"
onClick={() => {
this.setState({ query: '', offset: 0 });
this.runQuery('', 0);
}}
edge="end"
>
<CloseIcon sx={{ fontSize: 18 }} />
</IconButton>
</InputAdornment>
) : null,
},
}}
/>
</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 />}
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={article.name || '–'}
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>
);
}
renderDialogModal(isControlled) {
const {
open: controlledOpen,
title = this.props.label || 'Artikel suchen',
} = this.props;
const { internalOpen } = this.state;
const isDialogOpen = isControlled ? Boolean(controlledOpen) : internalOpen;
return (
<Dialog
open={isDialogOpen}
onClose={this.handleCloseDialog}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
height: '80vh',
minHeight: '80vh',
maxHeight: '80vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
},
}}
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>
</Dialog>
);
}
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>
</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 (
<TextField
{...params}
autoFocus={autoFocus}
label={label}
placeholder={placeholder}
InputProps={{
...params.InputProps,
startAdornment,
endAdornment,
}}
/>
);
}
return (
<TextField
{...params}
autoFocus={autoFocus}
label={label}
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)}
</>
);
}
}

View File

@@ -18,6 +18,7 @@ import MenuItem from '@mui/material/MenuItem';
import Tab from '@mui/material/Tab';
import Tabs from '@mui/material/Tabs';
import TextInput from '../../TextInput';
import ArticleSearch from '../ArticleSearch';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import AddIcon from '@mui/icons-material/Add';
@@ -611,6 +612,7 @@ export default class ArticleViewLists extends PureComponent {
};
renderStuecklisteContent(stuecklisteItems, selectableOtherArticles, isStueckliste = true) {
const { article } = this.props;
const { busy, stuecklisteTargetArticle, stuecklisteCount } = this.state;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, flex: 1, minHeight: 0 }}>
@@ -663,23 +665,14 @@ export default class ArticleViewLists extends PureComponent {
{/* Add Component Row */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
<Autocomplete
<ArticleSearch
size="small"
options={selectableOtherArticles}
label="Artikel"
placeholder="Artikel suchen..."
value={stuecklisteTargetArticle}
disabled={busy}
onChange={(_e, val) => this.setState({ stuecklisteTargetArticle: val })}
getOptionLabel={(opt) =>
typeof opt === 'string' ? opt : (opt ? `${opt.sku} - ${opt.name || ''}` : '')
}
isOptionEqualToValue={(opt, val) => opt?.sku === val?.sku}
renderInput={(params) => (
<TextInput
{...params}
label="Artikel"
placeholder="Artikel suchen..."
/>
)}
excludeSkus={[article?.sku, ...stuecklisteItems.map((item) => item.component_sku || item.sku)]}
onSelect={(val) => this.setState({ stuecklisteTargetArticle: val })}
sx={{ flex: 1, minWidth: 0 }}
/>
@@ -807,6 +800,7 @@ export default class ArticleViewLists extends PureComponent {
}
renderOptionsContent(optionsItems, selectableOtherArticles, hasOptions = true) {
const { article } = this.props;
const { busy, optionTargetArticle, optionCount, optionMandated } = this.state;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, flex: 1, minHeight: 0 }}>
@@ -860,23 +854,14 @@ export default class ArticleViewLists extends PureComponent {
{/* Add Option Row */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
<Autocomplete
<ArticleSearch
size="small"
options={selectableOtherArticles}
label="Option"
placeholder="Option suchen..."
value={optionTargetArticle}
disabled={busy}
onChange={(_e, val) => this.setState({ optionTargetArticle: val })}
getOptionLabel={(opt) =>
typeof opt === 'string' ? opt : (opt ? `${opt.sku} - ${opt.name || ''}` : '')
}
isOptionEqualToValue={(opt, val) => opt?.sku === val?.sku}
renderInput={(params) => (
<TextInput
{...params}
label="Option"
placeholder="Option suchen..."
/>
)}
excludeSkus={[article?.sku, ...optionsItems.map((item) => item.component_sku || item.sku)]}
onSelect={(val) => this.setState({ optionTargetArticle: val })}
sx={{ flex: 1, minWidth: 0 }}
/>

View File

@@ -4,3 +4,4 @@ export { default as ArticlePhotosDialog } from './ArticlePhotosDialog';
export { default as ArticleImageSearchDialog } from './ArticleImageSearchDialog';
export { default as ArticleTileTab } from './ArticleTileTab';
export { default as NewArticleTile } from './NewArticleTile';
export { default as ArticleSearch } from './ArticleSearch';

View File

@@ -1,3 +1,5 @@
import { getCategoryBreadcrumbs, ROOT_CATEGORY_NAME } from '../../utils/categoryUtils';
export function parseFilter(filterStr) {
if (!filterStr) return [];
return filterStr.split(',').map((s) => s.trim()).filter(Boolean);
@@ -15,13 +17,62 @@ export function formatStock(value) {
export function getCategoryDepth(id, facets) {
let depth = 0;
let parent = facets[id]?.parent;
while (parent && parent !== 0 && facets[String(parent)]) {
while (
parent &&
parent !== 0 &&
String(parent) !== '1' &&
facets[String(parent)]
) {
depth += 1;
parent = facets[String(parent)]?.parent;
}
return depth;
}
export function getCategoryBreadcrumbLabel(catId, categories, rootCategoryId, facets) {
if (categories && categories[String(catId)]) {
const crumbs = getCategoryBreadcrumbs(categories, catId, rootCategoryId);
const visible = crumbs.filter((c) => {
if (String(c.id) === '1') return false;
if (rootCategoryId != null && String(c.id) === String(rootCategoryId)) return false;
if (!c.name) return false;
const lower = c.name.trim().toLowerCase();
if (lower === 'home' || lower === 'kategorie 1' || lower === 'category 1') return false;
return true;
});
if (visible.length > 0) {
return visible.map((c) => c.name).join(' › ');
}
}
if (facets && facets[String(catId)]) {
const chain = [];
let cur = String(catId);
const seen = new Set();
while (cur && !seen.has(cur) && facets[cur]) {
seen.add(cur);
const meta = facets[cur];
const lower = (meta.name ?? '').trim().toLowerCase();
if (
cur !== '1' &&
(rootCategoryId == null || cur !== String(rootCategoryId)) &&
lower !== 'home' &&
lower !== 'kategorie 1' &&
lower !== 'category 1'
) {
chain.unshift(meta.name);
}
cur = meta.parent ? String(meta.parent) : null;
}
if (chain.length > 0) {
return chain.join(' › ');
}
return facets[String(catId)]?.name ?? `Kategorie ${catId}`;
}
return `Kategorie ${catId}`;
}
export function formatCategoryList(catStr, facets) {
if (!catStr) return '';
return catStr