article and relayout

This commit is contained in:
seb
2026-07-19 19:39:07 +02:00
parent f012f27bf9
commit af9e072427
12 changed files with 878 additions and 209 deletions

View File

@@ -1,12 +1,10 @@
import { Component } from 'react';
import { useSearchParams } from 'react-router-dom';
import Alert from '@mui/material/Alert';
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 Pagination from '@mui/material/Pagination';
import Paper from '@mui/material/Paper';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
@@ -14,96 +12,178 @@ import SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear';
import { article } from '../../api';
import { formatPrice } from '../../utils/price';
import {
buildSearchUrl,
clearArtikelQuery,
queryFromParams,
readArtikelQuery,
writeArtikelQuery,
} from './artikelQuery';
import ResultsPagination from './ResultsPagination';
import {
buildFilter,
formatCategoryList,
formatStock,
getCategoryDepth,
parseFilter,
} from './searchFormat';
import { preserveSearchFocus } from './searchFocus';
import withSearchParams from './withSearchParams';
const PAGE_SIZE = 10;
function parseFilter(filterStr) {
if (!filterStr) return [];
return filterStr.split(',').map((s) => s.trim()).filter(Boolean);
}
function buildFilter(categories) {
return categories.length ? categories.join(',') : '';
}
function formatStock(value) {
if (value == null) return '—';
return new Intl.NumberFormat('de-DE', { maximumFractionDigits: 2 }).format(value);
}
function getCategoryDepth(id, facets) {
let depth = 0;
let parent = facets[id]?.parent;
while (parent && parent !== 0 && facets[String(parent)]) {
depth += 1;
parent = facets[String(parent)]?.parent;
}
return depth;
}
function formatCategoryList(catStr, facets) {
if (!catStr) return '';
return catStr
.split(',')
.map((id) => facets[id]?.name)
.filter(Boolean)
.join(', ');
}
function ResultsPagination({ offset, totalCount, pageCount, currentPage, onPageChange, sx }) {
if (totalCount <= PAGE_SIZE) return null;
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 (
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 1,
...sx,
}}
>
<Typography variant="body2" color="text.secondary">
{offset + 1}–{Math.min(offset + PAGE_SIZE, totalCount)} von {totalCount}
</Typography>
<Pagination
count={pageCount}
page={currentPage}
onChange={onPageChange}
color="primary"
size="small"
onMouseDown={(e) => e.preventDefault()}
<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 } }}
/>
</Box>
<Typography variant="body2" sx={{ fontWeight: 600, flexShrink: 0, ml: 2 }}>
0,00 €
</Typography>
</ListItemButton>
);
}
function withSearchParams(Wrapped) {
function SearchParamsWrapper(props) {
const [searchParams, setSearchParams] = useSearchParams();
return (
<Wrapped
{...props}
searchParams={searchParams}
setSearchParams={setSearchParams}
/>
);
}
return SearchParamsWrapper;
}
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);
class ArtikelUebersichtPage extends Component {
state = {
input: this.props.searchParams.get('target') ?? '',
return {
input,
error: null,
categoryFacets: null,
totalCount: 0,
recordset: [],
totalCount: null,
recordset: null,
pageSize: null,
};
}
class ArtikelSearchPage extends Component {
state = initialSearchState(this.props.searchParams);
listSlotRef = { current: null };
rowMeasureRef = { current: null };
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.trim().length >= 3) 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,
});
};
restoreStoredQuery = (stored) => {
this.props.navigate(
buildSearchUrl(stored.target, stored.filter ?? '', stored.offset ?? 0),
{ replace: true },
);
};
componentDidMount() {
this.fetchResults();
document.addEventListener('mousedown', preserveSearchFocus, true);
window.addEventListener('resize', this.handleWindowResize);
const query = queryFromParams(this.props.searchParams);
const stored = readArtikelQuery();
if (query.target.trim().length < 3 && stored?.target?.trim().length >= 3) {
this.restoreStoredQuery(stored);
}
}
componentWillUnmount() {
document.removeEventListener('mousedown', preserveSearchFocus, true);
window.removeEventListener('resize', this.handleWindowResize);
this.persistQuery();
}
componentDidUpdate(prevProps) {
@@ -112,6 +192,14 @@ class ArtikelUebersichtPage extends Component {
const target = searchParams.get('target') ?? '';
const prevTarget = prev.get('target') ?? '';
if (target.trim().length < 3 && prevTarget.trim().length >= 3) {
const stored = readArtikelQuery();
if (stored?.target?.trim().length >= 3) {
this.restoreStoredQuery(stored);
return;
}
}
if (target !== prevTarget && target !== this.state.input) {
this.setState({ input: target });
}
@@ -126,41 +214,74 @@ class ArtikelUebersichtPage extends Component {
}
updateParams = (nextTarget, nextFilter, nextOffset = 0) => {
const params = new URLSearchParams();
if (nextTarget) params.set('target', nextTarget);
if (nextFilter) params.set('filter', nextFilter);
if (nextOffset > 0) params.set('offset', String(nextOffset));
this.props.setSearchParams(params, { replace: true });
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 });
const belowMin = value.trim().length < 3;
if (belowMin) {
clearArtikelQuery();
// Clear results in the same update that shortens the input — otherwise the
// hint appears one paint before fetchResults (driven by URL) removes the list.
this.setState({
input: value,
categoryFacets: null,
totalCount: null,
recordset: null,
error: null,
pageSize: null,
});
} else {
this.setState({ input: value });
}
this.updateParams(value, this.props.searchParams.get('filter') ?? '', 0);
};
fetchResults = async () => {
const target = this.props.searchParams.get('target') ?? '';
const filterParam = this.props.searchParams.get('filter') ?? '';
const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || 0);
const { target, filter, offset } = queryFromParams(this.props.searchParams);
const trimmed = target.trim();
const { pageSize } = this.state;
if (trimmed.length < 3) {
this.setState({
categoryFacets: null,
totalCount: 0,
recordset: [],
error: null,
});
const { categoryFacets, totalCount, recordset, error, pageSize: ps } = this.state;
if (
categoryFacets != null
|| totalCount != null
|| recordset != null
|| error != null
|| ps != null
) {
this.setState({
categoryFacets: null,
totalCount: null,
recordset: null,
error: null,
pageSize: null,
});
}
return;
}
if (pageSize == null) return;
writeArtikelQuery({ target, filter, offset });
try {
const data = await article.search({
target: trimmed,
filter: filterParam || undefined,
filter: filter || undefined,
offset,
maxCount: PAGE_SIZE,
maxCount: pageSize,
});
this.setState({
@@ -172,20 +293,21 @@ class ArtikelUebersichtPage extends Component {
} catch (err) {
this.setState({
categoryFacets: null,
totalCount: 0,
recordset: [],
totalCount: null,
recordset: null,
error: err.message || 'Suche fehlgeschlagen',
});
}
};
render() {
const { input, error, categoryFacets, totalCount, recordset } = this.state;
const target = this.props.searchParams.get('target') ?? '';
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);
const searchActive = target.trim().length >= 3;
// Drive UI from input so hint vs results switch in one paint (URL lags one tick).
const searchActive = input.trim().length >= 3;
const rows = recordset ?? [];
const facetEntries = categoryFacets
? Object.entries(categoryFacets)
@@ -199,14 +321,14 @@ class ArtikelUebersichtPage extends Component {
.sort((a, b) => Number(a.id) - Number(b.id))
: [];
const pageCount = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
const emptyRowCount = Math.max(0, PAGE_SIZE - recordset.length);
const showResultsPanel =
facetEntries.length > 0 || recordset.length > 0 || (searchActive && !error && totalCount === 0);
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) * PAGE_SIZE);
this.updateParams(input, filterParam, (page - 1) * safePageSize);
};
return (
@@ -249,10 +371,10 @@ class ArtikelUebersichtPage extends Component {
</Alert>
)}
{showResultsPanel && (
{searchActive && (
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start' }}>
{facetEntries.length > 0 && (
<Paper variant="outlined" sx={{ width: 220, flexShrink: 0, overflow: 'hidden' }}>
<Paper variant="outlined" sx={{ width: 220, flexShrink: 0, overflow: 'auto' }}>
<Typography
variant="subtitle2"
sx={{ px: 2, py: 1.5, bgcolor: 'action.hover', fontWeight: 600 }}
@@ -287,33 +409,59 @@ class ArtikelUebersichtPage extends Component {
</Paper>
)}
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<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>
)}
{recordset.length > 0 && (
<>
<ResultsPagination
offset={offset}
totalCount={totalCount}
pageCount={pageCount}
currentPage={currentPage}
onPageChange={handlePageChange}
sx={{ mb: 2 }}
/>
{/* Always mount real pagination height (invisible until needed) so pageSize is correct. */}
<Box 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>
<Box ref={this.setListSlotRef}>
{rows.length > 0 && pageSize != null && (
<List component={Paper} variant="outlined" disablePadding>
{recordset.map((row) => (
{rows.map((row, index) => (
<ListItemButton
key={row.id}
divider
sx={{ alignItems: 'flex-start', py: 1.5 }}
type="button"
divider={index < rows.length - 1}
data-result-row
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
this.persistQuery();
this.props.navigate(`/artikel/${encodeURIComponent(row.sku)}`);
}}
sx={resultRowSx}
>
<ListItemText
primary={row.name}
secondary={
<Typography component="span" variant="body2" color="text.secondary">
<Typography
component="span"
variant="body2"
color="text.secondary"
noWrap
>
{row.sku}
{' · '}
Bestand {formatStock(row.stock)}
@@ -324,7 +472,7 @@ class ArtikelUebersichtPage extends Component {
: ''}
</Typography>
}
slotProps={{ primary: { fontWeight: 500 } }}
slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
/>
<Typography
variant="body2"
@@ -334,35 +482,9 @@ class ArtikelUebersichtPage extends Component {
</Typography>
</ListItemButton>
))}
{Array.from({ length: emptyRowCount }, (_, i) => (
<ListItemButton
key={`empty-${i}`}
divider
disabled
aria-hidden
sx={{
alignItems: 'flex-start',
py: 1.5,
visibility: 'hidden',
pointerEvents: 'none',
}}
>
<ListItemText primary="." secondary="." />
<Typography variant="body2">.</Typography>
</ListItemButton>
))}
</List>
<ResultsPagination
offset={offset}
totalCount={totalCount}
pageCount={pageCount}
currentPage={currentPage}
onPageChange={handlePageChange}
sx={{ mt: 2 }}
/>
</>
)}
)}
</Box>
</Box>
</Box>
)}
@@ -371,4 +493,4 @@ class ArtikelUebersichtPage extends Component {
}
}
export default withSearchParams(ArtikelUebersichtPage);
export default withSearchParams(ArtikelSearchPage);