Compare commits

...

3 Commits

Author SHA1 Message Date
seb
454b802943 u 2026-10-10 02:48:03 +02:00
seb
909898a3c7 u 2026-10-10 02:40:45 +02:00
seb
c5b46f11f1 u 2026-10-10 02:37:38 +02:00
7 changed files with 460 additions and 472 deletions

View File

@@ -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 = {
@@ -90,385 +88,25 @@ function SearchRowMeasureSample() {
}
/**
* 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.
* 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.
*/
export default class ArticleSearch extends PureComponent {
headerRef = createRef();
paginationRef = createRef();
listContainerRef = createRef();
contentContainerRef = createRef();
fillSpaceRef = createRef();
const searchRowsContext = { busySku: null, actionLabel: 'Auswählen', onSelect: null };
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,
};
}
/**
* 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;
componentDidMount() {
this.unsubIndex = articleIndexAutomat.subscribe(this, (state) => ({
indexStatus: state.status,
}));
// Data-free probe (`item` undefined): the fixed-height measure sample.
if (!article) return <SearchRowMeasureSample />;
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 = '',
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,
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 }}>
<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>
<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;
@@ -481,10 +119,9 @@ export default class ArticleSearch extends PureComponent {
return (
<ListItemButton
key={article.id || article.sku}
divider
disabled={Boolean(busySku)}
onClick={() => this.handleSelectArticle(article)}
onClick={() => onSelect?.(article)}
sx={searchResultRowSx}
>
<ListItemAvatar sx={{ minWidth: 48 }}>
@@ -612,7 +249,7 @@ export default class ArticleSearch extends PureComponent {
disabled={Boolean(busySku)}
onClick={(e) => {
e.stopPropagation();
this.handleSelectArticle(article);
onSelect?.(article);
}}
sx={{
fontSize: '0.75rem',
@@ -627,10 +264,339 @@ export default class ArticleSearch extends PureComponent {
</Box>
</ListItemButton>
);
})}
</List>
}
}
/**
* 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>
)}
</FillSpace>
<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>
);
}

View File

@@ -128,7 +128,7 @@ class ArtikelMarke extends PureComponent {
'&:hover': disabled ? undefined : { opacity: 0.8 },
}}
>
<BrandThumb brand={brand} size={40} />
<BrandThumb brand={brand}/>
</Box>
) : (
<IconButton

View File

@@ -10,7 +10,7 @@ export default class BrandThumb extends PureComponent {
render() {
const {
brand,
size = 20,
size = 40,
fallback = null,
sx,
} = this.props;

View File

@@ -5,6 +5,12 @@ import ButtonBase from '@mui/material/ButtonBase';
import Typography from '@mui/material/Typography';
import { TILE_SIZE, Tile, tileBaseSx } from 'dashgui/tiles';
import { formatPrice, getArticleGross } from '../../utils/price';
import {
getCachedImage,
getThumbHashDataUrl,
isImageLoaded,
markImageLoaded,
} from '../../utils/thumbHashCache';
import companyAutomat from '../../automats/companyAutomat';
import { articleSku } from '../article/articleRoute';
import { BrandLabel } from '../brand';

View File

@@ -12,6 +12,11 @@ import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import DeleteForeverOutlinedIcon from '@mui/icons-material/DeleteForeverOutlined';
import { thumb } from '../../api';
import { tileBaseSx } from 'dashgui/tiles';
import {
getThumbHashDataUrl,
isImageLoaded,
markImageLoaded,
} from '../../utils/thumbHashCache';
import {
RED_TINT,
SCRIM_BADGE,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
import { thumbHashToDataURL } from 'thumbhash';
// Module-scoped caches are also stashed on `globalThis` so they survive Vite
// HMR reloads instead of re-decoding / re-fetching every image.
const dataUrlCache = (globalThis.__THUMB_DATA_URL_CACHE__ ??= new Map());
const loadedImageHashes = (globalThis.__LOADED_IMAGE_HASHES__ ??= new Set());
export const imageCache = (globalThis.__IMAGE_CACHE__ ??= new Map());
/** True once the full image for `fullHash` has been loaded (data URL or `<img>`). */
export function isImageLoaded(fullHash) {
if (!fullHash) return false;
return loadedImageHashes.has(fullHash) || imageCache.has(fullHash);
}
/** Records that `fullHash` is loaded, optionally caching the decoded `<img>`. */
export function markImageLoaded(fullHash, img) {
if (!fullHash) return;
loadedImageHashes.add(fullHash);
if (img) {
imageCache.set(fullHash, img);
}
}
/** Returns the cached, already-decoded `<img>` for `fullHash`, or `null`. */
export function getCachedImage(fullHash) {
if (!fullHash) return null;
const img = imageCache.get(fullHash);
if (img && img.complete && img.naturalWidth) {
return img;
}
return null;
}
/** Decodes a ThumbHash string into a data URL (cached per hash). */
export function getThumbHashDataUrl(thumbHash) {
if (!thumbHash || typeof thumbHash !== 'string') return null;
let url = dataUrlCache.get(thumbHash);
if (!url) {
try {
const bytes = Uint8Array.from(atob(thumbHash), (c) => c.charCodeAt(0));
url = thumbHashToDataURL(bytes);
dataUrlCache.set(thumbHash, url);
} catch (err) {
console.warn('[thumbHashCache] thumbHashToDataURL failed:', err);
return null;
}
}
return url;
}