From af9e0724275840584c840f9e338e8694c844df9f Mon Sep 17 00:00:00 2001 From: seb Date: Sun, 19 Jul 2026 19:39:07 +0200 Subject: [PATCH] article and relayout --- src/App.jsx | 2 + src/api/index.js | 5 + src/components/AppShell.jsx | 92 ++--- src/components/PageLayout.jsx | 37 +- src/components/SectionLayout.jsx | 10 +- src/pages/artikel/ResultsPagination.jsx | 56 +++ src/pages/artikel/artikelQuery.js | 57 ++++ src/pages/artikel/index.jsx | 430 +++++++++++++++--------- src/pages/artikel/searchFocus.js | 8 + src/pages/artikel/searchFormat.js | 32 ++ src/pages/artikel/sku.jsx | 341 +++++++++++++++++++ src/pages/artikel/withSearchParams.jsx | 17 + 12 files changed, 878 insertions(+), 209 deletions(-) create mode 100644 src/pages/artikel/ResultsPagination.jsx create mode 100644 src/pages/artikel/artikelQuery.js create mode 100644 src/pages/artikel/searchFocus.js create mode 100644 src/pages/artikel/searchFormat.js create mode 100644 src/pages/artikel/sku.jsx create mode 100644 src/pages/artikel/withSearchParams.jsx 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 && ( +
+ + {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; +}