diff --git a/src/App.jsx b/src/App.jsx
index 7a42bb4..93ed395 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -8,6 +8,7 @@ import SectionLayout from './components/SectionLayout';
import ArtikelUebersichtPage from './pages/artikel/index';
import ArtikelKategorienPage from './pages/artikel/kategorien';
import ArtikelLagerPage from './pages/artikel/lager';
+import ArtikelSkuPage from './pages/artikel/sku';
import LieferungPage from './pages/lieferung/index';
import AuftragAktivePage from './pages/auftrag/index';
import AuftragArchivPage from './pages/auftrag/archiv';
@@ -50,6 +51,7 @@ export default function App() {
} />
} />
} />
+ } />
}>
} />
diff --git a/src/api/index.js b/src/api/index.js
index c490d2d..7957ef8 100644
--- a/src/api/index.js
+++ b/src/api/index.js
@@ -36,6 +36,11 @@ export const article = {
if (maxCount != null) params.set('maxCount', String(maxCount));
return http(`/api/article/search?${params}`);
},
+
+ get({ target } = {}) {
+ const params = new URLSearchParams({ target });
+ return http(`/api/article/get?${params}`);
+ },
};
export const auth = {
diff --git a/src/components/AppShell.jsx b/src/components/AppShell.jsx
index 8e0d1da..16a7706 100644
--- a/src/components/AppShell.jsx
+++ b/src/components/AppShell.jsx
@@ -3,26 +3,19 @@ import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
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 { NAV_ITEMS } from '../nav';
+import { artikelQueryPath } from '../pages/artikel/artikelQuery';
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() {
+ const navigate = useNavigate();
+ const { pathname } = useLocation();
+
return (
-
+
({
@@ -34,37 +27,48 @@ export default function AppShell() {
})}
>
- {NAV_ITEMS.map(({ path, label, icon: Icon }) => (
- e.preventDefault()}
- sx={(theme) => ({
- borderRadius: 0,
- px: 1.5,
- py: 1.25,
- borderLeft: '3px solid transparent',
- color: theme.nav.item,
- '& .MuiListItemIcon-root': { color: 'inherit' },
- '&:hover': {
- bgcolor: theme.nav.itemHover,
- color: theme.nav.itemHoverText,
- },
- '&.active': {
- bgcolor: theme.nav.itemActive,
- color: theme.nav.itemActiveText,
- fontWeight: 600,
- borderLeftColor: theme.nav.itemActiveAccent,
- },
- })}
- >
-
-
-
-
-
- ))}
+ {NAV_ITEMS.map(({ path, label, icon: Icon }) => {
+ const active = pathname === path || pathname.startsWith(`${path}/`);
+
+ return (
+ e.preventDefault()}
+ onClick={() => {
+ navigate(path === '/artikel' ? artikelQueryPath() : path);
+ }}
+ sx={(theme) => ({
+ borderRadius: 0,
+ px: 1.5,
+ py: 1.25,
+ borderLeft: '3px solid transparent',
+ color: theme.nav.item,
+ '& .MuiListItemIcon-root': { color: 'inherit' },
+ '&:hover': {
+ bgcolor: theme.nav.itemHover,
+ color: theme.nav.itemHoverText,
+ },
+ '&.Mui-selected': {
+ bgcolor: theme.nav.itemActive,
+ color: theme.nav.itemActiveText,
+ fontWeight: 600,
+ borderLeftColor: theme.nav.itemActiveAccent,
+ '&:hover': {
+ bgcolor: theme.nav.itemActive,
+ color: theme.nav.itemActiveText,
+ },
+ },
+ })}
+ >
+
+
+
+
+
+ );
+ })}
diff --git a/src/components/PageLayout.jsx b/src/components/PageLayout.jsx
index fe944e3..e6bdd66 100644
--- a/src/components/PageLayout.jsx
+++ b/src/components/PageLayout.jsx
@@ -2,12 +2,27 @@ import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
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;
+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 }) {
const hasSubNav = subNav?.length > 0;
+ const navigate = useNavigate();
+ const { pathname } = useLocation();
+ const knownSegments = new Set((subNav ?? []).map((item) => item.segment).filter(Boolean));
return (
@@ -25,31 +40,33 @@ export default function PageLayout({ title, subNav, basePath, children }) {
>
{hasSubNav &&
subNav.map(({ segment, label, icon: Icon }) => {
- const to = segment ? `${basePath}/${segment}` : basePath;
+ const active = isSubNavActive(pathname, basePath, segment, knownSegments);
return (
e.preventDefault()}
+ onClick={() => {
+ if (!segment && basePath === '/artikel') {
+ navigate(artikelQueryPath());
+ return;
+ }
+ navigate(segment ? `${basePath}/${segment}` : basePath);
+ }}
sx={(theme) => ({
width: 80,
height: 80,
borderRadius: '50%',
- color: theme.palette.text.secondary,
+ color: active ? theme.palette.primary.main : theme.palette.text.secondary,
bgcolor: 'transparent',
'&:hover': {
bgcolor: theme.palette.action.hover,
color: theme.palette.primary.main,
},
- '&.active': {
- bgcolor: 'transparent',
- color: theme.palette.primary.main,
- },
})}
>
diff --git a/src/components/SectionLayout.jsx b/src/components/SectionLayout.jsx
index 3cffc56..760d687 100644
--- a/src/components/SectionLayout.jsx
+++ b/src/components/SectionLayout.jsx
@@ -10,7 +10,15 @@ export default function SectionLayout({ sectionPath }) {
const subPath = item.segment ? `${sectionPath}/${item.segment}` : sectionPath;
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 (
diff --git a/src/pages/artikel/ResultsPagination.jsx b/src/pages/artikel/ResultsPagination.jsx
new file mode 100644
index 0000000..263c55b
--- /dev/null
+++ b/src/pages/artikel/ResultsPagination.jsx
@@ -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 (
+
+
+ {offset + 1}–{Math.min(offset + pageSize, totalCount)} von {totalCount}
+
+ e.preventDefault()}
+ sx={{
+ '& .MuiPaginationItem-root': {
+ minWidth: PAGINATION_ITEM_SIZE,
+ height: PAGINATION_ITEM_SIZE,
+ fontSize: '1rem',
+ },
+ }}
+ />
+
+ );
+}
diff --git a/src/pages/artikel/artikelQuery.js b/src/pages/artikel/artikelQuery.js
new file mode 100644
index 0000000..12748c8
--- /dev/null
+++ b/src/pages/artikel/artikelQuery.js
@@ -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);
+}
diff --git a/src/pages/artikel/index.jsx b/src/pages/artikel/index.jsx
index db2b514..d2194c9 100644
--- a/src/pages/artikel/index.jsx
+++ b/src/pages/artikel/index.jsx
@@ -1,12 +1,10 @@
import { Component } from 'react';
-import { useSearchParams } from 'react-router-dom';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import InputAdornment from '@mui/material/InputAdornment';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
-import Pagination from '@mui/material/Pagination';
import Paper from '@mui/material/Paper';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
@@ -14,96 +12,178 @@ import SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear';
import { article } from '../../api';
import { formatPrice } from '../../utils/price';
+import {
+ buildSearchUrl,
+ clearArtikelQuery,
+ queryFromParams,
+ readArtikelQuery,
+ writeArtikelQuery,
+} from './artikelQuery';
+import ResultsPagination from './ResultsPagination';
+import {
+ buildFilter,
+ formatCategoryList,
+ formatStock,
+ getCategoryDepth,
+ parseFilter,
+} from './searchFormat';
+import { preserveSearchFocus } from './searchFocus';
+import withSearchParams from './withSearchParams';
-const PAGE_SIZE = 10;
-
-function parseFilter(filterStr) {
- if (!filterStr) return [];
- return filterStr.split(',').map((s) => s.trim()).filter(Boolean);
-}
-
-function buildFilter(categories) {
- return categories.length ? categories.join(',') : '';
-}
-
-function formatStock(value) {
- if (value == null) return '—';
- return new Intl.NumberFormat('de-DE', { maximumFractionDigits: 2 }).format(value);
-}
-
-function getCategoryDepth(id, facets) {
- let depth = 0;
- let parent = facets[id]?.parent;
- while (parent && parent !== 0 && facets[String(parent)]) {
- depth += 1;
- parent = facets[String(parent)]?.parent;
- }
- return depth;
-}
-
-function formatCategoryList(catStr, facets) {
- if (!catStr) return '';
- return catStr
- .split(',')
- .map((id) => facets[id]?.name)
- .filter(Boolean)
- .join(', ');
-}
-
-function ResultsPagination({ offset, totalCount, pageCount, currentPage, onPageChange, sx }) {
- if (totalCount <= PAGE_SIZE) return null;
+const resultRowSx = {
+ alignItems: 'center',
+ py: 1.5,
+ // Keep every row one line so sample height matches real rows.
+ '& .MuiListItemText-root': { minWidth: 0 },
+ '& .MuiListItemText-primary, & .MuiListItemText-secondary': {
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ },
+};
+function ResultRowMeasureSample() {
return (
-
-
- {offset + 1}–{Math.min(offset + PAGE_SIZE, totalCount)} von {totalCount}
-
- e.preventDefault()}
+
+
+ SKU · Bestand 0 · Verfügbar 0 · Kategorie
+
+ }
+ slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
/>
-
+
+ 0,00 €
+
+
);
}
-function withSearchParams(Wrapped) {
- function SearchParamsWrapper(props) {
- const [searchParams, setSearchParams] = useSearchParams();
- return (
-
- );
- }
- return SearchParamsWrapper;
-}
+function initialSearchState(searchParams) {
+ const query = queryFromParams(searchParams);
+ const stored = readArtikelQuery();
+ const input =
+ query.target.trim().length >= 3
+ ? query.target
+ : (stored?.target?.trim().length >= 3 ? stored.target : query.target);
-class ArtikelUebersichtPage extends Component {
- state = {
- input: this.props.searchParams.get('target') ?? '',
+ return {
+ input,
error: null,
categoryFacets: null,
- totalCount: 0,
- recordset: [],
+ totalCount: null,
+ recordset: null,
+ pageSize: null,
+ };
+}
+
+class ArtikelSearchPage extends Component {
+ state = initialSearchState(this.props.searchParams);
+ listSlotRef = { current: null };
+ rowMeasureRef = { current: null };
+
+ setListSlotRef = (node) => {
+ if (this.listSlotRef.current === node) return;
+ this.listSlotRef.current = node;
+ if (!node) return;
+ this.updatePageSize();
+ };
+
+ setRowMeasureRef = (node) => {
+ if (this.rowMeasureRef.current === node) return;
+ this.rowMeasureRef.current = node;
+ if (!node) return;
+ this.updatePageSize();
+ };
+
+ measureRowHeight = () => {
+ // Always use the sample — real rows used to report a different height after paint
+ // (wrapped text), which made resize "fix" a wrong first measure.
+ const sample = this.rowMeasureRef.current;
+ if (!sample) return { height: 0, source: 'none' };
+ return {
+ height: sample.getBoundingClientRect().height,
+ source: 'sample',
+ };
+ };
+
+ updatePageSize = ({ fromResize = false } = {}) => {
+ const slot = this.listSlotRef.current;
+ if (!slot) return;
+
+ // After the first measure, ignore layout churn from painting results.
+ // Only window resize may change pageSize and refetch.
+ if (this.state.pageSize != null && !fromResize) return;
+
+ const slotTop = slot.getBoundingClientRect().top;
+ const available = window.innerHeight - slotTop;
+ const { height: rowHeight, source: rowSource } = this.measureRowHeight();
+ if (available < 1 || rowHeight < 1) return;
+
+ // Outlined Paper around the list is 1px top + 1px bottom — not in row height.
+ const listChrome = 2;
+ const next = Math.max(1, Math.floor((available - listChrome) / rowHeight));
+
+ console.log('[pageSize] measure', {
+ fromResize,
+ prevPageSize: this.state.pageSize,
+ next,
+ available,
+ rowHeight,
+ rowSource,
+ listChrome,
+ slotTop,
+ realRowHeight:
+ slot.querySelector('[data-result-row]')?.getBoundingClientRect().height ?? null,
+ });
+
+ if (next === this.state.pageSize) return;
+
+ this.setState({ pageSize: next }, () => {
+ const target = this.props.searchParams.get('target') ?? '';
+ if (target.trim().length >= 3) this.fetchResults();
+ });
+ };
+
+ handleWindowResize = () => {
+ this.updatePageSize({ fromResize: true });
+ };
+
+ persistQuery = () => {
+ const query = queryFromParams(this.props.searchParams);
+ const target = query.target || this.state.input;
+ writeArtikelQuery({
+ target,
+ filter: query.filter,
+ offset: query.offset,
+ });
+ };
+
+ restoreStoredQuery = (stored) => {
+ this.props.navigate(
+ buildSearchUrl(stored.target, stored.filter ?? '', stored.offset ?? 0),
+ { replace: true },
+ );
};
componentDidMount() {
- this.fetchResults();
+ document.addEventListener('mousedown', preserveSearchFocus, true);
+ window.addEventListener('resize', this.handleWindowResize);
+
+ const query = queryFromParams(this.props.searchParams);
+ const stored = readArtikelQuery();
+
+ if (query.target.trim().length < 3 && stored?.target?.trim().length >= 3) {
+ this.restoreStoredQuery(stored);
+ }
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener('mousedown', preserveSearchFocus, true);
+ window.removeEventListener('resize', this.handleWindowResize);
+ this.persistQuery();
}
componentDidUpdate(prevProps) {
@@ -112,6 +192,14 @@ class ArtikelUebersichtPage extends Component {
const target = searchParams.get('target') ?? '';
const prevTarget = prev.get('target') ?? '';
+ if (target.trim().length < 3 && prevTarget.trim().length >= 3) {
+ const stored = readArtikelQuery();
+ if (stored?.target?.trim().length >= 3) {
+ this.restoreStoredQuery(stored);
+ return;
+ }
+ }
+
if (target !== prevTarget && target !== this.state.input) {
this.setState({ input: target });
}
@@ -126,41 +214,74 @@ class ArtikelUebersichtPage extends Component {
}
updateParams = (nextTarget, nextFilter, nextOffset = 0) => {
- const params = new URLSearchParams();
- if (nextTarget) params.set('target', nextTarget);
- if (nextFilter) params.set('filter', nextFilter);
- if (nextOffset > 0) params.set('offset', String(nextOffset));
- this.props.setSearchParams(params, { replace: true });
+ writeArtikelQuery({
+ target: nextTarget,
+ filter: nextFilter,
+ offset: nextOffset,
+ });
+ this.props.navigate(buildSearchUrl(nextTarget, nextFilter, nextOffset), {
+ replace: true,
+ });
};
handleInputChange = (event) => {
const value = event.target.value;
- this.setState({ input: value });
+ const belowMin = value.trim().length < 3;
+
+ if (belowMin) {
+ clearArtikelQuery();
+ // Clear results in the same update that shortens the input — otherwise the
+ // hint appears one paint before fetchResults (driven by URL) removes the list.
+ this.setState({
+ input: value,
+ categoryFacets: null,
+ totalCount: null,
+ recordset: null,
+ error: null,
+ pageSize: null,
+ });
+ } else {
+ this.setState({ input: value });
+ }
+
this.updateParams(value, this.props.searchParams.get('filter') ?? '', 0);
};
fetchResults = async () => {
- const target = this.props.searchParams.get('target') ?? '';
- const filterParam = this.props.searchParams.get('filter') ?? '';
- const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || 0);
+ const { target, filter, offset } = queryFromParams(this.props.searchParams);
const trimmed = target.trim();
+ const { pageSize } = this.state;
if (trimmed.length < 3) {
- this.setState({
- categoryFacets: null,
- totalCount: 0,
- recordset: [],
- error: null,
- });
+ const { categoryFacets, totalCount, recordset, error, pageSize: ps } = this.state;
+ if (
+ categoryFacets != null
+ || totalCount != null
+ || recordset != null
+ || error != null
+ || ps != null
+ ) {
+ this.setState({
+ categoryFacets: null,
+ totalCount: null,
+ recordset: null,
+ error: null,
+ pageSize: null,
+ });
+ }
return;
}
+ if (pageSize == null) return;
+
+ writeArtikelQuery({ target, filter, offset });
+
try {
const data = await article.search({
target: trimmed,
- filter: filterParam || undefined,
+ filter: filter || undefined,
offset,
- maxCount: PAGE_SIZE,
+ maxCount: pageSize,
});
this.setState({
@@ -172,20 +293,21 @@ class ArtikelUebersichtPage extends Component {
} catch (err) {
this.setState({
categoryFacets: null,
- totalCount: 0,
- recordset: [],
+ totalCount: null,
+ recordset: null,
error: err.message || 'Suche fehlgeschlagen',
});
}
};
render() {
- const { input, error, categoryFacets, totalCount, recordset } = this.state;
- const target = this.props.searchParams.get('target') ?? '';
+ const { input, error, categoryFacets, totalCount, recordset, pageSize } = this.state;
const filterParam = this.props.searchParams.get('filter') ?? '';
const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || 0);
const categories = parseFilter(filterParam);
- const searchActive = target.trim().length >= 3;
+ // Drive UI from input so hint vs results switch in one paint (URL lags one tick).
+ const searchActive = input.trim().length >= 3;
+ const rows = recordset ?? [];
const facetEntries = categoryFacets
? Object.entries(categoryFacets)
@@ -199,14 +321,14 @@ class ArtikelUebersichtPage extends Component {
.sort((a, b) => Number(a.id) - Number(b.id))
: [];
- const pageCount = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
- const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
- const emptyRowCount = Math.max(0, PAGE_SIZE - recordset.length);
- const showResultsPanel =
- facetEntries.length > 0 || recordset.length > 0 || (searchActive && !error && totalCount === 0);
+ const safePageSize = pageSize ?? 1;
+ const pageCount = Math.max(1, Math.ceil((totalCount ?? 0) / safePageSize));
+ const currentPage = Math.floor(offset / safePageSize) + 1;
+ const showPagination =
+ rows.length > 0 && pageSize != null && totalCount != null && totalCount > pageSize;
const handlePageChange = (_e, page) => {
- this.updateParams(input, filterParam, (page - 1) * PAGE_SIZE);
+ this.updateParams(input, filterParam, (page - 1) * safePageSize);
};
return (
@@ -249,10 +371,10 @@ class ArtikelUebersichtPage extends Component {
)}
- {showResultsPanel && (
+ {searchActive && (
{facetEntries.length > 0 && (
-
+
)}
-
+
+ {/* Same column width as the list; invisible, zero layout footprint. */}
+
+
+
+
+
+
{searchActive && !error && totalCount === 0 && (
Keine Artikel gefunden.
)}
- {recordset.length > 0 && (
- <>
-
+ {/* Always mount real pagination height (invisible until needed) so pageSize is correct. */}
+
+
+
+
+ {rows.length > 0 && pageSize != null && (
- {recordset.map((row) => (
+ {rows.map((row, index) => (
e.preventDefault()}
+ onClick={() => {
+ this.persistQuery();
+ this.props.navigate(`/artikel/${encodeURIComponent(row.sku)}`);
+ }}
+ sx={resultRowSx}
>
+
{row.sku}
{' · '}
Bestand {formatStock(row.stock)}
@@ -324,7 +472,7 @@ class ArtikelUebersichtPage extends Component {
: ''}
}
- slotProps={{ primary: { fontWeight: 500 } }}
+ slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
/>
))}
- {Array.from({ length: emptyRowCount }, (_, i) => (
-
-
- .
-
- ))}
-
-
- >
- )}
+ )}
+
)}
@@ -371,4 +493,4 @@ class ArtikelUebersichtPage extends Component {
}
}
-export default withSearchParams(ArtikelUebersichtPage);
+export default withSearchParams(ArtikelSearchPage);
diff --git a/src/pages/artikel/searchFocus.js b/src/pages/artikel/searchFocus.js
new file mode 100644
index 0000000..2db37fe
--- /dev/null
+++ b/src/pages/artikel/searchFocus.js
@@ -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();
+}
diff --git a/src/pages/artikel/searchFormat.js b/src/pages/artikel/searchFormat.js
new file mode 100644
index 0000000..98912ba
--- /dev/null
+++ b/src/pages/artikel/searchFormat.js
@@ -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(', ');
+}
diff --git a/src/pages/artikel/sku.jsx b/src/pages/artikel/sku.jsx
new file mode 100644
index 0000000..3bcd472
--- /dev/null
+++ b/src/pages/artikel/sku.jsx
@@ -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 (
+
+
+ {label}
+
+
+ {children}
+
+
+ );
+}
+
+function Section({ title, children }) {
+ if (!children) return null;
+ return (
+
+
+ {title}
+
+ {children}
+
+ );
+}
+
+export function ArticleView({ article: a }) {
+ const brutto =
+ a.fVKNEtto != null ? getBrutto(a.fVKNEtto, a.kSteuerklasse) : null;
+
+ return (
+
+
+
+ {a.cName}
+
+
+
+
+
+ {a.cArtNr}
+ {a.cBarcode}
+ {a.cHersteller}
+
+ {a.fVKNEtto != null ? formatPrice(a.fVKNEtto) : null}
+
+
+ {brutto != null ? formatPrice(brutto) : null}
+
+ {a.fUVP != null ? formatPrice(a.fUVP) : null}
+ {formatStock(a.fBestand)}
+ {a.garage != null ? formatStock(a.garage) : null}
+ {a.keller != null ? formatStock(a.keller) : null}
+ {a.tVersandklassecName}
+
+ {a.fGewicht != null
+ ? `${new Intl.NumberFormat('de-DE').format(a.fGewicht)} kg`
+ : null}
+
+ {a.vater?.cArtNr && (
+
+
+ {a.vater.cArtNr}
+
+
+ )}
+ {(a.cEigenschaftName || a.cEigenschaftWertName) && (
+
+ {[a.cEigenschaftName, a.cEigenschaftWertName].filter(Boolean).join(': ')}
+
+ )}
+
+
+ {a.cKurzBeschreibung && (
+
+ {a.cKurzBeschreibung}
+
+ )}
+
+ {a.cBeschreibung && (
+
+ )}
+
+ {a.EANs?.length > 0 && (
+
+
+ {a.EANs.map((row) => row.cEAN).filter(Boolean).join(', ')}
+
+
+ )}
+
+ {a.KATs?.length > 0 && (
+
+
+ {a.KATs.map((row) => row.kKategorie).join(', ')}
+
+
+ )}
+
+ {a.staffel?.length > 0 && (
+
+
+
+
+ Menge
+ VK Brutto
+
+
+
+ {a.staffel.map((row, i) => (
+
+ {row.menge}
+ {row.fVKBrutto}
+
+ ))}
+
+
+
+ )}
+
+ {a.lief?.length > 0 && (
+
+
+
+
+ Firma
+ Lief.-Artikelnr.
+ EK Netto
+ Lager
+
+
+
+ {a.lief.map((row, i) => (
+
+
+ {row.cFirma}
+ {row.nStandard ? ' ★' : ''}
+
+ {row.cLiefArtNr || '—'}
+
+ {row.fEKNetto != null ? formatPrice(row.fEKNetto) : '—'}
+
+ {formatStock(row.fLagerbestand)}
+
+ ))}
+
+
+
+ )}
+
+ {a.merkmale?.length > 0 && (
+
+
+ {a.merkmale.map((row, i) => (
+
+ {row.cWert}
+
+ ))}
+
+
+ )}
+
+ {a.children?.length > 0 && (
+
+
+
+
+ Artikelnr.
+ Name
+
+
+
+ {a.children.map((row) => (
+
+
+ {row.cArtNr ? (
+
+ {row.cArtNr}
+
+ ) : (
+ '—'
+ )}
+
+ {row.cName || '—'}
+
+ ))}
+
+
+
+ )}
+
+ {a.liste?.length > 0 && (
+
+
+
+
+ Artikelnr.
+ Name
+ Menge
+
+
+
+ {a.liste.map((row, i) => (
+
+
+ {row.cArtNr ? (
+
+ {row.cArtNr}
+
+ ) : (
+ '—'
+ )}
+
+ {row.cName || '—'}
+ {formatStock(row.fAnzahl ?? row.menge)}
+
+ ))}
+
+
+
+ )}
+
+ {a.cAnmerkung && (
+
+ )}
+
+ );
+}
+
+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 (
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ {error}
+
+ );
+ }
+
+ if (!data) return null;
+
+ return ;
+}
diff --git a/src/pages/artikel/withSearchParams.jsx b/src/pages/artikel/withSearchParams.jsx
new file mode 100644
index 0000000..09fd020
--- /dev/null
+++ b/src/pages/artikel/withSearchParams.jsx
@@ -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 (
+
+ );
+ }
+ return SearchParamsWrapper;
+}