From 855710a19812d235325fa2b414163f87f4e0cb24 Mon Sep 17 00:00:00 2001 From: seb Date: Thu, 6 Aug 2026 00:19:21 +0200 Subject: [PATCH] u --- .cursor/rules/prefer-class-components.mdc | 2 +- src/App.jsx | 53 +------- src/appRouter.jsx | 62 ++++++++++ src/components/AppShell.jsx | 115 ++++++++++++++++-- src/components/PageLayout.jsx | 104 +++------------- src/components/SectionLayout.jsx | 13 +- src/components/article/ArticlePhotoTile.jsx | 4 +- src/components/article/ArticleTileTab.jsx | 4 +- src/components/article/ArticleView.jsx | 19 ++- .../article/boxes/ArticleViewBrand.jsx | 4 +- .../article/boxes/ArticleViewEANs.jsx | 4 +- .../article/boxes/ArticleViewLists.jsx | 4 +- .../article/boxes/ArticleViewName.jsx | 4 +- .../article/boxes/ArticleViewPrice.jsx | 4 +- .../article/boxes/ArticleViewStock.jsx | 4 +- src/nav.js | 55 +++++++-- 16 files changed, 270 insertions(+), 185 deletions(-) create mode 100644 src/appRouter.jsx diff --git a/.cursor/rules/prefer-class-components.mdc b/.cursor/rules/prefer-class-components.mdc index 811a245..9a52e0e 100644 --- a/.cursor/rules/prefer-class-components.mdc +++ b/.cursor/rules/prefer-class-components.mdc @@ -6,7 +6,7 @@ alwaysApply: false # Class Components bevorzugen -Neue und geänderte React-Komponenten als **Class Components** (`extends Component`), nicht als Function Components. +Neue und geänderte React-Komponenten als **Class Components** (`extends PureComponent`), nicht als Function Components. ```js // ❌ diff --git a/src/App.jsx b/src/App.jsx index 48d4038..ab02aef 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,26 +1,12 @@ -import { Component, lazy, Suspense } from 'react'; +import { Component, Suspense } from 'react'; import { connect } from 'react-redux'; import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; -import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; +import { RouterProvider } from 'react-router-dom'; import { selectAuthStatus, selectNeedsCompanySetup } from './store/authSlice'; import LoginPage from './components/LoginPage'; import CompanySetupDialog from './components/CompanySetupDialog'; -import AppShell from './components/AppShell'; -import SectionLayout from './components/SectionLayout'; - -const ArtikelUebersichtPage = lazy(() => import('./pages/artikel/index')); -const ArtikelKategorienPage = lazy(() => import('./pages/artikel/kategorien')); -const RedirectArtikelKategorien = lazy(() => import('./pages/artikel/RedirectArtikelKategorien')); -const ArtikelLagerPage = lazy(() => import('./pages/artikel/lager')); -const ArtikelSkuPage = lazy(() => import('./pages/artikel/sku')); -const LieferungPage = lazy(() => import('./pages/lieferung/index')); -const AuftragAktivePage = lazy(() => import('./pages/auftrag/index')); -const AuftragArchivPage = lazy(() => import('./pages/auftrag/archiv')); -const KundeListePage = lazy(() => import('./pages/kunde/index')); -const KundeGruppenPage = lazy(() => import('./pages/kunde/gruppen')); -const KundeKontaktePage = lazy(() => import('./pages/kunde/kontakte')); -const InfosPage = lazy(() => import('./pages/infos/index')); +import { router } from './appRouter'; function PageFallback() { return ( @@ -67,36 +53,9 @@ class App extends Component { } return ( - - }> - - }> - } /> - } /> - } /> - }> - } /> - - }> - } /> - } /> - } /> - } /> - - }> - } /> - } /> - - }> - } /> - } /> - } /> - - } /> - - - - + }> + + ); } } diff --git a/src/appRouter.jsx b/src/appRouter.jsx new file mode 100644 index 0000000..fdb5fcf --- /dev/null +++ b/src/appRouter.jsx @@ -0,0 +1,62 @@ +import { lazy } from 'react'; +import { Navigate, createBrowserRouter } from 'react-router-dom'; +import AppShell from './components/AppShell'; +import SectionLayout from './components/SectionLayout'; + +const ArtikelUebersichtPage = lazy(() => import('./pages/artikel/index')); +const ArtikelKategorienPage = lazy(() => import('./pages/artikel/kategorien')); +const RedirectArtikelKategorien = lazy(() => import('./pages/artikel/RedirectArtikelKategorien')); +const ArtikelLagerPage = lazy(() => import('./pages/artikel/lager')); +const ArtikelSkuPage = lazy(() => import('./pages/artikel/sku')); +const LieferungPage = lazy(() => import('./pages/lieferung/index')); +const AuftragAktivePage = lazy(() => import('./pages/auftrag/index')); +const AuftragArchivPage = lazy(() => import('./pages/auftrag/archiv')); +const KundeListePage = lazy(() => import('./pages/kunde/index')); +const KundeGruppenPage = lazy(() => import('./pages/kunde/gruppen')); +const KundeKontaktePage = lazy(() => import('./pages/kunde/kontakte')); +const InfosPage = lazy(() => import('./pages/infos/index')); + +export const router = createBrowserRouter([ + { + path: '/', + element: , + children: [ + { index: true, element: }, + { path: 'lieferung', element: }, + { path: 'infos', element: }, + { + path: 'kategorien/*', + element: , + children: [{ path: '*', element: }], + }, + { + path: 'artikel', + element: , + children: [ + { index: true, element: }, + { path: 'kategorien/*', element: }, + { path: 'lager', element: }, + { path: ':sku/:tab?', element: }, + ], + }, + { + path: 'auftrag', + element: , + children: [ + { index: true, element: }, + { path: 'archiv', element: }, + ], + }, + { + path: 'kunde', + element: , + children: [ + { index: true, element: }, + { path: 'gruppen', element: }, + { path: 'kontakte', element: }, + ], + }, + { path: '*', element: }, + ], + }, +]); diff --git a/src/components/AppShell.jsx b/src/components/AppShell.jsx index 741031b..9b77d52 100644 --- a/src/components/AppShell.jsx +++ b/src/components/AppShell.jsx @@ -1,21 +1,24 @@ +import { PureComponent } from 'react'; import Box from '@mui/material/Box'; 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 IconButton from '@mui/material/IconButton'; import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import UserMenu from './UserMenu'; -import { NAV_ITEMS, isNavItemActive } from '../nav'; +import { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav'; import { artikelQueryPath } from '../pages/artikel/artikelQuery'; +import { defaultCategoryPath } from '../pages/artikel/categoryRoute'; const SIDEBAR_WIDTH = 144; +const SUB_NAV_WIDTH = 80; -export default function AppShell() { - const navigate = useNavigate(); - const { pathname } = useLocation(); +class AppSidebar extends PureComponent { + render() { + const { activeNav, navigate } = this.props; - return ( - + return ( ({ @@ -28,7 +31,7 @@ export default function AppShell() { > {NAV_ITEMS.map(({ path, label, icon: Icon }) => { - const active = isNavItemActive(pathname, path); + const active = path === activeNav; return ( + ); + } +} +class AppSubNav extends PureComponent { + render() { + const { activeNav, activeSub, navigate } = this.props; + const subNav = activeNav ? SUB_NAV[activeNav] : null; + + if (!subNav?.length) { + return ( + + ); + } + + return ( + + {subNav.map(({ segment, label, icon: Icon }) => { + const active = segment === activeSub; + + return ( + e.preventDefault()} + onClick={() => { + if (!segment && activeNav === '/artikel') { + navigate(artikelQueryPath()); + return; + } + if (segment === 'kategorien') { + navigate(defaultCategoryPath()); + return; + } + navigate(segment ? `${activeNav}/${segment}` : activeNav); + }} + sx={(theme) => ({ + width: 80, + height: 80, + borderRadius: '50%', + color: active + ? theme.palette.primary.main + : theme.palette.text.secondary, + bgcolor: 'transparent', + '&:hover': { + bgcolor: theme.palette.action.hover, + color: theme.palette.primary.main, + }, + })} + > + + + ); + })} + + ); + } +} + +export default function AppShell() { + const navigate = useNavigate(); + const { pathname } = useLocation(); + const activeNav = resolveActiveNav(pathname); + const activeSub = activeNav ? resolveActiveSub(pathname, activeNav) : null; + + return ( + + ({ flexGrow: 1, + display: 'flex', + gap: 2, py: 4, px: 3, bgcolor: theme.nav.mainBackground, + minWidth: 0, })} > - + + + + ); diff --git a/src/components/PageLayout.jsx b/src/components/PageLayout.jsx index 5f69124..633f35b 100644 --- a/src/components/PageLayout.jsx +++ b/src/components/PageLayout.jsx @@ -1,95 +1,23 @@ import Box from '@mui/material/Box'; -import IconButton from '@mui/material/IconButton'; import Typography from '@mui/material/Typography'; -import { useLocation, useNavigate } from 'react-router-dom'; -import { artikelQueryPath } from '../pages/artikel/artikelQuery'; -import { defaultCategoryPath, isKategorienPath } from '../pages/artikel/categoryRoute'; - -const SUB_NAV_WIDTH = 80; - -function isSubNavActive(pathname, basePath, segment) { - if (!segment) { - if (isKategorienPath(pathname)) return false; - return pathname === basePath || pathname === `${basePath}/`; - } - if (segment === 'kategorien') { - return isKategorienPath(pathname); - } - const subPath = `${basePath}/${segment}`; - return pathname === subPath || pathname === `${subPath}/`; -} - -export default function PageLayout({ title, showTitle = true, titleAction, subNav, basePath, children }) { - const hasSubNav = subNav?.length > 0; - const navigate = useNavigate(); - const { pathname } = useLocation(); +export default function PageLayout({ + title, + showTitle = true, + titleAction, + children, +}) { return ( - - - {hasSubNav && - subNav.map(({ segment, label, icon: Icon }) => { - const active = isSubNavActive(pathname, basePath, segment); - - return ( - e.preventDefault()} - onClick={() => { - if (!segment && basePath === '/artikel') { - navigate(artikelQueryPath()); - return; - } - if (segment === 'kategorien') { - navigate(defaultCategoryPath()); - return; - } - navigate(segment ? `${basePath}/${segment}` : basePath); - }} - sx={(theme) => ({ - width: 80, - height: 80, - borderRadius: '50%', - color: active ? theme.palette.primary.main : theme.palette.text.secondary, - bgcolor: 'transparent', - '&:hover': { - bgcolor: theme.palette.action.hover, - color: theme.palette.primary.main, - }, - })} - > - - - ); - })} - - - - {showTitle ? ( - - - {title} - - {titleAction} - - ) : null} - {children} - + + {showTitle ? ( + + + {title} + + {titleAction} + + ) : null} + {children} ); } diff --git a/src/components/SectionLayout.jsx b/src/components/SectionLayout.jsx index 921d715..e491071 100644 --- a/src/components/SectionLayout.jsx +++ b/src/components/SectionLayout.jsx @@ -10,7 +10,7 @@ import ListItemText from '@mui/material/ListItemText'; import GetAppIcon from '@mui/icons-material/GetApp'; import SettingsIcon from '@mui/icons-material/Settings'; import PageLayout from './PageLayout'; -import { NAV_ITEMS, SUB_NAV } from '../nav'; +import { NAV_ITEMS, SUB_NAV, resolveActiveSub } from '../nav'; import { isKategorienPath } from '../pages/artikel/categoryRoute'; import { selectRenameMode, setRenameMode } from '../store/categorySlice'; import { ActionElevatedPill, ToggleElevatedPill } from './ElevatedPill'; @@ -34,13 +34,8 @@ export default function SectionLayout({ sectionPath }) { const [extrasAnchor, setExtrasAnchor] = useState(null); const title = NAV_ITEMS.find((item) => item.path === sectionPath)?.label ?? ''; const subNav = SUB_NAV[sectionPath]; - const activeSub = subNav?.find((item) => { - if (item.segment === 'kategorien') { - return isKategorienPath(pathname); - } - const subPath = item.segment ? `${sectionPath}/${item.segment}` : sectionPath; - return pathname === subPath || pathname === `${subPath}/`; - }); + const subSegment = resolveActiveSub(pathname, sectionPath); + const activeSub = subNav?.find((item) => item.segment === subSegment); const knownSegments = new Set((subNav ?? []).map((item) => item.segment).filter(Boolean)); const rest = pathname.slice(sectionPath.length).replace(/^\//, '').split('/')[0]; @@ -107,8 +102,6 @@ export default function SectionLayout({ sectionPath }) { diff --git a/src/components/article/ArticlePhotoTile.jsx b/src/components/article/ArticlePhotoTile.jsx index 13a4c3e..6c114e6 100644 --- a/src/components/article/ArticlePhotoTile.jsx +++ b/src/components/article/ArticlePhotoTile.jsx @@ -1,4 +1,4 @@ -import { Component, createRef } from 'react'; +import { PureComponent, createRef } from 'react'; import ContentPasteIcon from '@mui/icons-material/ContentPaste'; import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; import { StorageManagerContext } from '../../api/storageManagerContext'; @@ -11,7 +11,7 @@ import { articleWithUploadedPhoto, primaryPhoto } from '../../utils/articleDetai import ArticlePhoto from './ArticlePhoto'; import ArticlePhotosDialog from './ArticlePhotosDialog'; -class ArticlePhotoTile extends Component { +class ArticlePhotoTile extends PureComponent { static contextType = StorageManagerContext; constructor(props) { diff --git a/src/components/article/ArticleTileTab.jsx b/src/components/article/ArticleTileTab.jsx index 691df2a..849da68 100644 --- a/src/components/article/ArticleTileTab.jsx +++ b/src/components/article/ArticleTileTab.jsx @@ -1,4 +1,4 @@ -import { Component } from 'react'; +import { PureComponent } from 'react'; import Box from '@mui/material/Box'; import IconButton from '@mui/material/IconButton'; import Tab from '@mui/material/Tab'; @@ -69,7 +69,7 @@ function visibleTabs(article) { return tabs; } -class ArticleTileTab extends Component { +class ArticleTileTab extends PureComponent { constructor(props) { super(props); this.state = { diff --git a/src/components/article/ArticleView.jsx b/src/components/article/ArticleView.jsx index 80f531f..4cf7fae 100644 --- a/src/components/article/ArticleView.jsx +++ b/src/components/article/ArticleView.jsx @@ -20,6 +20,9 @@ import ArticleViewEANs from './boxes/ArticleViewEANs'; import ArticleViewPrice from './boxes/ArticleViewPrice'; import ArticleViewLists from './boxes/ArticleViewLists'; +/** Stable prop so PureComponent photo tile does not see a new sx each render. */ +const PHOTO_TILE_SX = { minHeight: 0 }; + class ArticleView extends Component { static contextType = StorageManagerContext; @@ -27,10 +30,14 @@ class ArticleView extends Component { super(props, context); const { article, unwatch } = context.article.watch( props.sku, - (next) => {this.setState({ article: next });}, + (next) => { + this.setState({ article: next }); + }, ); this.state = { article }; this.unwatchArticle = unwatch; + this.handleTabChange = this.handleTabChange.bind(this); + this.handleArticleDeleted = this.handleArticleDeleted.bind(this); } componentDidUpdate(prevProps) { @@ -60,7 +67,7 @@ class ArticleView extends Component { this.unwatchArticle = null; } - handleArticleDeleted = () => { + handleArticleDeleted() { const { navigate } = this.props; const { article } = this.state; const categoryState = this.context.getCategoryState(); @@ -72,13 +79,13 @@ class ArticleView extends Component { return; } navigate(defaultCategoryPath()); - }; + } - handleTabChange = (nextTab) => { + handleTabChange(nextTab) { const { navigate } = this.props; const { article } = this.state; navigate(artikelDetailPath(article, nextTab)); - }; + } render() { const { tab } = this.props; @@ -99,7 +106,7 @@ class ArticleView extends Component { diff --git a/src/components/article/boxes/ArticleViewBrand.jsx b/src/components/article/boxes/ArticleViewBrand.jsx index ecabc09..ef0eebf 100644 --- a/src/components/article/boxes/ArticleViewBrand.jsx +++ b/src/components/article/boxes/ArticleViewBrand.jsx @@ -1,4 +1,4 @@ -import { Component } from 'react'; +import { Component, PureComponent } from 'react'; import Autocomplete from '@mui/material/Autocomplete'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; @@ -71,7 +71,7 @@ class BrandEdit extends Component { } } -export default class ArticleViewBrand extends Component { +export default class ArticleViewBrand extends PureComponent { static contextType = StorageManagerContext; constructor(props) { diff --git a/src/components/article/boxes/ArticleViewEANs.jsx b/src/components/article/boxes/ArticleViewEANs.jsx index a934347..56aae70 100644 --- a/src/components/article/boxes/ArticleViewEANs.jsx +++ b/src/components/article/boxes/ArticleViewEANs.jsx @@ -1,4 +1,4 @@ -import { Component } from 'react'; +import { Component, PureComponent } from 'react'; import Autocomplete from '@mui/material/Autocomplete'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; @@ -83,7 +83,7 @@ class EansEdit extends Component { } } -export default class ArticleViewEANs extends Component { +export default class ArticleViewEANs extends PureComponent { static contextType = StorageManagerContext; handleSave = async (draft) => { diff --git a/src/components/article/boxes/ArticleViewLists.jsx b/src/components/article/boxes/ArticleViewLists.jsx index d15c4ed..17ce63e 100644 --- a/src/components/article/boxes/ArticleViewLists.jsx +++ b/src/components/article/boxes/ArticleViewLists.jsx @@ -1,4 +1,4 @@ -import { Component } from 'react'; +import { Component, PureComponent } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import Link from '@mui/material/Link'; import Typography from '@mui/material/Typography'; @@ -35,7 +35,7 @@ class RelationLine extends Component { } } -export default class ArticleViewLists extends Component { +export default class ArticleViewLists extends PureComponent { render() { const { article: articleData } = this.props; return ( diff --git a/src/components/article/boxes/ArticleViewName.jsx b/src/components/article/boxes/ArticleViewName.jsx index cf8f219..0e1c16b 100644 --- a/src/components/article/boxes/ArticleViewName.jsx +++ b/src/components/article/boxes/ArticleViewName.jsx @@ -1,4 +1,4 @@ -import { Component } from 'react'; +import { Component, PureComponent } from 'react'; import { EditablePanelTile } from '../../panels'; import SingleLineTextEdit from '../../SingleLineTextEdit'; import { StorageManagerContext } from '../../../api/storageManagerContext'; @@ -224,7 +224,7 @@ class NameEdit extends Component { } } -export default class ArticleViewName extends Component { +export default class ArticleViewName extends PureComponent { static contextType = StorageManagerContext; constructor(props) { diff --git a/src/components/article/boxes/ArticleViewPrice.jsx b/src/components/article/boxes/ArticleViewPrice.jsx index 639176a..f74a93d 100644 --- a/src/components/article/boxes/ArticleViewPrice.jsx +++ b/src/components/article/boxes/ArticleViewPrice.jsx @@ -1,4 +1,4 @@ -import { Component } from 'react'; +import { Component, PureComponent } from 'react'; import Box from '@mui/material/Box'; import InputAdornment from '@mui/material/InputAdornment'; import TextField from '@mui/material/TextField'; @@ -364,7 +364,7 @@ class PriceEdit extends Component { } } -export default class ArticleViewPrice extends Component { +export default class ArticleViewPrice extends PureComponent { static contextType = StorageManagerContext; constructor(props) { diff --git a/src/components/article/boxes/ArticleViewStock.jsx b/src/components/article/boxes/ArticleViewStock.jsx index 227dfa4..d6f98d4 100644 --- a/src/components/article/boxes/ArticleViewStock.jsx +++ b/src/components/article/boxes/ArticleViewStock.jsx @@ -1,4 +1,4 @@ -import { Component } from 'react'; +import { Component, PureComponent } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import { PanelTile } from '../../panels'; @@ -25,7 +25,7 @@ class PanelValue extends Component { const SECOND_ROW_MIN_HEIGHT = 220; -export default class ArticleViewStock extends Component { +export default class ArticleViewStock extends PureComponent { render() { const { article: articleData } = this.props; return ( diff --git a/src/nav.js b/src/nav.js index 6ebcf42..553e2b7 100644 --- a/src/nav.js +++ b/src/nav.js @@ -20,15 +20,6 @@ export const NAV_ITEMS = [ { path: '/infos', label: 'Infos', icon: InfoIcon }, ]; -export function isNavItemActive(pathname, navPath) { - if (navPath === '/artikel') { - return pathname === navPath - || pathname.startsWith(`${navPath}/`) - || isKategorienPath(pathname); - } - return pathname === navPath || pathname.startsWith(`${navPath}/`); -} - export const SUB_NAV = { '/artikel': [ { segment: '', icon: ViewListIcon }, @@ -45,3 +36,49 @@ export const SUB_NAV = { { segment: 'kontakte', label: 'Kontakte', icon: ContactPageIcon }, ], }; + +export function isNavItemActive(pathname, navPath) { + if (navPath === '/artikel') { + return ( + pathname === navPath || + pathname.startsWith(`${navPath}/`) || + isKategorienPath(pathname) + ); + } + return pathname === navPath || pathname.startsWith(`${navPath}/`); +} + +/** Stable across article tab changes (e.g. always `/artikel`). */ +export function resolveActiveNav(pathname) { + for (const item of NAV_ITEMS) { + if (isNavItemActive(pathname, item.path)) return item.path; + } + return null; +} + +/** + * Active sub-nav segment for a section. + * `''` = section index, `null` = none (e.g. article detail tabs). + */ +export function resolveActiveSub(pathname, sectionPath) { + const subNav = SUB_NAV[sectionPath]; + if (!subNav) return null; + + const knownSegments = new Set(subNav.map((item) => item.segment).filter(Boolean)); + const rest = pathname.slice(sectionPath.length).replace(/^\//, '').split('/')[0]; + const isArticleDetail = + sectionPath === '/artikel' && + !isKategorienPath(pathname) && + rest && + !knownSegments.has(rest); + if (isArticleDetail) return null; + + const active = subNav.find((item) => { + if (item.segment === 'kategorien') { + return isKategorienPath(pathname); + } + const subPath = item.segment ? `${sectionPath}/${item.segment}` : sectionPath; + return pathname === subPath || pathname === `${subPath}/`; + }); + return active ? active.segment : null; +}