461 lines
16 KiB
JavaScript
461 lines
16 KiB
JavaScript
import { Component, createRef } from 'react';
|
|
import FillSpace from 'dashgui/FillSpace';
|
|
import Box from '@mui/material/Box';
|
|
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 articleSearchAutomat from '../automats/articleSearchAutomat';
|
|
import BackendError from '../components/BackendError';
|
|
import { formatPrice } from '../utils/price';
|
|
import {
|
|
buildSearchUrl,
|
|
queryFromParams,
|
|
readArtikelQuery,
|
|
writeArtikelQuery,
|
|
} from '../components/article/artikelQuery';
|
|
import ResultsPagination from '../components/article/ResultsPagination';
|
|
import {
|
|
buildFilter,
|
|
formatCategoryList,
|
|
formatStock,
|
|
getCategoryDepth,
|
|
parseFilter,
|
|
} from '../components/article/searchFormat';
|
|
import { preserveSearchFocus } from '../components/article/searchFocus';
|
|
import { artikelDetailPath } from '../components/article/articleRoute';
|
|
import withSearchParams from '../components/article/withSearchParams';
|
|
import { setLastArticleSku } from '../automats';
|
|
import { BrandLabel } from '../components/brand';
|
|
|
|
const resultRowSx = {
|
|
alignItems: 'center',
|
|
py: 1.5,
|
|
// Keep every row one line so sample height matches real rows.
|
|
'& .MuiListItemText-root': { minWidth: 0 },
|
|
'& .MuiListItemText-primary, & .MuiListItemText-secondary': {
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
},
|
|
};
|
|
|
|
function ResultRowMeasureSample() {
|
|
return (
|
|
<ListItemButton tabIndex={-1} divider sx={resultRowSx}>
|
|
<ListItemText
|
|
primary="Measure"
|
|
secondary={
|
|
<Typography component="span" variant="body2" color="text.secondary" noWrap>
|
|
SKU · Bestand 0 · Verfügbar 0 · Kategorie
|
|
</Typography>
|
|
}
|
|
slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
|
|
/>
|
|
<Typography variant="body2" sx={{ fontWeight: 600, flexShrink: 0, ml: 2 }}>
|
|
0,00 €
|
|
</Typography>
|
|
</ListItemButton>
|
|
);
|
|
}
|
|
|
|
function initialSearchState(searchParams) {
|
|
const query = queryFromParams(searchParams);
|
|
const stored = readArtikelQuery();
|
|
const input =
|
|
query.target.trim().length >= 3
|
|
? query.target
|
|
: (stored?.target?.trim().length >= 3 ? stored.target : query.target);
|
|
|
|
return {
|
|
input,
|
|
error: null,
|
|
categoryFacets: null,
|
|
totalCount: null,
|
|
recordset: null,
|
|
pageSize: null,
|
|
};
|
|
}
|
|
|
|
class ArtikelSearchPage extends Component {
|
|
state = initialSearchState(this.props.searchParams);
|
|
listSlotRef = { current: null };
|
|
rowMeasureRef = { current: null };
|
|
paginationRef = createRef();
|
|
|
|
setListSlotRef = (node) => {
|
|
if (this.listSlotRef.current === node) return;
|
|
this.listSlotRef.current = node;
|
|
if (!node) return;
|
|
this.updatePageSize();
|
|
};
|
|
|
|
setRowMeasureRef = (node) => {
|
|
if (this.rowMeasureRef.current === node) return;
|
|
this.rowMeasureRef.current = node;
|
|
if (!node) return;
|
|
this.updatePageSize();
|
|
};
|
|
|
|
measureRowHeight = () => {
|
|
// Always use the sample — real rows used to report a different height after paint
|
|
// (wrapped text), which made resize "fix" a wrong first measure.
|
|
const sample = this.rowMeasureRef.current;
|
|
if (!sample) return { height: 0, source: 'none' };
|
|
return {
|
|
height: sample.getBoundingClientRect().height,
|
|
source: 'sample',
|
|
};
|
|
};
|
|
|
|
updatePageSize = ({ fromResize = false } = {}) => {
|
|
const slot = this.listSlotRef.current;
|
|
if (!slot) return;
|
|
|
|
// After the first measure, ignore layout churn from painting results.
|
|
// Only window resize may change pageSize and refetch.
|
|
if (this.state.pageSize != null && !fromResize) return;
|
|
|
|
const slotTop = slot.getBoundingClientRect().top;
|
|
const available = window.innerHeight - slotTop;
|
|
const { height: rowHeight, source: rowSource } = this.measureRowHeight();
|
|
if (available < 1 || rowHeight < 1) return;
|
|
|
|
// Outlined Paper around the list is 1px top + 1px bottom — not in row height.
|
|
const listChrome = 2;
|
|
const next = Math.max(1, Math.floor((available - listChrome) / rowHeight));
|
|
|
|
console.log('[pageSize] measure', {
|
|
fromResize,
|
|
prevPageSize: this.state.pageSize,
|
|
next,
|
|
available,
|
|
rowHeight,
|
|
rowSource,
|
|
listChrome,
|
|
slotTop,
|
|
realRowHeight:
|
|
slot.querySelector('[data-result-row]')?.getBoundingClientRect().height ?? null,
|
|
});
|
|
|
|
if (next === this.state.pageSize) return;
|
|
|
|
this.setState({ pageSize: next }, () => {
|
|
const target = this.props.searchParams.get('target') ?? '';
|
|
if (target) this.fetchResults();
|
|
});
|
|
};
|
|
|
|
handleWindowResize = () => {
|
|
this.updatePageSize({ fromResize: true });
|
|
};
|
|
|
|
persistQuery = () => {
|
|
const query = queryFromParams(this.props.searchParams);
|
|
const target = query.target || this.state.input;
|
|
writeArtikelQuery({
|
|
target,
|
|
filter: query.filter,
|
|
offset: query.offset,
|
|
});
|
|
};
|
|
|
|
componentDidMount() {
|
|
document.addEventListener('mouseup', preserveSearchFocus, true);
|
|
window.addEventListener('resize', this.handleWindowResize);
|
|
setLastArticleSku(null);
|
|
|
|
// Push search results into local component state.
|
|
this.unsubSearch = articleSearchAutomat.subscribe(this, (state) => state.result);
|
|
|
|
// Kick off initial search (empty input = show all products).
|
|
void articleSearchAutomat.actions.search(this.state.input).catch(() => {});
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
document.removeEventListener('mouseup', preserveSearchFocus, true);
|
|
window.removeEventListener('resize', this.handleWindowResize);
|
|
this.unsubSearch?.();
|
|
this.unsubSearch = null;
|
|
this.persistQuery();
|
|
}
|
|
|
|
componentDidUpdate(prevProps) {
|
|
const { searchParams } = this.props;
|
|
const prev = prevProps.searchParams;
|
|
const target = searchParams.get('target') ?? '';
|
|
const prevTarget = prev.get('target') ?? '';
|
|
|
|
if (target !== prevTarget && target !== this.state.input) {
|
|
this.setState({ input: target });
|
|
}
|
|
|
|
if (
|
|
target !== prevTarget
|
|
|| searchParams.get('filter') !== prev.get('filter')
|
|
|| searchParams.get('offset') !== prev.get('offset')
|
|
) {
|
|
this.fetchResults();
|
|
}
|
|
}
|
|
|
|
updateParams = (nextTarget, nextFilter, nextOffset = 0) => {
|
|
writeArtikelQuery({
|
|
target: nextTarget,
|
|
filter: nextFilter,
|
|
offset: nextOffset,
|
|
});
|
|
this.props.navigate(buildSearchUrl(nextTarget, nextFilter, nextOffset), {
|
|
replace: true,
|
|
});
|
|
};
|
|
|
|
handleInputChange = (event) => {
|
|
const value = event.target.value;
|
|
this.setState({ input: value });
|
|
void articleSearchAutomat.actions.search(value).catch(() => {});
|
|
};
|
|
|
|
fetchResults = () => {
|
|
const { target, filter, offset } = queryFromParams(this.props.searchParams);
|
|
const { pageSize } = this.state;
|
|
|
|
if (pageSize == null) return;
|
|
|
|
writeArtikelQuery({ target, filter, offset });
|
|
|
|
void articleSearchAutomat.actions.search(target.trim()).catch(() => {});
|
|
};
|
|
|
|
render() {
|
|
const { input, error, categoryFacets, totalCount, recordset, pageSize } = this.state;
|
|
const filterParam = this.props.searchParams.get('filter') ?? '';
|
|
const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || 0);
|
|
const categories = parseFilter(filterParam);
|
|
// Drive UI from input so hint vs results switch in one paint (URL lags one tick).
|
|
const searchActive = input.trim().length > 0 || (recordset ?? []).length > 0;
|
|
const rows = recordset ?? [];
|
|
|
|
const facetEntries = categoryFacets
|
|
? Object.entries(categoryFacets)
|
|
.filter(([, meta]) => meta?.name)
|
|
.map(([id, { count, name }]) => ({
|
|
id,
|
|
count,
|
|
name,
|
|
depth: getCategoryDepth(id, categoryFacets),
|
|
}))
|
|
.sort((a, b) => Number(a.id) - Number(b.id))
|
|
: [];
|
|
|
|
const safePageSize = pageSize ?? 1;
|
|
const pageCount = Math.max(1, Math.ceil((totalCount ?? 0) / safePageSize));
|
|
const currentPage = Math.floor(offset / safePageSize) + 1;
|
|
const showPagination =
|
|
rows.length > 0 && pageSize != null && totalCount != null && totalCount > pageSize;
|
|
|
|
const handlePageChange = (_e, page) => {
|
|
this.updateParams(input, filterParam, (page - 1) * safePageSize);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Box sx={{ mb: 3, maxWidth: 720 }}>
|
|
<TextField
|
|
fullWidth
|
|
autoFocus
|
|
type="search"
|
|
autoComplete="off"
|
|
placeholder="Artikel suchen (EAN, Artikelnr., Name, Barcode)…"
|
|
value={input}
|
|
onChange={this.handleInputChange}
|
|
slotProps={{
|
|
htmlInput: {
|
|
spellCheck: false,
|
|
autoCapitalize: 'off',
|
|
autoCorrect: 'off',
|
|
},
|
|
input: {
|
|
startAdornment: (
|
|
<InputAdornment position="start">
|
|
<SearchIcon color="action" />
|
|
</InputAdornment>
|
|
),
|
|
},
|
|
}}
|
|
/>
|
|
</Box>
|
|
|
|
{error && (
|
|
<BackendError
|
|
error={error}
|
|
sx={{ mb: 2 }}
|
|
onRetry={this.fetchResults}
|
|
/>
|
|
)}
|
|
|
|
{searchActive && (
|
|
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start' }}>
|
|
{facetEntries.length > 0 && (
|
|
<Paper variant="outlined" sx={{ width: 220, flexShrink: 0, overflow: 'auto' }}>
|
|
<Typography
|
|
variant="subtitle2"
|
|
sx={{ px: 2, py: 1.5, bgcolor: 'action.hover', fontWeight: 600 }}
|
|
>
|
|
Kategorien
|
|
</Typography>
|
|
<List dense disablePadding>
|
|
{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.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>
|
|
)}
|
|
|
|
<Box sx={{ flexGrow: 1, minWidth: 0, mb: -4 }}>
|
|
{/* Same column width as the list; invisible, zero layout footprint. */}
|
|
<Box aria-hidden sx={{ height: 0, overflow: 'hidden', width: '100%' }}>
|
|
<Box
|
|
ref={this.setRowMeasureRef}
|
|
sx={{ visibility: 'hidden', pointerEvents: 'none' }}
|
|
>
|
|
<ResultRowMeasureSample />
|
|
</Box>
|
|
</Box>
|
|
|
|
{searchActive && !error && 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={handlePageChange}
|
|
/>
|
|
</Box>
|
|
|
|
<FillSpace
|
|
aboveRef={this.paginationRef}
|
|
debug
|
|
sx={{ overflow: 'auto' }}
|
|
// Temporary dummy unit so the repeating fill is visible.
|
|
measureElement={
|
|
<Box
|
|
sx={{
|
|
bgcolor: '#dbeafe',
|
|
p: 2,
|
|
borderRadius: 1,
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
<Typography variant="body2">Einheit</Typography>
|
|
</Box>
|
|
}
|
|
>
|
|
<Box ref={this.setListSlotRef}>
|
|
{rows.length > 0 && (
|
|
<List component={Paper} variant="outlined" disablePadding>
|
|
{rows.map((row, index) => (
|
|
<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={row.name}
|
|
secondary={
|
|
<Typography
|
|
component="span"
|
|
variant="body2"
|
|
color="text.secondary"
|
|
noWrap
|
|
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, maxWidth: '100%' }}
|
|
>
|
|
{(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 }}
|
|
>
|
|
{row.price != null ? formatPrice(row.price) : '—'}
|
|
</Typography>
|
|
</ListItemButton>
|
|
))}
|
|
</List>
|
|
)}
|
|
</Box>
|
|
</FillSpace>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
}
|
|
|
|
export default withSearchParams(ArtikelSearchPage);
|