Files
dashfront/src/routes/artikel.jsx
2026-10-09 10:42:24 +02:00

733 lines
26 KiB
JavaScript

// #region Imports
import { Component, createRef } from 'react';
import FillSpace from 'dashgui/FillSpace';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import Paper from '@mui/material/Paper';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear';
import FilterListIcon from '@mui/icons-material/FilterList';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import articleSearchAutomat from '../automats/articleSearchAutomat';
import BackendError from '../components/BackendError';
import { formatPrice, getArticleGross } from '../utils/price';
import {
buildSearchUrl,
queryFromParams,
readArtikelQuery,
writeArtikelQuery,
} from '../components/article/artikelQuery';
import ResultsPagination from '../components/article/ResultsPagination';
import {
buildFilter,
formatCategoryList,
formatStock,
getCategoryDepth,
getCategoryBreadcrumbLabel,
parseFilter,
} from '../components/article/searchFormat';
import { preserveSearchFocus } from '../components/article/searchFocus';
import { artikelDetailPath } from '../components/article/articleRoute';
import { buildCategoryPath } from '../components/category/categoryRoute';
import withSearchParams from '../components/article/withSearchParams';
import {
setLastArticleSku,
setLastSearchTerm,
setLastCategoryId,
navigationAutomat,
categoryAutomat,
companyAutomat,
} from '../automats';
import { BrandLabel } from '../components/brand';
import { SCRIM_LABEL } from '../colors';
// #endregion
// #region Styles & Measure Sample
const resultRowSx = {
alignItems: 'center',
minHeight: 64,
maxHeight: 64,
height: 64,
boxSizing: 'border-box',
py: 0,
// Keep every row one line so sample height matches real rows.
'& .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',
},
};
function ResultRowMeasureSample() {
return (
<ListItemButton
tabIndex={-1}
divider
disabled
sx={{
...resultRowSx,
pointerEvents: 'none',
userSelect: 'none',
opacity: 0.2,
}}
>
<ListItemText
primary={'\u00A0'}
secondary={
<Typography component="span" variant="body2" sx={{ visibility: 'hidden', lineHeight: '20px', height: '20px' }} noWrap>
&nbsp;
</Typography>
}
slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
/>
<Typography
variant="body2"
sx={{ fontWeight: 600, flexShrink: 0, ml: 2, visibility: 'hidden' }}
>
&nbsp;
</Typography>
</ListItemButton>
);
}
// #endregion
// #region Helpers
function initialSearchState(searchParams) {
const query = queryFromParams(searchParams);
const stored = readArtikelQuery();
const navTerm = navigationAutomat.state.lastSearchTerm;
const input =
query.target.trim().length > 0
? query.target
: (navTerm?.trim().length > 0
? navTerm
: (stored?.target?.trim().length >= 3 ? stored.target : ''));
const automatResult = articleSearchAutomat.state.result;
return {
input,
categoryFilterOpen: false,
error: automatResult.error,
categoryFacets: automatResult.categoryFacets,
totalCount: automatResult.totalCount,
recordset: automatResult.recordset,
pageSize: automatResult.pageSize,
pageCount: automatResult.pageCount,
currentPage: automatResult.currentPage,
offset: automatResult.offset,
};
}
function getCategoryTreeOrder(categoriesMap, rootCategoryId) {
const order = new Map();
let index = 0;
function traverse(parentId) {
const parentKey = String(parentId);
const children = Object.values(categoriesMap || {})
.filter((item) => item && item.parentId != null && String(item.parentId) === parentKey)
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || Number(a.id) - Number(b.id));
for (const child of children) {
order.set(String(child.id), index++);
traverse(child.id);
}
}
const rootId = rootCategoryId ?? 1;
traverse(rootId);
return order;
}
// #endregion
// #region ArtikelSearchPage
class ArtikelSearchPage extends Component {
// #region Constructor & State
constructor(props) {
super(props);
this.state = initialSearchState(props.searchParams);
this.paginationRef = createRef();
this.filterHeaderRef = createRef();
this.fillSpaceRef = createRef();
this.categoryFillSpaceRef = createRef();
}
// #endregion
// #region Measurement & Query Persistence
toggleCategoryFilter = () => {
this.setState((prev) => ({ categoryFilterOpen: !prev.categoryFilterOpen }));
};
handleMeasurement = (count) => {
if (!count || count === this.state.pageSize) return;
this.setState({ pageSize: count });
const { filter, offset } = queryFromParams(this.props.searchParams);
const activeTarget = this.state.input;
setLastSearchTerm(activeTarget, buildSearchUrl(activeTarget, filter, offset));
void articleSearchAutomat.actions.search({
target: activeTarget,
filter,
offset,
pageSize: count,
}).catch(() => {});
};
persistQuery = () => {
const query = queryFromParams(this.props.searchParams);
const target = this.state.input;
writeArtikelQuery({
target,
filter: query.filter,
offset: query.offset,
});
};
// #endregion
// #region Lifecycle
componentDidMount() {
document.addEventListener('mouseup', preserveSearchFocus, true);
setLastArticleSku(null);
// Push search results into local component state.
this.unsubSearch = articleSearchAutomat.subscribe(this, (state) => state.result);
// Initial search: kicks off index loading if idle and derives current items
const { filter: urlFilter, offset } = queryFromParams(this.props.searchParams);
const hasFilterInUrl = this.props.searchParams.has('filter');
const navCategory = navigationAutomat.state.lastCategoryId;
const filter = hasFilterInUrl ? urlFilter : (navCategory || urlFilter);
const activeTarget = this.state.input;
if (!hasFilterInUrl && navCategory) {
const syncedUrl = buildSearchUrl(activeTarget, filter, offset);
setLastSearchTerm(activeTarget, syncedUrl);
this.props.navigate(syncedUrl, { replace: true });
} else if (hasFilterInUrl) {
const selectedCats = parseFilter(urlFilter);
const primaryCatId = selectedCats.length > 0 ? selectedCats[0] : null;
setLastCategoryId(primaryCatId);
}
setLastSearchTerm(activeTarget, buildSearchUrl(activeTarget, filter, offset));
void articleSearchAutomat.actions.search({
target: activeTarget,
filter,
offset,
pageSize: this.state.pageSize,
}).catch(() => {});
}
componentWillUnmount() {
document.removeEventListener('mouseup', preserveSearchFocus, true);
this.unsubSearch?.();
this.unsubSearch = null;
this.persistQuery();
const query = queryFromParams(this.props.searchParams);
const target = this.state.input;
setLastSearchTerm(target, buildSearchUrl(target, query.filter, query.offset));
}
componentDidUpdate(prevProps, prevState) {
const { searchParams } = this.props;
const prev = prevProps.searchParams;
const target = searchParams.get('target') ?? '';
const prevTarget = prev.get('target') ?? '';
if (
this.state.categoryFilterOpen !== prevState?.categoryFilterOpen ||
searchParams.get('filter') !== prev.get('filter')
) {
requestAnimationFrame(() => {
this.fillSpaceRef.current?.measure();
this.categoryFillSpaceRef.current?.measure();
});
}
if (target !== prevTarget && target !== this.state.input) {
this.setState({ input: target });
this.fetchResults();
return;
}
if (
searchParams.get('filter') !== prev.get('filter')
|| searchParams.get('offset') !== prev.get('offset')
) {
this.fetchResults();
}
}
// #endregion
// #region Search & Navigation Handlers
updateParams = (nextTarget, nextFilter, nextOffset = 0) => {
writeArtikelQuery({
target: nextTarget,
filter: nextFilter,
offset: nextOffset,
});
const nextUrl = buildSearchUrl(nextTarget, nextFilter, nextOffset);
setLastSearchTerm(nextTarget, nextUrl);
// Synchronize category with navigationAutomat and /kategorien/
const selectedCats = parseFilter(nextFilter);
const primaryCatId = selectedCats.length > 0 ? selectedCats[0] : null;
const categoriesMap = categoryAutomat.state?.categories;
const rootCategoryId = categoryAutomat.state?.rootCategoryId;
const isRoot = !primaryCatId || (rootCategoryId != null && String(primaryCatId) === String(rootCategoryId));
const activeId = isRoot ? null : String(primaryCatId);
const catPath = activeId && categoriesMap && rootCategoryId != null
? buildCategoryPath(categoriesMap, rootCategoryId, activeId)
: '/kategorien';
setLastCategoryId(activeId, catPath);
this.props.navigate(nextUrl, {
replace: true,
});
};
handleInputChange = (event) => {
const value = event.target.value;
this.setState({ input: value });
const { filter } = queryFromParams(this.props.searchParams);
const { pageSize } = this.state;
const currentUrl = buildSearchUrl(value, filter, 0);
setLastSearchTerm(value, currentUrl);
writeArtikelQuery({ target: value, filter, offset: 0 });
this.props.navigate(currentUrl, { replace: true });
void articleSearchAutomat.actions.search({
target: value,
filter,
offset: 0,
pageSize: pageSize ?? undefined,
}).catch(() => {});
};
handleClearInput = () => {
this.handleInputChange({ target: { value: '' } });
};
fetchResults = () => {
const { target, filter, offset } = queryFromParams(this.props.searchParams);
const { pageSize } = this.state;
writeArtikelQuery({ target, filter, offset });
void articleSearchAutomat.actions.search({
target: target.trim(),
filter,
offset,
pageSize: pageSize ?? undefined,
}).catch(() => {});
};
handlePageChange = (_e, page) => {
const { pageSize } = this.state;
const safePageSize = pageSize ?? 1;
const nextOffset = (page - 1) * safePageSize;
const filterParam = this.props.searchParams.get('filter') ?? '';
this.updateParams(this.state.input, filterParam, nextOffset);
};
// #endregion
// #region Render
render() {
const {
input,
categoryFilterOpen,
error,
categoryFacets,
totalCount,
recordset,
pageSize,
pageCount,
currentPage,
offset: stateOffset,
} = this.state;
const filterParam = this.props.searchParams.get('filter') ?? '';
const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || stateOffset || 0);
const categories = parseFilter(filterParam);
const rows = recordset ?? [];
const categoriesMap = categoryAutomat.state?.categories;
const rootCategoryId = categoryAutomat.state?.rootCategoryId;
const treeOrder = getCategoryTreeOrder(categoriesMap, rootCategoryId);
const facetEntries = categoryFacets
? Object.entries(categoryFacets)
.filter(([id, meta]) => {
if (!meta?.name) return false;
if (String(id) === '1' || (rootCategoryId != null && String(id) === String(rootCategoryId))) {
return false;
}
const lower = meta.name.trim().toLowerCase();
if (lower === 'kategorie 1' || lower === 'category 1' || lower === 'home') {
return false;
}
return true;
})
.map(([id, { count, name }]) => ({
id,
count,
name,
depth: getCategoryDepth(id, categoryFacets),
}))
.sort((a, b) => {
const orderA = treeOrder.get(String(a.id));
const orderB = treeOrder.get(String(b.id));
if (orderA != null && orderB != null) return orderA - orderB;
if (orderA != null) return -1;
if (orderB != null) return 1;
return Number(a.id) - Number(b.id);
})
: [];
const showPagination =
rows.length > 0 && pageSize != null && totalCount != null && totalCount > pageSize;
return (
<>
<Box
ref={this.filterHeaderRef}
sx={{
mb: 2,
display: 'flex',
gap: 1.5,
alignItems: 'center',
minHeight: 40,
}}
>
<TextField
sx={{ maxWidth: 520, flexGrow: 1 }}
autoFocus
autoComplete="off"
placeholder="(EAN, SKU, Name)"
value={input}
onChange={this.handleInputChange}
slotProps={{
htmlInput: {
autoCapitalize: 'off',
autoCorrect: 'off',
},
input: {
sx: { height: 40 },
startAdornment: (
<InputAdornment position="start">
<SearchIcon color="action" />
</InputAdornment>
),
endAdornment: input ? (
<InputAdornment position="end">
<IconButton
size="small"
onClick={this.handleClearInput}
onMouseDown={(e) => e.preventDefault()}
title="Suche löschen"
aria-label="Suche löschen"
>
<ClearIcon fontSize="small" />
</IconButton>
</InputAdornment>
) : null,
},
}}
/>
<Button
variant={categoryFilterOpen ? 'contained' : 'outlined'}
color="inherit"
disabled={facetEntries.length === 0}
onClick={this.toggleCategoryFilter}
startIcon={<FilterListIcon />}
endIcon={categoryFilterOpen ? <ExpandLessIcon /> : <ExpandMoreIcon />}
sx={{ height: 40, whiteSpace: 'nowrap', flexShrink: 0 }}
>
Kategorien
</Button>
{categories.map((catId) => {
const label = getCategoryBreadcrumbLabel(
catId,
categoriesMap,
rootCategoryId,
categoryFacets,
);
return (
<Chip
key={catId}
label={label}
color="primary"
variant="outlined"
onDelete={() => {
const next = categories.filter((id) => id !== catId);
this.updateParams(input, buildFilter(next), 0);
}}
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
/>
);
})}
</Box>
{error && (
<BackendError
error={error}
sx={{ mb: 2 }}
onRetry={this.fetchResults}
/>
)}
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start', mb: -4 }}>
{categoryFilterOpen && facetEntries.length > 0 && (
<FillSpace
ref={this.categoryFillSpaceRef}
aboveRef={this.filterHeaderRef}
sx={{ width: 240, flexShrink: 0 }}
>
<Paper
variant="outlined"
sx={{
height: '100%',
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<Box
sx={{
px: 2,
py: 1.5,
bgcolor: 'action.hover',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexShrink: 0,
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
Kategorien
</Typography>
<IconButton
size="small"
onClick={this.toggleCategoryFilter}
title="Kategorien einklappen"
aria-label="Kategorien einklappen"
>
<ChevronLeftIcon fontSize="small" />
</IconButton>
</Box>
<List dense disablePadding sx={{ flexGrow: 1, overflowY: 'auto' }}>
{facetEntries.map(({ id, count, name, depth }) => {
const selected = categories.includes(id);
return (
<ListItemButton
key={id}
selected={selected}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
const next = selected ? [] : [id];
this.setState({ categoryFilterOpen: false });
this.updateParams(input, buildFilter(next), 0);
}}
sx={{ pl: 2 + depth * 1.5 }}
>
<ListItemText
primary={`${name} (${count})`}
slotProps={{ primary: { variant: 'body2' } }}
/>
{selected && (
<ClearIcon fontSize="small" color="action" sx={{ ml: 1, flexShrink: 0 }} />
)}
</ListItemButton>
);
})}
</List>
</Paper>
</FillSpace>
)}
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
{!error && input.trim().length > 0 && totalCount === 0 && (
<Typography color="text.secondary">Keine Artikel gefunden.</Typography>
)}
{/* Always mount real pagination height (invisible until needed) so pageSize is correct. */}
<Box ref={this.paginationRef} sx={{ mb: 2 }}>
<ResultsPagination
invisible={!showPagination}
offset={showPagination ? offset : 0}
totalCount={showPagination ? totalCount : (pageSize ?? 1) + 1}
pageSize={pageSize ?? 1}
pageCount={showPagination ? pageCount : 5}
currentPage={showPagination ? currentPage : 1}
onPageChange={this.handlePageChange}
/>
</Box>
<FillSpace
ref={this.fillSpaceRef}
aboveRef={[this.filterHeaderRef, this.paginationRef]}
onMeasurement={this.handleMeasurement}
sx={{ overflow: 'auto' }}
measureElement={<ResultRowMeasureSample />}
>
<Box>
{rows.length > 0 && (
<List component={Paper} variant="outlined" disablePadding>
{rows.map((row, index) => {
const isParent = Boolean(row.isParent || row.is_parent);
const childCount = Number(row.childCount) || 0;
const showChildBadge = isParent || childCount > 0;
const price = getArticleGross(row, companyAutomat.state.current?.taxrate);
return (
<ListItemButton
key={row.id}
type="button"
divider={index < rows.length - 1}
data-result-row
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
this.persistQuery();
this.props.navigate(artikelDetailPath(row));
}}
sx={resultRowSx}
>
<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',
}}
>
{row.name}
</Box>
{showChildBadge && (
<Box
component="span"
title={`${childCount} 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',
}}
>
{childCount}
</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',
}}
>
{(row.brand?.name || row.brandName) && (
<>
<BrandLabel
brand={row.brand || { name: row.brandName, thumbHash: row.brandThumbHash }}
name={row.brand?.name || row.brandName}
size={14}
spacing={0.5}
typographyProps={{
variant: 'body2',
color: 'text.secondary',
noWrap: true,
}}
/>
{' · '}
</>
)}
{row.sku}
{' · '}
Bestand {formatStock(row.stock)}
{' · '}
Verfügbar {formatStock(row.available)}
{row.categories
? ` · ${formatCategoryList(row.categories, categoryFacets ?? {})}`
: ''}
</Typography>
}
slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
/>
<Typography
variant="body2"
sx={{ fontWeight: 600, flexShrink: 0, ml: 2 }}
>
{price != null ? formatPrice(price) : '—'}
</Typography>
</ListItemButton>
);
})}
</List>
)}
</Box>
</FillSpace>
</Box>
</Box>
</>
);
}
// #endregion
}
// #endregion
export default withSearchParams(ArtikelSearchPage);