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;
+}