article and relayout
This commit is contained in:
@@ -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 />} />
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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 }} />
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
56
src/pages/artikel/ResultsPagination.jsx
Normal file
56
src/pages/artikel/ResultsPagination.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/pages/artikel/artikelQuery.js
Normal file
57
src/pages/artikel/artikelQuery.js
Normal 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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
8
src/pages/artikel/searchFocus.js
Normal file
8
src/pages/artikel/searchFocus.js
Normal 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();
|
||||||
|
}
|
||||||
32
src/pages/artikel/searchFormat.js
Normal file
32
src/pages/artikel/searchFormat.js
Normal 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
341
src/pages/artikel/sku.jsx
Normal 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} />;
|
||||||
|
}
|
||||||
17
src/pages/artikel/withSearchParams.jsx
Normal file
17
src/pages/artikel/withSearchParams.jsx
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user