pwa
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user