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

@@ -8,6 +8,7 @@ import SectionLayout from './components/SectionLayout';
import ArtikelUebersichtPage from './pages/artikel/index'; import ArtikelUebersichtPage from './pages/artikel/index';
import ArtikelKategorienPage from './pages/artikel/kategorien'; import ArtikelKategorienPage from './pages/artikel/kategorien';
import ArtikelLagerPage from './pages/artikel/lager'; import ArtikelLagerPage from './pages/artikel/lager';
import ArtikelSkuPage from './pages/artikel/sku';
import LieferungPage from './pages/lieferung/index'; import LieferungPage from './pages/lieferung/index';
import AuftragAktivePage from './pages/auftrag/index'; import AuftragAktivePage from './pages/auftrag/index';
import AuftragArchivPage from './pages/auftrag/archiv'; import AuftragArchivPage from './pages/auftrag/archiv';
@@ -50,6 +51,7 @@ export default function App() {
<Route index element={<ArtikelUebersichtPage />} /> <Route index element={<ArtikelUebersichtPage />} />
<Route path="kategorien" element={<ArtikelKategorienPage />} /> <Route path="kategorien" element={<ArtikelKategorienPage />} />
<Route path="lager" element={<ArtikelLagerPage />} /> <Route path="lager" element={<ArtikelLagerPage />} />
<Route path=":sku" element={<ArtikelSkuPage />} />
</Route> </Route>
<Route path="auftrag" element={<SectionLayout sectionPath="/auftrag" />}> <Route path="auftrag" element={<SectionLayout sectionPath="/auftrag" />}>
<Route index element={<AuftragAktivePage />} /> <Route index element={<AuftragAktivePage />} />

View File

@@ -36,6 +36,11 @@ export const article = {
if (maxCount != null) params.set('maxCount', String(maxCount)); if (maxCount != null) params.set('maxCount', String(maxCount));
return http(`/api/article/search?${params}`); return http(`/api/article/search?${params}`);
}, },
get({ target } = {}) {
const params = new URLSearchParams({ target });
return http(`/api/article/get?${params}`);
},
}; };
export const auth = { export const auth = {

View File

@@ -3,26 +3,19 @@ import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton'; import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText'; import ListItemText from '@mui/material/ListItemText';
import { NavLink, Outlet } from 'react-router-dom'; import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import UserMenu from './UserMenu'; import UserMenu from './UserMenu';
import { NAV_ITEMS } from '../nav'; import { NAV_ITEMS } from '../nav';
import { artikelQueryPath } from '../pages/artikel/artikelQuery';
const SIDEBAR_WIDTH = 144; const SIDEBAR_WIDTH = 144;
const FOCUSABLE =
'a, button, input, textarea, select, [role="button"], [role="menuitem"], [tabindex]:not([tabindex="-1"]), [contenteditable="true"]';
function preserveFocusOnBackground(e) {
if (e.target.closest(FOCUSABLE)) return;
e.preventDefault();
}
export default function AppShell() { export default function AppShell() {
const navigate = useNavigate();
const { pathname } = useLocation();
return ( return (
<Box <Box sx={{ display: 'flex', minHeight: '100vh', bgcolor: 'background.default' }}>
onMouseDown={preserveFocusOnBackground}
sx={{ display: 'flex', minHeight: '100vh', bgcolor: 'background.default' }}
>
<Box <Box
component="nav" component="nav"
sx={(theme) => ({ sx={(theme) => ({
@@ -34,37 +27,48 @@ export default function AppShell() {
})} })}
> >
<List disablePadding sx={{ flexGrow: 1 }}> <List disablePadding sx={{ flexGrow: 1 }}>
{NAV_ITEMS.map(({ path, label, icon: Icon }) => ( {NAV_ITEMS.map(({ path, label, icon: Icon }) => {
<ListItemButton const active = pathname === path || pathname.startsWith(`${path}/`);
key={path}
component={NavLink} return (
to={path} <ListItemButton
onMouseDown={(e) => e.preventDefault()} key={path}
sx={(theme) => ({ type="button"
borderRadius: 0, selected={active}
px: 1.5, onMouseDown={(e) => e.preventDefault()}
py: 1.25, onClick={() => {
borderLeft: '3px solid transparent', navigate(path === '/artikel' ? artikelQueryPath() : path);
color: theme.nav.item, }}
'& .MuiListItemIcon-root': { color: 'inherit' }, sx={(theme) => ({
'&:hover': { borderRadius: 0,
bgcolor: theme.nav.itemHover, px: 1.5,
color: theme.nav.itemHoverText, py: 1.25,
}, borderLeft: '3px solid transparent',
'&.active': { color: theme.nav.item,
bgcolor: theme.nav.itemActive, '& .MuiListItemIcon-root': { color: 'inherit' },
color: theme.nav.itemActiveText, '&:hover': {
fontWeight: 600, bgcolor: theme.nav.itemHover,
borderLeftColor: theme.nav.itemActiveAccent, color: theme.nav.itemHoverText,
}, },
})} '&.Mui-selected': {
> bgcolor: theme.nav.itemActive,
<ListItemIcon sx={{ minWidth: 36 }}> color: theme.nav.itemActiveText,
<Icon /> fontWeight: 600,
</ListItemIcon> borderLeftColor: theme.nav.itemActiveAccent,
<ListItemText primary={label} /> '&:hover': {
</ListItemButton> bgcolor: theme.nav.itemActive,
))} color: theme.nav.itemActiveText,
},
},
})}
>
<ListItemIcon sx={{ minWidth: 36 }}>
<Icon />
</ListItemIcon>
<ListItemText primary={label} />
</ListItemButton>
);
})}
</List> </List>
<UserMenu /> <UserMenu />

View File

@@ -2,12 +2,27 @@ import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { NavLink } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { artikelQueryPath } from '../pages/artikel/artikelQuery';
const SUB_NAV_WIDTH = 80; const SUB_NAV_WIDTH = 80;
function isSubNavActive(pathname, basePath, segment, knownSegments) {
if (!segment) {
if (pathname === basePath || pathname === `${basePath}/`) return true;
// /artikel/:sku detail — highlight overview, not kategorien/lager
const rest = pathname.slice(basePath.length).replace(/^\//, '');
return Boolean(rest) && !rest.includes('/') && !knownSegments.has(rest);
}
const subPath = `${basePath}/${segment}`;
return pathname === subPath || pathname === `${subPath}/`;
}
export default function PageLayout({ title, subNav, basePath, children }) { export default function PageLayout({ title, subNav, basePath, children }) {
const hasSubNav = subNav?.length > 0; const hasSubNav = subNav?.length > 0;
const navigate = useNavigate();
const { pathname } = useLocation();
const knownSegments = new Set((subNav ?? []).map((item) => item.segment).filter(Boolean));
return ( return (
<Box sx={{ display: 'flex', gap: 2, minHeight: 0 }}> <Box sx={{ display: 'flex', gap: 2, minHeight: 0 }}>
@@ -25,31 +40,33 @@ export default function PageLayout({ title, subNav, basePath, children }) {
> >
{hasSubNav && {hasSubNav &&
subNav.map(({ segment, label, icon: Icon }) => { subNav.map(({ segment, label, icon: Icon }) => {
const to = segment ? `${basePath}/${segment}` : basePath; const active = isSubNavActive(pathname, basePath, segment, knownSegments);
return ( return (
<Tooltip key={segment || 'index'} title={label} placement="right"> <Tooltip key={segment || 'index'} title={label} placement="right">
<IconButton <IconButton
component={NavLink} type="button"
to={to}
end={!segment}
aria-label={label} aria-label={label}
aria-current={active ? 'page' : undefined}
disableRipple disableRipple
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => {
if (!segment && basePath === '/artikel') {
navigate(artikelQueryPath());
return;
}
navigate(segment ? `${basePath}/${segment}` : basePath);
}}
sx={(theme) => ({ sx={(theme) => ({
width: 80, width: 80,
height: 80, height: 80,
borderRadius: '50%', borderRadius: '50%',
color: theme.palette.text.secondary, color: active ? theme.palette.primary.main : theme.palette.text.secondary,
bgcolor: 'transparent', bgcolor: 'transparent',
'&:hover': { '&:hover': {
bgcolor: theme.palette.action.hover, bgcolor: theme.palette.action.hover,
color: theme.palette.primary.main, color: theme.palette.primary.main,
}, },
'&.active': {
bgcolor: 'transparent',
color: theme.palette.primary.main,
},
})} })}
> >
<Icon sx={{ fontSize: 40 }} /> <Icon sx={{ fontSize: 40 }} />

View File

@@ -10,7 +10,15 @@ export default function SectionLayout({ sectionPath }) {
const subPath = item.segment ? `${sectionPath}/${item.segment}` : sectionPath; const subPath = item.segment ? `${sectionPath}/${item.segment}` : sectionPath;
return pathname === subPath || pathname === `${subPath}/`; return pathname === subPath || pathname === `${subPath}/`;
}); });
const pageTitle = activeSub && subNav ? activeSub.label : title;
const knownSegments = new Set((subNav ?? []).map((item) => item.segment).filter(Boolean));
const rest = pathname.slice(sectionPath.length).replace(/^\//, '').split('/')[0];
const detailSku =
sectionPath === '/artikel' && rest && !knownSegments.has(rest)
? decodeURIComponent(rest)
: null;
const pageTitle = detailSku ?? (activeSub && subNav ? activeSub.label : title);
return ( return (
<PageLayout title={pageTitle} subNav={subNav} basePath={sectionPath}> <PageLayout title={pageTitle} subNav={subNav} basePath={sectionPath}>

View File

@@ -0,0 +1,56 @@
import Box from '@mui/material/Box';
import Pagination from '@mui/material/Pagination';
import Typography from '@mui/material/Typography';
/** Shared with the search page layout reserve / touch targets. */
export const PAGINATION_ITEM_SIZE = 44;
export default function ResultsPagination({
offset,
totalCount,
pageSize,
pageCount,
currentPage,
onPageChange,
/** Keep layout height without showing controls (for page-size measure). */
invisible = false,
sx,
}) {
if (!invisible && (totalCount == null || totalCount <= pageSize)) return null;
return (
<Box
aria-hidden={invisible || undefined}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 1,
width: '100%',
visibility: invisible ? 'hidden' : 'visible',
pointerEvents: invisible ? 'none' : 'auto',
...sx,
}}
>
<Typography variant="body2" color="text.secondary">
{offset + 1}–{Math.min(offset + pageSize, totalCount)} von {totalCount}
</Typography>
<Pagination
count={pageCount}
page={currentPage}
onChange={onPageChange}
color="primary"
size="large"
onMouseDown={(e) => e.preventDefault()}
sx={{
'& .MuiPaginationItem-root': {
minWidth: PAGINATION_ITEM_SIZE,
height: PAGINATION_ITEM_SIZE,
fontSize: '1rem',
},
}}
/>
</Box>
);
}

View File

@@ -0,0 +1,57 @@
const ARTIKEL_QUERY_KEY = 'dashfront:artikel-query';
export function queryFromParams(searchParams) {
return {
target: searchParams.get('target') ?? '',
filter: searchParams.get('filter') ?? '',
offset: Math.max(0, Number(searchParams.get('offset')) || 0),
};
}
export function buildSearchUrl(target, filter = '', offset = 0) {
const params = new URLSearchParams();
if (target) params.set('target', target);
if (filter) params.set('filter', filter);
if (offset > 0) params.set('offset', String(offset));
const qs = params.toString();
return qs ? `/artikel?${qs}` : '/artikel';
}
export function readArtikelQuery() {
try {
const raw = localStorage.getItem(ARTIKEL_QUERY_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
export function writeArtikelQuery({ target, filter = '', offset = 0 }) {
try {
if (!target || target.trim().length < 3) {
localStorage.removeItem(ARTIKEL_QUERY_KEY);
return;
}
localStorage.setItem(
ARTIKEL_QUERY_KEY,
JSON.stringify({ target, filter, offset }),
);
} catch {
// ignore quota / private mode
}
}
export function clearArtikelQuery() {
try {
localStorage.removeItem(ARTIKEL_QUERY_KEY);
} catch {
// ignore
}
}
/** Build `/artikel?target=…` from the last stored query, or plain `/artikel`. */
export function artikelQueryPath() {
const query = readArtikelQuery();
if (!query?.target || query.target.trim().length < 3) return '/artikel';
return buildSearchUrl(query.target, query.filter ?? '', query.offset ?? 0);
}

View File

@@ -1,12 +1,10 @@
import { Component } from 'react'; import { Component } from 'react';
import { useSearchParams } from 'react-router-dom';
import Alert from '@mui/material/Alert'; import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import InputAdornment from '@mui/material/InputAdornment'; import InputAdornment from '@mui/material/InputAdornment';
import List from '@mui/material/List'; import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton'; import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText'; import ListItemText from '@mui/material/ListItemText';
import Pagination from '@mui/material/Pagination';
import Paper from '@mui/material/Paper'; import Paper from '@mui/material/Paper';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography'; 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 ClearIcon from '@mui/icons-material/Clear';
import { article } from '../../api'; import { article } from '../../api';
import { formatPrice } from '../../utils/price'; 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; const resultRowSx = {
alignItems: 'center',
function parseFilter(filterStr) { py: 1.5,
if (!filterStr) return []; // Keep every row one line so sample height matches real rows.
return filterStr.split(',').map((s) => s.trim()).filter(Boolean); '& .MuiListItemText-root': { minWidth: 0 },
} '& .MuiListItemText-primary, & .MuiListItemText-secondary': {
overflow: 'hidden',
function buildFilter(categories) { textOverflow: 'ellipsis',
return categories.length ? categories.join(',') : ''; whiteSpace: 'nowrap',
} },
};
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;
function ResultRowMeasureSample() {
return ( return (
<Box <ListItemButton tabIndex={-1} divider sx={resultRowSx}>
sx={{ <ListItemText
display: 'flex', primary="Measure"
justifyContent: 'space-between', secondary={
alignItems: 'center', <Typography component="span" variant="body2" color="text.secondary" noWrap>
flexWrap: 'wrap', SKU · Bestand 0 · Verfügbar 0 · Kategorie
gap: 1, </Typography>
...sx, }
}} slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
>
<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()}
/> />
</Box> <Typography variant="body2" sx={{ fontWeight: 600, flexShrink: 0, ml: 2 }}>
0,00 €
</Typography>
</ListItemButton>
); );
} }
function withSearchParams(Wrapped) { function initialSearchState(searchParams) {
function SearchParamsWrapper(props) { const query = queryFromParams(searchParams);
const [searchParams, setSearchParams] = useSearchParams(); const stored = readArtikelQuery();
return ( const input =
<Wrapped query.target.trim().length >= 3
{...props} ? query.target
searchParams={searchParams} : (stored?.target?.trim().length >= 3 ? stored.target : query.target);
setSearchParams={setSearchParams}
/>
);
}
return SearchParamsWrapper;
}
class ArtikelUebersichtPage extends Component { return {
state = { input,
input: this.props.searchParams.get('target') ?? '',
error: null, error: null,
categoryFacets: null, categoryFacets: null,
totalCount: 0, totalCount: null,
recordset: [], 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() { 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) { componentDidUpdate(prevProps) {
@@ -112,6 +192,14 @@ class ArtikelUebersichtPage extends Component {
const target = searchParams.get('target') ?? ''; const target = searchParams.get('target') ?? '';
const prevTarget = prev.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) { if (target !== prevTarget && target !== this.state.input) {
this.setState({ input: target }); this.setState({ input: target });
} }
@@ -126,41 +214,74 @@ class ArtikelUebersichtPage extends Component {
} }
updateParams = (nextTarget, nextFilter, nextOffset = 0) => { updateParams = (nextTarget, nextFilter, nextOffset = 0) => {
const params = new URLSearchParams(); writeArtikelQuery({
if (nextTarget) params.set('target', nextTarget); target: nextTarget,
if (nextFilter) params.set('filter', nextFilter); filter: nextFilter,
if (nextOffset > 0) params.set('offset', String(nextOffset)); offset: nextOffset,
this.props.setSearchParams(params, { replace: true }); });
this.props.navigate(buildSearchUrl(nextTarget, nextFilter, nextOffset), {
replace: true,
});
}; };
handleInputChange = (event) => { handleInputChange = (event) => {
const value = event.target.value; 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); this.updateParams(value, this.props.searchParams.get('filter') ?? '', 0);
}; };
fetchResults = async () => { fetchResults = async () => {
const target = this.props.searchParams.get('target') ?? ''; const { target, filter, offset } = queryFromParams(this.props.searchParams);
const filterParam = this.props.searchParams.get('filter') ?? '';
const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || 0);
const trimmed = target.trim(); const trimmed = target.trim();
const { pageSize } = this.state;
if (trimmed.length < 3) { if (trimmed.length < 3) {
this.setState({ const { categoryFacets, totalCount, recordset, error, pageSize: ps } = this.state;
categoryFacets: null, if (
totalCount: 0, categoryFacets != null
recordset: [], || totalCount != null
error: null, || recordset != null
}); || error != null
|| ps != null
) {
this.setState({
categoryFacets: null,
totalCount: null,
recordset: null,
error: null,
pageSize: null,
});
}
return; return;
} }
if (pageSize == null) return;
writeArtikelQuery({ target, filter, offset });
try { try {
const data = await article.search({ const data = await article.search({
target: trimmed, target: trimmed,
filter: filterParam || undefined, filter: filter || undefined,
offset, offset,
maxCount: PAGE_SIZE, maxCount: pageSize,
}); });
this.setState({ this.setState({
@@ -172,20 +293,21 @@ class ArtikelUebersichtPage extends Component {
} catch (err) { } catch (err) {
this.setState({ this.setState({
categoryFacets: null, categoryFacets: null,
totalCount: 0, totalCount: null,
recordset: [], recordset: null,
error: err.message || 'Suche fehlgeschlagen', error: err.message || 'Suche fehlgeschlagen',
}); });
} }
}; };
render() { render() {
const { input, error, categoryFacets, totalCount, recordset } = this.state; const { input, error, categoryFacets, totalCount, recordset, pageSize } = this.state;
const target = this.props.searchParams.get('target') ?? '';
const filterParam = this.props.searchParams.get('filter') ?? ''; const filterParam = this.props.searchParams.get('filter') ?? '';
const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || 0); const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || 0);
const categories = parseFilter(filterParam); 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 const facetEntries = categoryFacets
? Object.entries(categoryFacets) ? Object.entries(categoryFacets)
@@ -199,14 +321,14 @@ class ArtikelUebersichtPage extends Component {
.sort((a, b) => Number(a.id) - Number(b.id)) .sort((a, b) => Number(a.id) - Number(b.id))
: []; : [];
const pageCount = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); const safePageSize = pageSize ?? 1;
const currentPage = Math.floor(offset / PAGE_SIZE) + 1; const pageCount = Math.max(1, Math.ceil((totalCount ?? 0) / safePageSize));
const emptyRowCount = Math.max(0, PAGE_SIZE - recordset.length); const currentPage = Math.floor(offset / safePageSize) + 1;
const showResultsPanel = const showPagination =
facetEntries.length > 0 || recordset.length > 0 || (searchActive && !error && totalCount === 0); rows.length > 0 && pageSize != null && totalCount != null && totalCount > pageSize;
const handlePageChange = (_e, page) => { const handlePageChange = (_e, page) => {
this.updateParams(input, filterParam, (page - 1) * PAGE_SIZE); this.updateParams(input, filterParam, (page - 1) * safePageSize);
}; };
return ( return (
@@ -249,10 +371,10 @@ class ArtikelUebersichtPage extends Component {
</Alert> </Alert>
)} )}
{showResultsPanel && ( {searchActive && (
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start' }}> <Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start' }}>
{facetEntries.length > 0 && ( {facetEntries.length > 0 && (
<Paper variant="outlined" sx={{ width: 220, flexShrink: 0, overflow: 'hidden' }}> <Paper variant="outlined" sx={{ width: 220, flexShrink: 0, overflow: 'auto' }}>
<Typography <Typography
variant="subtitle2" variant="subtitle2"
sx={{ px: 2, py: 1.5, bgcolor: 'action.hover', fontWeight: 600 }} sx={{ px: 2, py: 1.5, bgcolor: 'action.hover', fontWeight: 600 }}
@@ -287,33 +409,59 @@ class ArtikelUebersichtPage extends Component {
</Paper> </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 && ( {searchActive && !error && totalCount === 0 && (
<Typography color="text.secondary">Keine Artikel gefunden.</Typography> <Typography color="text.secondary">Keine Artikel gefunden.</Typography>
)} )}
{recordset.length > 0 && ( {/* Always mount real pagination height (invisible until needed) so pageSize is correct. */}
<> <Box sx={{ mb: 2 }}>
<ResultsPagination <ResultsPagination
offset={offset} invisible={!showPagination}
totalCount={totalCount} offset={showPagination ? offset : 0}
pageCount={pageCount} totalCount={showPagination ? totalCount : (pageSize ?? 1) + 1}
currentPage={currentPage} pageSize={pageSize ?? 1}
onPageChange={handlePageChange} pageCount={showPagination ? pageCount : 5}
sx={{ mb: 2 }} currentPage={showPagination ? currentPage : 1}
/> onPageChange={handlePageChange}
/>
</Box>
<Box ref={this.setListSlotRef}>
{rows.length > 0 && pageSize != null && (
<List component={Paper} variant="outlined" disablePadding> <List component={Paper} variant="outlined" disablePadding>
{recordset.map((row) => ( {rows.map((row, index) => (
<ListItemButton <ListItemButton
key={row.id} key={row.id}
divider type="button"
sx={{ alignItems: 'flex-start', py: 1.5 }} 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 <ListItemText
primary={row.name} primary={row.name}
secondary={ secondary={
<Typography component="span" variant="body2" color="text.secondary"> <Typography
component="span"
variant="body2"
color="text.secondary"
noWrap
>
{row.sku} {row.sku}
{' · '} {' · '}
Bestand {formatStock(row.stock)} Bestand {formatStock(row.stock)}
@@ -324,7 +472,7 @@ class ArtikelUebersichtPage extends Component {
: ''} : ''}
</Typography> </Typography>
} }
slotProps={{ primary: { fontWeight: 500 } }} slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
/> />
<Typography <Typography
variant="body2" variant="body2"
@@ -334,35 +482,9 @@ class ArtikelUebersichtPage extends Component {
</Typography> </Typography>
</ListItemButton> </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> </List>
)}
<ResultsPagination </Box>
offset={offset}
totalCount={totalCount}
pageCount={pageCount}
currentPage={currentPage}
onPageChange={handlePageChange}
sx={{ mt: 2 }}
/>
</>
)}
</Box> </Box>
</Box> </Box>
)} )}
@@ -371,4 +493,4 @@ class ArtikelUebersichtPage extends Component {
} }
} }
export default withSearchParams(ArtikelUebersichtPage); export default withSearchParams(ArtikelSearchPage);

View File

@@ -0,0 +1,8 @@
const FOCUSABLE =
'a, button, input, textarea, select, [role="button"], [role="menuitem"], [tabindex]:not([tabindex="-1"]), [contenteditable="true"]';
/** Keep the search field focused when clicking non-interactive chrome. */
export function preserveSearchFocus(e) {
if (e.target.closest(FOCUSABLE)) return;
e.preventDefault();
}

View File

@@ -0,0 +1,32 @@
export function parseFilter(filterStr) {
if (!filterStr) return [];
return filterStr.split(',').map((s) => s.trim()).filter(Boolean);
}
export function buildFilter(categories) {
return categories.length ? categories.join(',') : '';
}
export function formatStock(value) {
if (value == null) return '—';
return new Intl.NumberFormat('de-DE', { maximumFractionDigits: 2 }).format(value);
}
export 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;
}
export function formatCategoryList(catStr, facets) {
if (!catStr) return '';
return catStr
.split(',')
.map((id) => facets[id]?.name)
.filter(Boolean)
.join(', ');
}

341
src/pages/artikel/sku.jsx Normal file
View File

@@ -0,0 +1,341 @@
import { useEffect, useState } from 'react';
import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import Link from '@mui/material/Link';
import Paper from '@mui/material/Paper';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Typography from '@mui/material/Typography';
import { article } from '../../api';
import { formatPrice, getBrutto } from '../../utils/price';
import { formatStock } from './searchFormat';
function DetailRow({ label, children }) {
if (children == null || children === '') return null;
return (
<Box sx={{ display: 'grid', gridTemplateColumns: '160px 1fr', gap: 1, py: 0.75 }}>
<Typography variant="body2" color="text.secondary">
{label}
</Typography>
<Typography variant="body2" component="div">
{children}
</Typography>
</Box>
);
}
function Section({ title, children }) {
if (!children) return null;
return (
<Box sx={{ mt: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1 }}>
{title}
</Typography>
{children}
</Box>
);
}
export function ArticleView({ article: a }) {
const brutto =
a.fVKNEtto != null ? getBrutto(a.fVKNEtto, a.kSteuerklasse) : null;
return (
<Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 1, mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mr: 1 }}>
{a.cName}
</Typography>
<Chip
size="small"
label={a.cAktiv === 'Y' ? 'Aktiv' : 'Inaktiv'}
color={a.cAktiv === 'Y' ? 'success' : 'default'}
variant="outlined"
/>
</Box>
<Paper variant="outlined" sx={{ p: 2 }}>
<DetailRow label="Artikelnr.">{a.cArtNr}</DetailRow>
<DetailRow label="Barcode">{a.cBarcode}</DetailRow>
<DetailRow label="Hersteller">{a.cHersteller}</DetailRow>
<DetailRow label="VK Netto">
{a.fVKNEtto != null ? formatPrice(a.fVKNEtto) : null}
</DetailRow>
<DetailRow label="VK Brutto">
{brutto != null ? formatPrice(brutto) : null}
</DetailRow>
<DetailRow label="UVP">{a.fUVP != null ? formatPrice(a.fUVP) : null}</DetailRow>
<DetailRow label="Bestand">{formatStock(a.fBestand)}</DetailRow>
<DetailRow label="Garage">{a.garage != null ? formatStock(a.garage) : null}</DetailRow>
<DetailRow label="Keller">{a.keller != null ? formatStock(a.keller) : null}</DetailRow>
<DetailRow label="Versandklasse">{a.tVersandklassecName}</DetailRow>
<DetailRow label="Gewicht">
{a.fGewicht != null
? `${new Intl.NumberFormat('de-DE').format(a.fGewicht)} kg`
: null}
</DetailRow>
{a.vater?.cArtNr && (
<DetailRow label="Vaterartikel">
<Link component={RouterLink} to={`/artikel/${encodeURIComponent(a.vater.cArtNr)}`}>
{a.vater.cArtNr}
</Link>
</DetailRow>
)}
{(a.cEigenschaftName || a.cEigenschaftWertName) && (
<DetailRow label="Variante">
{[a.cEigenschaftName, a.cEigenschaftWertName].filter(Boolean).join(': ')}
</DetailRow>
)}
</Paper>
{a.cKurzBeschreibung && (
<Section title="Kurzbeschreibung">
<Typography variant="body2">{a.cKurzBeschreibung}</Typography>
</Section>
)}
{a.cBeschreibung && (
<Section title="Beschreibung">
<Box
sx={{ typography: 'body2', '& p': { mt: 0 } }}
dangerouslySetInnerHTML={{ __html: a.cBeschreibung }}
/>
</Section>
)}
{a.EANs?.length > 0 && (
<Section title="EANs">
<Typography variant="body2">
{a.EANs.map((row) => row.cEAN).filter(Boolean).join(', ')}
</Typography>
</Section>
)}
{a.KATs?.length > 0 && (
<Section title="Kategorien">
<Typography variant="body2">
{a.KATs.map((row) => row.kKategorie).join(', ')}
</Typography>
</Section>
)}
{a.staffel?.length > 0 && (
<Section title="Staffelpreise">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Menge</TableCell>
<TableCell align="right">VK Brutto</TableCell>
</TableRow>
</TableHead>
<TableBody>
{a.staffel.map((row, i) => (
<TableRow key={i}>
<TableCell>{row.menge}</TableCell>
<TableCell align="right">{row.fVKBrutto}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Section>
)}
{a.lief?.length > 0 && (
<Section title="Lieferanten">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Firma</TableCell>
<TableCell>Lief.-Artikelnr.</TableCell>
<TableCell align="right">EK Netto</TableCell>
<TableCell align="right">Lager</TableCell>
</TableRow>
</TableHead>
<TableBody>
{a.lief.map((row, i) => (
<TableRow key={i}>
<TableCell>
{row.cFirma}
{row.nStandard ? ' ★' : ''}
</TableCell>
<TableCell>{row.cLiefArtNr || '—'}</TableCell>
<TableCell align="right">
{row.fEKNetto != null ? formatPrice(row.fEKNetto) : '—'}
</TableCell>
<TableCell align="right">{formatStock(row.fLagerbestand)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Section>
)}
{a.merkmale?.length > 0 && (
<Section title="Merkmale">
<Paper variant="outlined" sx={{ p: 2 }}>
{a.merkmale.map((row, i) => (
<DetailRow key={i} label={row.cName}>
{row.cWert}
</DetailRow>
))}
</Paper>
</Section>
)}
{a.children?.length > 0 && (
<Section title="Varianten">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Artikelnr.</TableCell>
<TableCell>Name</TableCell>
</TableRow>
</TableHead>
<TableBody>
{a.children.map((row) => (
<TableRow key={row.cArtNr || row.kArtikel} hover>
<TableCell>
{row.cArtNr ? (
<Link
component={RouterLink}
to={`/artikel/${encodeURIComponent(row.cArtNr)}`}
>
{row.cArtNr}
</Link>
) : (
'—'
)}
</TableCell>
<TableCell>{row.cName || '—'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Section>
)}
{a.liste?.length > 0 && (
<Section title="Stückliste">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Artikelnr.</TableCell>
<TableCell>Name</TableCell>
<TableCell align="right">Menge</TableCell>
</TableRow>
</TableHead>
<TableBody>
{a.liste.map((row, i) => (
<TableRow key={i}>
<TableCell>
{row.cArtNr ? (
<Link
component={RouterLink}
to={`/artikel/${encodeURIComponent(row.cArtNr)}`}
>
{row.cArtNr}
</Link>
) : (
'—'
)}
</TableCell>
<TableCell>{row.cName || '—'}</TableCell>
<TableCell align="right">{formatStock(row.fAnzahl ?? row.menge)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Section>
)}
{a.cAnmerkung && (
<Section title="Interne Anmerkung">
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{a.cAnmerkung}
</Typography>
</Section>
)}
</Box>
);
}
export default function ArtikelSkuPage() {
const { sku } = useParams();
const navigate = useNavigate();
const target = sku ? decodeURIComponent(sku) : '';
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
function onKeyDown(e) {
if (e.key !== 'Escape') return;
e.preventDefault();
navigate(-1);
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [navigate]);
useEffect(() => {
let cancelled = false;
async function load() {
if (!target) {
setError('Keine Artikelnr. angegeben');
setData(null);
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const res = await article.get({ target });
if (!cancelled) {
setData(res.article ?? null);
if (!res.article) setError('Artikel nicht gefunden');
}
} catch (err) {
if (!cancelled) {
setData(null);
setError(err.message || 'Artikel konnte nicht geladen werden');
}
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => {
cancelled = true;
};
}, [target]);
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Alert severity="error" sx={{ maxWidth: 640 }}>
{error}
</Alert>
);
}
if (!data) return null;
return <ArticleView article={data} />;
}

View File

@@ -0,0 +1,17 @@
import { useNavigate, useSearchParams } from 'react-router-dom';
export default function withSearchParams(Wrapped) {
function SearchParamsWrapper(props) {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
return (
<Wrapped
{...props}
searchParams={searchParams}
setSearchParams={setSearchParams}
navigate={navigate}
/>
);
}
return SearchParamsWrapper;
}