This commit is contained in:
seb
2026-07-19 16:37:45 +02:00
parent 17a36b2283
commit 9c20672e26
13 changed files with 481 additions and 26 deletions

View File

@@ -1,7 +1,363 @@
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';
import SearchIcon from '@mui/icons-material/Search';
import { article } from '../../api';
import { formatPrice } from '../../utils/price';
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 ?? id)
.join(', ');
}
function ResultsPagination({ offset, totalCount, pageCount, currentPage, onPageChange, sx }) {
if (totalCount <= PAGE_SIZE) return null;
export default function ArtikelUebersichtPage() {
return (
<Typography color="text.secondary">Übersicht — Inhalt folgt.</Typography>
<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"
/>
</Box>
);
}
function withSearchParams(Wrapped) {
function SearchParamsWrapper(props) {
const [searchParams, setSearchParams] = useSearchParams();
return (
<Wrapped
{...props}
searchParams={searchParams}
setSearchParams={setSearchParams}
/>
);
}
return SearchParamsWrapper;
}
class ArtikelUebersichtPage extends Component {
state = {
input: this.props.searchParams.get('target') ?? '',
error: null,
categoryFacets: null,
totalCount: 0,
recordset: [],
};
componentDidMount() {
this.fetchResults();
}
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) => {
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 });
};
handleInputChange = (event) => {
const value = event.target.value;
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 trimmed = target.trim();
if (trimmed.length < 3) {
this.setState({
categoryFacets: null,
totalCount: 0,
recordset: [],
error: null,
});
return;
}
try {
const data = await article.search({
target: trimmed,
filter: filterParam || undefined,
offset,
maxCount: PAGE_SIZE,
});
this.setState({
categoryFacets: data.categories ?? {},
totalCount: data.length ?? 0,
recordset: data.recordset ?? [],
error: null,
});
} catch (err) {
this.setState({
categoryFacets: null,
totalCount: 0,
recordset: [],
error: err.message || 'Suche fehlgeschlagen',
});
}
};
render() {
const { input, error, categoryFacets, totalCount, recordset } = this.state;
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 categories = parseFilter(filterParam);
const searchActive = target.trim().length >= 3;
const facetEntries = categoryFacets
? Object.entries(categoryFacets)
.map(([id, { count, name, parent }]) => ({
id,
count,
name,
depth: getCategoryDepth(id, categoryFacets),
}))
.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 handlePageChange = (_e, page) => {
this.updateParams(input, filterParam, (page - 1) * PAGE_SIZE);
};
return (
<>
<Box sx={{ mb: 3, maxWidth: 720 }}>
<TextField
fullWidth
autoFocus
type="search"
autoComplete="off"
inputProps={{
autocapitalize: 'off',
autocorrect: 'off',
spellcheck: false,
}}
placeholder="Artikel suchen (EAN, Artikelnr., Name, Barcode)…"
value={input}
onChange={this.handleInputChange}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon color="action" />
</InputAdornment>
),
},
}}
/>
</Box>
{input.trim().length < 3 && (
<Typography color="text.secondary">
Mindestens 3 Zeichen eingeben — mehrere Wörter werden mit UND verknüpft.
</Typography>
)}
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{searchActive && !error && totalCount === 0 && (
<Typography color="text.secondary">Keine Artikel gefunden.</Typography>
)}
{(facetEntries.length > 0 || recordset.length > 0) && (
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start' }}>
{facetEntries.length > 0 && (
<Paper variant="outlined" sx={{ width: 220, flexShrink: 0, overflow: 'hidden' }}>
<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 }) => (
<ListItemButton
key={id}
selected={categories.includes(id)}
onClick={() => {
const next = categories.includes(id)
? categories.filter((c) => c !== id)
: [...categories, id];
this.updateParams(input, buildFilter(next), 0);
}}
sx={{ pl: 2 + depth * 1.5 }}
>
<ListItemText
primary={`${name ?? id} (${count})`}
slotProps={{ primary: { variant: 'body2' } }}
/>
</ListItemButton>
))}
</List>
</Paper>
)}
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
{recordset.length > 0 && (
<>
<ResultsPagination
offset={offset}
totalCount={totalCount}
pageCount={pageCount}
currentPage={currentPage}
onPageChange={handlePageChange}
sx={{ mb: 2 }}
/>
<List component={Paper} variant="outlined" disablePadding>
{recordset.map((row) => (
<ListItemButton
key={row.id}
divider
sx={{ alignItems: 'flex-start', py: 1.5 }}
>
<ListItemText
primary={row.name}
secondary={
<Typography component="span" variant="body2" color="text.secondary">
{row.sku}
{' · '}
Bestand {formatStock(row.stock)}
{' · '}
Verfügbar {formatStock(row.available)}
{row.categories
? ` · ${formatCategoryList(row.categories, categoryFacets ?? {})}`
: ''}
</Typography>
}
slotProps={{ primary: { fontWeight: 500 } }}
/>
<Typography
variant="body2"
sx={{ fontWeight: 600, flexShrink: 0, ml: 2 }}
>
{row.price != null ? formatPrice(row.price) : '—'}
</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>
)}
</>
);
}
}
export default withSearchParams(ArtikelUebersichtPage);

View File

@@ -1,10 +0,0 @@
import Typography from '@mui/material/Typography';
import PageLayout from '../components/PageLayout';
export default function HomePage() {
return (
<PageLayout title="Home">
<Typography color="text.secondary">Home — Inhalt folgt.</Typography>
</PageLayout>
);
}