diff --git a/.cursor/rules/no-silent-fallbacks.mdc b/.cursor/rules/no-silent-fallbacks.mdc
new file mode 100644
index 0000000..990cb3e
--- /dev/null
+++ b/.cursor/rules/no-silent-fallbacks.mdc
@@ -0,0 +1,87 @@
+---
+description: Keine Fallbacks, keine alternativen API-Felder oder Endpoints
+alwaysApply: true
+---
+
+# Keine Fallbacks, keine alternativen APIs
+
+Stille Fallbacks und parallele API-Verträge verschleiern Fehler und entwerten Tests. Fehlende oder falsche Daten müssen **sichtbar** scheitern — nicht durch Ersatzwerte kaschiert werden.
+
+## Verboten
+
+### Alternative Feldnamen / Response-Formen
+
+```js
+// ❌
+const nodes = data?.tree ?? data?.categories ?? [];
+const target = article.sku ?? article.slug ?? article.id;
+const rootId = data.rootCategoryId ?? companyRootCategoryId ?? null;
+
+// ✅
+requireObject(data, 'category/tree');
+const nodes = requireArray(data.tree, 'category/tree.tree');
+const rootId = data.rootCategoryId ?? companyRootCategoryId;
+requireDefined(rootId, 'rootCategoryId');
+```
+
+Ein Endpoint, ein Shape, ein Feldname — dokumentiert in `src/api/index.js`.
+
+### Alternative Endpoints für denselben Zweck
+
+```js
+// ❌ Zwei Wege für dieselben Daten
+category.getTree();
+http('/api/article/byCategory?...');
+
+// ✅ Nur der vereinbarte Endpoint
+category.getArticles(categoryId);
+```
+
+Neue Endpoints nur nach expliziter Vereinbarung; alte nicht parallel als Fallback behalten.
+
+### Default-Werte beim Parsen / in Selectors
+
+```js
+// ❌
+selectCompanyIso639 = (s) => s.company.current?.iso639 ?? 'de';
+name: raw.name ?? 'Unbenannt';
+
+// ✅
+selectCompanyIso639 = (s) => s.company.current?.iso639;
+// UI zeigt leer oder Fehler, wenn null
+```
+
+### Display-Platzhalter für fehlende Daten
+
+```js
+// ❌
+{row.cName || '—'}
+label={`Kategorie ${id}`}
+
+// ✅
+{row.cName}
+// oder gar nicht rendern
+```
+
+### Ersatz-Identifikatoren in URLs
+
+```js
+// ❌
+/artikel/${sku ?? slug ?? id}
+
+// ✅
+/artikel/${sku} — nur wenn sku gesetzt, sonst disabled / kein Link
+```
+
+## Erlaubt
+
+- **Formular-Startwerte** beim Öffnen eines leeren Dialogs (z. B. `defaultIso639()` für Neuanlage)
+- **Explizite Fehlerbehandlung:** `requireObject` / `requireArray` / `requireDefined` aus `src/utils/apiShape.js` → Thunk rejected → `BackendError`
+- **Optionale Vertragsfelder** nur wenn im API-Vertrag als optional dokumentiert (z. B. `sort` in Sort-Comparator)
+
+## Bei neuem Code
+
+1. API-Vertrag in `src/api/index.js` kommentieren
+2. Antwort mit `apiShape`-Helfern validieren
+3. Kein `??`-Ketten für „vielleicht heißt es anders“
+4. Fehler im UI anzeigen, nicht durch Defaults überdecken
diff --git a/src/App.jsx b/src/App.jsx
index a0d92cc..e3dcd07 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -11,6 +11,7 @@ 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'));
@@ -73,9 +74,12 @@ class App extends Component {
} />
} />
} />
+ }>
+ } />
+
}>
} />
- } />
+ } />
} />
} />
diff --git a/src/api/index.js b/src/api/index.js
index a0a1fba..418bd2a 100644
--- a/src/api/index.js
+++ b/src/api/index.js
@@ -90,33 +90,54 @@ export const article = {
return http(`/api/article/search?${params}`);
},
- get({ target } = {}) {
- const params = new URLSearchParams({ target });
- return http(`/api/article/get?${params}`);
+ /** GET /api/article/:sku → { article: { id, sku, name, slug, net, taxRate, fatherArticleId, fatherSku, … } } */
+ getBySku(sku) {
+ return http(`/api/article/${encodeURIComponent(sku)}`);
+ },
+
+ /** POST /api/article → 201 { article } */
+ create({ parentId, name }) {
+ return http('/api/article', {
+ method: 'POST',
+ body: { parentId, name },
+ });
},
/**
- * GET /api/article/byCategory?categoryId&offset&maxCount
- * Articles assigned to a category (for tile view).
+ * PATCH /api/article/:sku
+ * Body: partial fields, e.g. { name }
+ * → 200 { article }
*/
- byCategory({ categoryId, offset, maxCount } = {}) {
- const params = new URLSearchParams({ categoryId: String(categoryId) });
- if (offset) params.set('offset', String(offset));
- if (maxCount != null) params.set('maxCount', String(maxCount));
- return http(`/api/article/byCategory?${params}`);
+ update(sku, body) {
+ return http(`/api/article/${encodeURIComponent(sku)}`, {
+ method: 'PATCH',
+ body,
+ });
},
};
export const category = {
- /**
- * GET /api/category/tree?companyId=
- * Flat map of categories keyed by id; rootCategoryId resolved server-side.
- */
- getTree({ companyId } = {}) {
- const params = new URLSearchParams();
- if (companyId != null) params.set('companyId', String(companyId));
- const qs = params.toString();
- return http(`/api/category/tree${qs ? `?${qs}` : ''}`);
+ /** GET /api/category/tree → { languageId, tree, articles } (company from session) */
+ getTree() {
+ return http('/api/category/tree');
+ },
+
+ /** GET /api/category/:id/articles → { articles: [...] } */
+ getArticles(categoryId) {
+ return http(`/api/category/${categoryId}/articles`);
+ },
+
+ /** POST /api/category → 201 { category } */
+ create({ parentId, name }) {
+ return http('/api/category', {
+ method: 'POST',
+ body: { parentId, name },
+ });
+ },
+
+ /** DELETE /api/category/:id → 204 */
+ delete(categoryId) {
+ return http(`/api/category/${categoryId}`, { method: 'DELETE' });
},
};
diff --git a/src/components/CompanySettingsDialog.jsx b/src/components/CompanySettingsDialog.jsx
index 1ff38fb..9bab05e 100644
--- a/src/components/CompanySettingsDialog.jsx
+++ b/src/components/CompanySettingsDialog.jsx
@@ -60,7 +60,7 @@ class CompanySettingsDialog extends Component {
syncFormFromCompany(company) {
this.setState({
name: company.name ?? '',
- iso639: company.iso639 ?? 'de',
+ iso639: company.iso639,
taxrate: company.taxrate != null ? String(company.taxrate) : '',
reducedtaxrate: company.reducedtaxrate != null ? String(company.reducedtaxrate) : '',
});
diff --git a/src/components/NameDialog.jsx b/src/components/NameDialog.jsx
new file mode 100644
index 0000000..258c55d
--- /dev/null
+++ b/src/components/NameDialog.jsx
@@ -0,0 +1,107 @@
+import { Component } from 'react';
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
+import Dialog from '@mui/material/Dialog';
+import DialogActions from '@mui/material/DialogActions';
+import DialogContent from '@mui/material/DialogContent';
+import DialogTitle from '@mui/material/DialogTitle';
+import TextField from '@mui/material/TextField';
+import Alert from '@mui/material/Alert';
+import BackendError from './BackendError';
+import { backendErrorFrom, isBackendFailure } from '../utils/backendError';
+
+/**
+ * Single-field name dialog (create/edit).
+ * Props: open, title, submitLabel, onClose, onSubmit({ name }),
+ * optional initialName (form start value when opened).
+ */
+class NameDialog extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ name: '',
+ busy: false,
+ error: null,
+ };
+ this.handleSubmit = this.handleSubmit.bind(this);
+ this.handleClose = this.handleClose.bind(this);
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.open && !prevProps.open) {
+ this.setState({
+ name: this.props.initialName ?? '',
+ busy: false,
+ error: null,
+ });
+ }
+ }
+
+ handleClose() {
+ if (this.state.busy) return;
+ this.props.onClose();
+ }
+
+ async handleSubmit(event) {
+ event.preventDefault();
+ const name = this.state.name.trim();
+ if (!name) {
+ this.setState({ error: 'Bitte Name eingeben.' });
+ return;
+ }
+
+ this.setState({ busy: true, error: null });
+ try {
+ await this.props.onSubmit({ name });
+ this.props.onClose();
+ } catch (err) {
+ this.setState({ error: backendErrorFrom(err), busy: false });
+ }
+ }
+
+ render() {
+ const { open, title, submitLabel } = this.props;
+ const { name, busy, error } = this.state;
+ const canSave = name.trim() !== '';
+
+ return (
+
+ );
+ }
+}
+
+export default NameDialog;
diff --git a/src/components/PageLayout.jsx b/src/components/PageLayout.jsx
index e6bdd66..47347e2 100644
--- a/src/components/PageLayout.jsx
+++ b/src/components/PageLayout.jsx
@@ -4,21 +4,24 @@ import Tooltip from '@mui/material/Tooltip';
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, 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);
}
+ if (segment === 'kategorien') {
+ return isKategorienPath(pathname);
+ }
const subPath = `${basePath}/${segment}`;
return pathname === subPath || pathname === `${subPath}/`;
}
-export default function PageLayout({ title, subNav, basePath, children }) {
+export default function PageLayout({ title, showTitle = true, subNav, basePath, children }) {
const hasSubNav = subNav?.length > 0;
const navigate = useNavigate();
const { pathname } = useLocation();
@@ -55,6 +58,10 @@ export default function PageLayout({ title, subNav, basePath, children }) {
navigate(artikelQueryPath());
return;
}
+ if (segment === 'kategorien') {
+ navigate(defaultCategoryPath());
+ return;
+ }
navigate(segment ? `${basePath}/${segment}` : basePath);
}}
sx={(theme) => ({
@@ -77,9 +84,11 @@ export default function PageLayout({ title, subNav, basePath, children }) {
-
- {title}
-
+ {showTitle ? (
+
+ {title}
+
+ ) : null}
{children}
diff --git a/src/components/SectionLayout.jsx b/src/components/SectionLayout.jsx
index 760d687..76cd763 100644
--- a/src/components/SectionLayout.jsx
+++ b/src/components/SectionLayout.jsx
@@ -1,27 +1,34 @@
import { Outlet, useLocation } from 'react-router-dom';
import PageLayout from './PageLayout';
import { NAV_ITEMS, SUB_NAV } from '../nav';
+import { isKategorienPath } from '../pages/artikel/categoryRoute';
export default function SectionLayout({ sectionPath }) {
const { pathname } = useLocation();
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 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 isArticleDetail =
+ sectionPath === '/artikel' &&
+ !isKategorienPath(pathname) &&
+ rest &&
+ !knownSegments.has(rest);
- const pageTitle = detailSku ?? (activeSub && subNav ? activeSub.label : title);
+ const pageTitle = isArticleDetail
+ ? title
+ : (activeSub?.label ?? title);
return (
-
+
);
diff --git a/src/components/article/ArticleNameDialog.jsx b/src/components/article/ArticleNameDialog.jsx
new file mode 100644
index 0000000..83a32e7
--- /dev/null
+++ b/src/components/article/ArticleNameDialog.jsx
@@ -0,0 +1,13 @@
+import NameDialog from '../NameDialog';
+
+export default function ArticleNameDialog({ open, onClose, onSubmit }) {
+ return (
+
+ );
+}
diff --git a/src/components/article/ArticleView.jsx b/src/components/article/ArticleView.jsx
new file mode 100644
index 0000000..df11650
--- /dev/null
+++ b/src/components/article/ArticleView.jsx
@@ -0,0 +1,209 @@
+import { Link as RouterLink } from 'react-router-dom';
+import Box from '@mui/material/Box';
+import Link from '@mui/material/Link';
+import TextField from '@mui/material/TextField';
+import Typography from '@mui/material/Typography';
+import { article } from '../../api';
+import { EditablePanelTile, PanelTile, PanelTileGrid } from '../panels';
+import { requireObject } from '../../utils/apiShape';
+import { formatPrice } from '../../utils/price';
+import { formatStock } from '../../pages/artikel/searchFormat';
+
+function PanelValue({ label, children }) {
+ if (children == null || children === '') return null;
+ return (
+
+ {label ? (
+
+ {label}
+
+ ) : null}
+
+ {children}
+
+
+ );
+}
+
+function ArticlePhoto() {
+ return (
+
+ );
+}
+
+const headerTileSx = {
+ p: 1.25,
+ minHeight: 0,
+};
+
+function CountLine({ label, count }) {
+ if (count == null) return null;
+ return (
+
+ {label}: {count}
+
+ );
+}
+
+function RelationLine({ label, active, children }) {
+ return (
+
+ {label}:{' '}
+ {active ? (
+ children ?? 'ja'
+ ) : (
+
+ nein
+
+ )}
+
+ );
+}
+
+function articleBrutto(article) {
+ if (article.net == null || article.taxRate == null) return null;
+ return article.net * (1 + article.taxRate / 100);
+}
+
+function nameDraftDirty(draft, saved) {
+ return draft.name.trim() !== String(saved.name ?? '').trim();
+}
+
+function nameDraftValid(draft) {
+ return draft.name.trim().length > 0;
+}
+
+export default function ArticleView({ article: articleData, onArticleUpdated }) {
+ const brutto = articleBrutto(articleData);
+
+ const handleSaveName = async (draft) => {
+ const name = draft.name.trim();
+ const res = await article.update(articleData.sku, { name });
+ requireObject(res, 'PATCH /api/article/:sku');
+ requireObject(res.article, 'article');
+ onArticleUpdated?.(res.article);
+ };
+
+ return (
+
+ (
+
+
+ {name}
+
+
+ )}
+ renderEdit={(draft, onChange, inputRef) => (
+
+ onChange({ name: event.target.value })}
+ sx={{
+ '& .MuiInput-input': {
+ fontSize: '1.25rem',
+ fontWeight: 700,
+ lineHeight: 1.25,
+ },
+ }}
+ />
+
+ )}
+ />
+
+
+
+
+
+
+
+ {articleData.stock != null ? formatStock(articleData.stock) : null}
+
+
+
+
+ {articleData.brand}
+
+
+
+ {articleData.sku}
+ {articleData.ean}
+
+
+
+
+ {articleData.net != null ? formatPrice(articleData.net) : null}
+
+
+ {articleData.taxRate != null ? `${articleData.taxRate} %` : null}
+
+
+ {brutto != null ? formatPrice(brutto) : null}
+
+
+
+
+ {articleData.slug}
+ {articleData.seoTitle}
+ {articleData.seoDescription}
+ {articleData.description}
+
+
+
+ 0}
+ >
+ {articleData.childCount}
+
+
+ {articleData.fatherSku ? (
+
+ {articleData.fatherSku}
+
+ ) : null}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/article/NewArticleTile.jsx b/src/components/article/NewArticleTile.jsx
new file mode 100644
index 0000000..15f3db9
--- /dev/null
+++ b/src/components/article/NewArticleTile.jsx
@@ -0,0 +1,58 @@
+import { Component } from 'react';
+import { connect } from 'react-redux';
+import { enqueueSnackbar } from 'notistack';
+import { ActionTile } from '../tiles';
+import { createArticle as createArticleThunk } from '../../store/categorySlice';
+import ArticleNameDialog from './ArticleNameDialog';
+
+class NewArticleTile extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { open: false };
+ this.handleOpen = this.handleOpen.bind(this);
+ this.handleClose = this.handleClose.bind(this);
+ this.handleSubmit = this.handleSubmit.bind(this);
+ }
+
+ handleOpen() {
+ this.setState({ open: true });
+ }
+
+ handleClose() {
+ this.setState({ open: false });
+ }
+
+ async handleSubmit({ name }) {
+ const { parentId } = this.props;
+ const article = await this.props.createArticle({ parentId, name }).unwrap();
+ enqueueSnackbar('Artikel angelegt.', { variant: 'success' });
+ if (article) {
+ this.props.onOpenArticle?.(article);
+ }
+ }
+
+ render() {
+ const { parentId, disabled } = this.props;
+
+ return (
+ <>
+
+
+ >
+ );
+ }
+}
+
+const mapDispatchToProps = {
+ createArticle: createArticleThunk,
+};
+
+export default connect(null, mapDispatchToProps)(NewArticleTile);
diff --git a/src/components/article/index.js b/src/components/article/index.js
new file mode 100644
index 0000000..e1fa182
--- /dev/null
+++ b/src/components/article/index.js
@@ -0,0 +1,3 @@
+export { default as ArticleView } from './ArticleView';
+export { default as ArticleNameDialog } from './ArticleNameDialog';
+export { default as NewArticleTile } from './NewArticleTile';
diff --git a/src/components/category/CategoryBreadcrumbs.jsx b/src/components/category/CategoryBreadcrumbs.jsx
new file mode 100644
index 0000000..c468f82
--- /dev/null
+++ b/src/components/category/CategoryBreadcrumbs.jsx
@@ -0,0 +1,42 @@
+import { Component } from 'react';
+import Breadcrumbs from '@mui/material/Breadcrumbs';
+import Link from '@mui/material/Link';
+
+class CategoryBreadcrumbs extends Component {
+ handleNavigate = (categoryId) => {
+ this.props.onNavigate(categoryId);
+ };
+
+ render() {
+ const { crumbs } = this.props;
+ const visible = (crumbs ?? []).filter((crumb) => crumb.name);
+ if (visible.length === 0) return null;
+
+ return (
+
+ {visible.map((crumb, index) => (
+ this.handleNavigate(crumb.id)}
+ sx={{
+ fontWeight: 600,
+ lineHeight: 1.43,
+ p: 0,
+ minWidth: 0,
+ }}
+ >
+ {crumb.name}
+
+ ))}
+
+ );
+ }
+}
+
+export default CategoryBreadcrumbs;
diff --git a/src/components/category/CategoryNameDialog.jsx b/src/components/category/CategoryNameDialog.jsx
new file mode 100644
index 0000000..ea76c87
--- /dev/null
+++ b/src/components/category/CategoryNameDialog.jsx
@@ -0,0 +1,30 @@
+import NameDialog from '../NameDialog';
+
+const TITLES = {
+ create: 'Neue Kategorie',
+ edit: 'Kategorie bearbeiten',
+};
+
+const SUBMIT_LABELS = {
+ create: 'Anlegen',
+ edit: 'Speichern',
+};
+
+export default function CategoryNameDialog({
+ open,
+ mode = 'create',
+ initialName,
+ onClose,
+ onSubmit,
+}) {
+ return (
+
+ );
+}
diff --git a/src/components/category/EditCategoryDialog.jsx b/src/components/category/EditCategoryDialog.jsx
new file mode 100644
index 0000000..6d5065c
--- /dev/null
+++ b/src/components/category/EditCategoryDialog.jsx
@@ -0,0 +1,21 @@
+import { Component } from 'react';
+import CategoryNameDialog from './CategoryNameDialog';
+
+/** Edit flow — wire onSubmit when PUT /api/category/:id exists. */
+class EditCategoryDialog extends Component {
+ render() {
+ const { open, onClose, initialName, onSubmit } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+export default EditCategoryDialog;
diff --git a/src/components/category/NewCategoryTile.jsx b/src/components/category/NewCategoryTile.jsx
new file mode 100644
index 0000000..e00e009
--- /dev/null
+++ b/src/components/category/NewCategoryTile.jsx
@@ -0,0 +1,56 @@
+import { Component } from 'react';
+import { connect } from 'react-redux';
+import { enqueueSnackbar } from 'notistack';
+import { ActionTile } from '../tiles';
+import { createCategory as createCategoryThunk } from '../../store/categorySlice';
+import CategoryNameDialog from './CategoryNameDialog';
+
+class NewCategoryTile extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { open: false };
+ this.handleOpen = this.handleOpen.bind(this);
+ this.handleClose = this.handleClose.bind(this);
+ this.handleSubmit = this.handleSubmit.bind(this);
+ }
+
+ handleOpen() {
+ this.setState({ open: true });
+ }
+
+ handleClose() {
+ this.setState({ open: false });
+ }
+
+ async handleSubmit({ name }) {
+ const { parentId } = this.props;
+ await this.props.createCategory({ parentId, name }).unwrap();
+ enqueueSnackbar('Kategorie angelegt.', { variant: 'success' });
+ }
+
+ render() {
+ const { parentId, disabled } = this.props;
+
+ return (
+ <>
+
+
+ >
+ );
+ }
+}
+
+const mapDispatchToProps = {
+ createCategory: createCategoryThunk,
+};
+
+export default connect(null, mapDispatchToProps)(NewCategoryTile);
diff --git a/src/components/category/index.js b/src/components/category/index.js
new file mode 100644
index 0000000..637eb84
--- /dev/null
+++ b/src/components/category/index.js
@@ -0,0 +1,4 @@
+export { default as CategoryNameDialog } from './CategoryNameDialog';
+export { default as CategoryBreadcrumbs } from './CategoryBreadcrumbs';
+export { default as NewCategoryTile } from './NewCategoryTile';
+export { default as EditCategoryDialog } from './EditCategoryDialog';
diff --git a/src/components/panels/EditablePanelTile.jsx b/src/components/panels/EditablePanelTile.jsx
new file mode 100644
index 0000000..7c8a872
--- /dev/null
+++ b/src/components/panels/EditablePanelTile.jsx
@@ -0,0 +1,204 @@
+import { Component, createRef } from 'react';
+import CloseOutlinedIcon from '@mui/icons-material/CloseOutlined';
+import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
+import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
+import Alert from '@mui/material/Alert';
+import Box from '@mui/material/Box';
+import CircularProgress from '@mui/material/CircularProgress';
+import IconButton from '@mui/material/IconButton';
+import Paper from '@mui/material/Paper';
+import Typography from '@mui/material/Typography';
+import { enqueueSnackbar } from 'notistack';
+import { backendErrorFrom } from '../../utils/backendError';
+import { panelTilePaperSx } from './panelTileLayout';
+
+function valuesEqual(a, b) {
+ return JSON.stringify(a) === JSON.stringify(b);
+}
+
+/**
+ * Panel tile with centralized inline-edit chrome.
+ * Pass `onSave` to enable edit UI; omit it for read-only tiles (no edit icon).
+ */
+export class EditablePanelTile extends Component {
+ constructor(props) {
+ super(props);
+ this.inputRef = createRef();
+ this.state = {
+ editing: false,
+ draft: null,
+ saving: false,
+ error: null,
+ };
+ }
+
+ componentDidUpdate(_prevProps, prevState) {
+ if (!prevState.editing && this.state.editing) {
+ this.inputRef.current?.focus();
+ }
+ }
+
+ getSavedValue() {
+ return this.props.value;
+ }
+
+ isDirty() {
+ const { draft, editing } = this.state;
+ if (!editing || draft == null) return false;
+ if (this.props.isDirty) {
+ return this.props.isDirty(draft, this.getSavedValue());
+ }
+ return !valuesEqual(draft, this.getSavedValue());
+ }
+
+ canSave() {
+ const { draft, saving } = this.state;
+ if (saving || !this.isDirty() || draft == null) return false;
+ if (this.props.canSave) {
+ return this.props.canSave(draft, this.getSavedValue());
+ }
+ return true;
+ }
+
+ handleStartEdit = (event) => {
+ event.stopPropagation();
+ this.setState({
+ editing: true,
+ draft: structuredClone(this.getSavedValue()),
+ error: null,
+ });
+ };
+
+ handleCancel = (event) => {
+ event.stopPropagation();
+ this.setState({
+ editing: false,
+ draft: null,
+ error: null,
+ });
+ };
+
+ handleDraftChange = (nextDraft) => {
+ this.setState({ draft: nextDraft, error: null });
+ };
+
+ handleSave = async (event) => {
+ event.stopPropagation();
+ if (!this.canSave()) return;
+
+ const { onSave } = this.props;
+ const { draft } = this.state;
+ this.setState({ saving: true, error: null });
+
+ try {
+ await onSave(draft);
+ this.setState({ editing: false, draft: null, saving: false, error: null });
+ enqueueSnackbar('Gespeichert.', { variant: 'success' });
+ } catch (err) {
+ const normalized = backendErrorFrom(err);
+ this.setState({ saving: false, error: normalized.message });
+ enqueueSnackbar(normalized.message, { variant: 'error' });
+ }
+ };
+
+ renderTitle() {
+ const { title } = this.props;
+ const { editing } = this.state;
+ if (!title) return null;
+ return (
+
+ {title}
+
+ );
+ }
+
+ renderActions() {
+ const { onSave } = this.props;
+ if (!onSave) return null;
+
+ const { editing, saving } = this.state;
+ const actionSx = {
+ position: 'absolute',
+ top: 4,
+ color: 'text.secondary',
+ };
+
+ if (!editing) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+ {saving ? (
+
+ ) : (
+
+ )}
+
+ >
+ );
+ }
+
+ render() {
+ const {
+ wide = 1,
+ sx,
+ minHeight = 120,
+ renderDisplay,
+ renderEdit,
+ } = this.props;
+ const { editing, draft, error } = this.state;
+ const savedValue = this.getSavedValue();
+ const pending = this.isDirty();
+
+ return (
+
+ {this.renderActions()}
+ {this.renderTitle()}
+
+ {editing && draft != null
+ ? renderEdit(draft, this.handleDraftChange, this.inputRef)
+ : renderDisplay(savedValue)}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ );
+ }
+}
diff --git a/src/components/panels/PanelTile.jsx b/src/components/panels/PanelTile.jsx
new file mode 100644
index 0000000..9c57254
--- /dev/null
+++ b/src/components/panels/PanelTile.jsx
@@ -0,0 +1,56 @@
+import { Component } from 'react';
+import Box from '@mui/material/Box';
+import Paper from '@mui/material/Paper';
+import Typography from '@mui/material/Typography';
+import { GRID_COLUMNS, panelTilePaperSx } from './panelTileLayout';
+
+export class PanelTile extends Component {
+ renderTitle() {
+ const { title } = this.props;
+ if (!title) return null;
+ return (
+
+ {title}
+
+ );
+ }
+
+ render() {
+ const { wide = 1, children, sx, minHeight = 120 } = this.props;
+
+ return (
+
+ {this.renderTitle()}
+ {children}
+
+ );
+ }
+}
+
+export function PanelTileGrid({ children, sx }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export { GRID_COLUMNS };
diff --git a/src/components/panels/index.js b/src/components/panels/index.js
new file mode 100644
index 0000000..d26b4a4
--- /dev/null
+++ b/src/components/panels/index.js
@@ -0,0 +1,3 @@
+export { PanelTile, PanelTileGrid, GRID_COLUMNS } from './PanelTile';
+export { EditablePanelTile } from './EditablePanelTile';
+export { PENDING_EDIT_BORDER } from './panelTileLayout';
diff --git a/src/components/panels/panelTileLayout.js b/src/components/panels/panelTileLayout.js
new file mode 100644
index 0000000..66760ba
--- /dev/null
+++ b/src/components/panels/panelTileLayout.js
@@ -0,0 +1,37 @@
+const GRID_COLUMNS = 4;
+
+export const PENDING_EDIT_BORDER = '#ec4899';
+
+function clampWide(wide) {
+ return Math.min(Math.max(wide, 1), 3);
+}
+
+export function responsiveColumnSpan(wide) {
+ const span = clampWide(wide);
+ return {
+ xs: '1 / -1',
+ sm: span >= 2 ? 'span 2' : 'span 1',
+ md: `span ${span}`,
+ };
+}
+
+export const PENDING_EDIT_RING_PX = 4;
+
+export function panelTilePaperSx({ wide, sx, minHeight = 120, pending = false }) {
+ return {
+ gridColumn: responsiveColumnSpan(wide),
+ position: 'relative',
+ p: 2,
+ minHeight,
+ display: 'flex',
+ flexDirection: 'column',
+ ...(pending
+ ? {
+ boxShadow: `inset 0 0 0 ${PENDING_EDIT_RING_PX}px ${PENDING_EDIT_BORDER}`,
+ }
+ : {}),
+ ...sx,
+ };
+}
+
+export { GRID_COLUMNS };
diff --git a/src/components/tiles/ArticleTile.jsx b/src/components/tiles/ArticleTile.jsx
index c8a95db..bcbefe1 100644
--- a/src/components/tiles/ArticleTile.jsx
+++ b/src/components/tiles/ArticleTile.jsx
@@ -2,6 +2,7 @@ import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import Typography from '@mui/material/Typography';
import { Tile } from './Tile';
import { formatPrice } from '../../utils/price';
+import { articleSku } from '../../pages/artikel/articleRoute';
export class ArticleTile extends Tile {
renderIcon() {
@@ -11,14 +12,17 @@ export class ArticleTile extends Tile {
renderSecondary() {
const { article } = this.props;
if (!article) return null;
+ const sku = articleSku(article);
return (
<>
-
- {article.sku}
-
- {article.price != null && (
+ {sku ? (
+
+ {sku}
+
+ ) : null}
+ {article.net != null && (
- {formatPrice(article.price)}
+ {formatPrice(article.net)}
)}
>
diff --git a/src/components/tiles/Tile.jsx b/src/components/tiles/Tile.jsx
index 60c9dc3..16231b4 100644
--- a/src/components/tiles/Tile.jsx
+++ b/src/components/tiles/Tile.jsx
@@ -1,5 +1,5 @@
import { Component } from 'react';
-import Paper from '@mui/material/Paper';
+import ButtonBase from '@mui/material/ButtonBase';
import Typography from '@mui/material/Typography';
export const TILE_SIZE = 140;
@@ -55,8 +55,7 @@ export class Tile extends Component {
render() {
const { sx, disabled } = this.props;
return (
-
+
);
}
}
diff --git a/src/pages/artikel/RedirectArtikelKategorien.jsx b/src/pages/artikel/RedirectArtikelKategorien.jsx
new file mode 100644
index 0000000..0b18489
--- /dev/null
+++ b/src/pages/artikel/RedirectArtikelKategorien.jsx
@@ -0,0 +1,8 @@
+import { Navigate, useParams } from 'react-router-dom';
+import { KATEGORIEN_BASE } from './categoryRoute';
+
+export default function RedirectArtikelKategorien() {
+ const { '*': rest = '' } = useParams();
+ const suffix = rest ? `/${rest}` : '';
+ return ;
+}
diff --git a/src/pages/artikel/articleRoute.js b/src/pages/artikel/articleRoute.js
new file mode 100644
index 0000000..d641367
--- /dev/null
+++ b/src/pages/artikel/articleRoute.js
@@ -0,0 +1,11 @@
+export function articleSku(article) {
+ if (!article) return null;
+ const sku = article.sku?.trim();
+ return sku || null;
+}
+
+export function artikelDetailPath(article) {
+ const sku = articleSku(article);
+ if (!sku) return null;
+ return `/artikel/${encodeURIComponent(sku)}`;
+}
diff --git a/src/pages/artikel/categoryRoute.js b/src/pages/artikel/categoryRoute.js
new file mode 100644
index 0000000..a2a6e27
--- /dev/null
+++ b/src/pages/artikel/categoryRoute.js
@@ -0,0 +1,67 @@
+import {
+ ROOT_CATEGORY_NAME,
+ categorySlug,
+ getCategoryBreadcrumbs,
+ getCategoryChildren,
+} from '../../store/categorySlice';
+
+export { ROOT_CATEGORY_NAME, categorySlug };
+
+export const KATEGORIEN_BASE = '/kategorien';
+
+export function isKategorienPath(pathname) {
+ return pathname === KATEGORIEN_BASE || pathname.startsWith(`${KATEGORIEN_BASE}/`);
+}
+
+function isRootSlug(slug) {
+ return slug === ROOT_CATEGORY_NAME;
+}
+
+export function categoryPathFromCrumbs(crumbs) {
+ const segments = (crumbs ?? [])
+ .filter((crumb) => crumb.slug && !isRootSlug(crumb.slug))
+ .map((crumb) => encodeURIComponent(crumb.slug));
+ if (segments.length === 0) {
+ return KATEGORIEN_BASE;
+ }
+ return `${KATEGORIEN_BASE}/${segments.join('/')}`;
+}
+
+export function buildCategoryPath(categories, rootCategoryId, categoryId) {
+ const crumbs = getCategoryBreadcrumbs(categories, categoryId, rootCategoryId);
+ return categoryPathFromCrumbs(crumbs);
+}
+
+export function resolveCategoryIdFromSlugPath(categories, rootCategoryId, slugPath) {
+ if (!categories || rootCategoryId == null) return null;
+
+ let segments = (slugPath ?? '')
+ .split('/')
+ .filter(Boolean)
+ .map((segment) => decodeURIComponent(segment));
+
+ // Legacy slug URLs: .../Home/...
+ if (segments[0] === ROOT_CATEGORY_NAME) {
+ segments = segments.slice(1);
+ }
+
+ if (segments.length === 0) {
+ return rootCategoryId;
+ }
+
+ let currentId = rootCategoryId;
+ for (const slug of segments) {
+ const children = getCategoryChildren(categories, currentId);
+ const match = children.find(
+ (child) => categorySlug(child.name, child.id, rootCategoryId) === slug,
+ );
+ if (!match) return null;
+ currentId = match.id;
+ }
+
+ return currentId;
+}
+
+export function defaultCategoryPath() {
+ return KATEGORIEN_BASE;
+}
diff --git a/src/pages/artikel/kategorien.jsx b/src/pages/artikel/kategorien.jsx
index 63e7d4f..b565492 100644
--- a/src/pages/artikel/kategorien.jsx
+++ b/src/pages/artikel/kategorien.jsx
@@ -4,28 +4,36 @@ import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Typography from '@mui/material/Typography';
import BackendError from '../../components/BackendError';
+import { NewCategoryTile, CategoryBreadcrumbs } from '../../components/category';
+import { NewArticleTile } from '../../components/article';
import {
- ActionTile,
ArticleTile,
BackTile,
CategoryTile,
TILE_SIZE,
} from '../../components/tiles';
-import withNavigate from '../../components/withNavigate';
+import withCategoryRoute from './withCategoryRoute';
+import { artikelDetailPath, articleSku } from './articleRoute';
import { selectCompanyId } from '../../store/companySlice';
+import {
+ buildCategoryPath,
+ defaultCategoryPath,
+ resolveCategoryIdFromSlugPath,
+ ROOT_CATEGORY_NAME,
+} from './categoryRoute';
import {
categoryDisplayName,
loadCategoryArticles,
loadCategoryTree,
- navigateBack,
navigateToCategory,
selectCanNavigateBack,
selectCategoryArticles,
selectCategoryArticlesStatus,
+ selectCategoryBreadcrumbs,
+ selectCategoryMap,
selectCategoryError,
selectCategoryTreeStatus,
selectChildCategories,
- selectCurrentCategory,
} from '../../store/categorySlice';
const gridSx = {
@@ -37,6 +45,7 @@ const gridSx = {
class ArtikelKategorienPage extends Component {
componentDidMount() {
this.ensureTreeLoaded();
+ this.syncCategoryFromUrl();
this.ensureArticlesLoaded();
}
@@ -44,13 +53,23 @@ class ArtikelKategorienPage extends Component {
if (this.props.companyId !== prevProps.companyId) {
this.ensureTreeLoaded();
}
- if (this.props.currentCategoryId !== prevProps.currentCategoryId) {
- this.ensureArticlesLoaded();
- }
+
if (
this.props.treeStatus === 'succeeded' &&
prevProps.treeStatus !== 'succeeded'
) {
+ this.syncCategoryFromUrl();
+ this.ensureArticlesLoaded();
+ }
+
+ if (
+ this.props.treeStatus === 'succeeded' &&
+ this.props.categorySlugPath !== prevProps.categorySlugPath
+ ) {
+ this.syncCategoryFromUrl();
+ }
+
+ if (this.props.currentCategoryId !== prevProps.currentCategoryId) {
this.ensureArticlesLoaded();
}
}
@@ -58,35 +77,76 @@ class ArtikelKategorienPage extends Component {
ensureTreeLoaded = () => {
const { companyId, treeStatus } = this.props;
if (!companyId || treeStatus !== 'idle') return;
- this.props.loadCategoryTree(companyId);
+ this.props.loadCategoryTree();
};
ensureArticlesLoaded = () => {
- const { currentCategoryId, articlesStatus, rootCategoryId } = this.props;
- if (!currentCategoryId || articlesStatus !== 'idle') return;
- // Root articles come from tree response; only fetch for subcategories.
+ const { currentCategoryId, articlesStatus, rootCategoryId, treeStatus } = this.props;
+ if (treeStatus !== 'succeeded' || !currentCategoryId || articlesStatus !== 'idle') return;
if (String(currentCategoryId) === String(rootCategoryId)) return;
- this.props.loadCategoryArticles({ categoryId: currentCategoryId });
+ this.props.loadCategoryArticles(currentCategoryId);
+ };
+
+ syncCategoryFromUrl = () => {
+ const {
+ categories,
+ rootCategoryId,
+ categorySlugPath,
+ currentCategoryId,
+ treeStatus,
+ navigate,
+ } = this.props;
+
+ if (treeStatus !== 'succeeded' || !categories || rootCategoryId == null) return;
+
+ const segments = (categorySlugPath ?? '').split('/').filter(Boolean);
+ const hasLegacyHomePrefix = segments[0] === ROOT_CATEGORY_NAME;
+
+ const resolvedId = resolveCategoryIdFromSlugPath(
+ categories,
+ rootCategoryId,
+ categorySlugPath,
+ );
+
+ if (resolvedId == null) {
+ navigate(defaultCategoryPath(), { replace: true });
+ return;
+ }
+
+ if (hasLegacyHomePrefix) {
+ navigate(buildCategoryPath(categories, rootCategoryId, resolvedId), { replace: true });
+ }
+
+ if (resolvedId !== currentCategoryId) {
+ this.props.navigateToCategory(resolvedId);
+ }
+ };
+
+ navigateToCategoryById = (categoryId) => {
+ const { categories, rootCategoryId, navigate } = this.props;
+ navigate(buildCategoryPath(categories, rootCategoryId, categoryId));
};
handleRetryTree = () => {
- const { companyId } = this.props;
- if (companyId) this.props.loadCategoryTree(companyId);
+ if (this.props.companyId) this.props.loadCategoryTree();
};
handleRetryArticles = () => {
const { currentCategoryId } = this.props;
if (currentCategoryId) {
- this.props.loadCategoryArticles({ categoryId: currentCategoryId });
+ this.props.loadCategoryArticles(currentCategoryId);
}
};
- handleCategoryClick = (categoryId) => {
- this.props.navigateToCategory(categoryId);
+ handleNavigateBack = () => {
+ const { breadcrumbs } = this.props;
+ if (breadcrumbs.length < 2) return;
+ this.navigateToCategoryById(breadcrumbs[breadcrumbs.length - 2].id);
};
- handleArticleClick = (sku) => {
- this.props.navigate(`/artikel/${encodeURIComponent(sku)}`);
+ handleArticleClick = (article) => {
+ const path = artikelDetailPath(article);
+ if (path) this.props.navigate(path);
};
render() {
@@ -97,7 +157,7 @@ class ArtikelKategorienPage extends Component {
articlesStatus,
error,
canGoBack,
- currentCategory,
+ breadcrumbs,
currentCategoryId,
childCategories,
articles,
@@ -128,28 +188,22 @@ class ArtikelKategorienPage extends Component {
);
}
- if (!rootCategoryId) {
+ if (rootCategoryId == null) {
return (
-
- Kategoriebaum ist leer.
+
+ rootCategoryId fehlt.
);
}
- const categoryName = categoryDisplayName(
- currentCategory?.name,
- currentCategoryId,
- rootCategoryId,
- );
const showArticlesLoading = articlesStatus === 'loading';
return (
<>
- {categoryName ? (
-
- {categoryName}
-
- ) : null}
+
{error && articlesStatus === 'failed' && (
{canGoBack && (
-
+
)}
{childCategories.map((item) => (
this.handleCategoryClick(item.id)}
+ onClick={() => this.navigateToCategoryById(item.id)}
/>
))}
{articles.map((item) => (
this.handleArticleClick(item.sku)}
+ disabled={!articleSku(item)}
+ onClick={() => this.handleArticleClick(item)}
/>
))}
- {}}
- />
- {}}
+
+
@@ -198,7 +248,6 @@ class ArtikelKategorienPage extends Component {
)}
-
>
);
}
@@ -206,12 +255,13 @@ class ArtikelKategorienPage extends Component {
const mapStateToProps = (state) => ({
companyId: selectCompanyId(state),
+ categories: selectCategoryMap(state),
rootCategoryId: state.category.rootCategoryId,
treeStatus: selectCategoryTreeStatus(state),
articlesStatus: selectCategoryArticlesStatus(state),
error: selectCategoryError(state),
currentCategoryId: state.category.currentCategoryId,
- currentCategory: selectCurrentCategory(state),
+ breadcrumbs: selectCategoryBreadcrumbs(state),
childCategories: selectChildCategories(state),
articles: selectCategoryArticles(state),
canGoBack: selectCanNavigateBack(state),
@@ -221,7 +271,6 @@ const mapDispatchToProps = {
loadCategoryTree,
loadCategoryArticles,
navigateToCategory,
- navigateBack,
};
-export default withNavigate(connect(mapStateToProps, mapDispatchToProps)(ArtikelKategorienPage));
+export default withCategoryRoute(connect(mapStateToProps, mapDispatchToProps)(ArtikelKategorienPage));
diff --git a/src/pages/artikel/sku.jsx b/src/pages/artikel/sku.jsx
index 8f1ce27..d68e54f 100644
--- a/src/pages/artikel/sku.jsx
+++ b/src/pages/artikel/sku.jsx
@@ -1,276 +1,18 @@
import { useCallback, useEffect, useState } from 'react';
-import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom';
+import { 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 { ArticleView } from '../../components/article';
import BackendError from '../../components/BackendError';
+import { requireObject } from '../../utils/apiShape';
import { backendErrorFrom, isBackendFailure } from '../../utils/backendError';
-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 { sku: skuParam } = useParams();
const navigate = useNavigate();
- const target = sku ? decodeURIComponent(sku) : '';
+ const sku = skuParam ? decodeURIComponent(skuParam) : '';
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
@@ -295,7 +37,7 @@ export default function ArtikelSkuPage() {
let cancelled = false;
async function load() {
- if (!target) {
+ if (!sku) {
setError('Keine Artikelnr. angegeben');
setData(null);
setLoading(false);
@@ -305,10 +47,11 @@ export default function ArtikelSkuPage() {
setLoading(true);
setError(null);
try {
- const res = await article.get({ target });
+ const res = await article.getBySku(sku);
+ requireObject(res, 'GET /api/article/:sku');
+ requireObject(res.article, 'article');
if (!cancelled) {
- setData(res.article ?? null);
- if (!res.article) setError('Artikel nicht gefunden');
+ setData(res.article);
}
} catch (err) {
if (!cancelled) {
@@ -324,7 +67,7 @@ export default function ArtikelSkuPage() {
return () => {
cancelled = true;
};
- }, [target, reloadKey]);
+ }, [sku, reloadKey]);
if (loading) {
return (
@@ -352,5 +95,5 @@ export default function ArtikelSkuPage() {
if (!data) return null;
- return ;
+ return ;
}
diff --git a/src/pages/artikel/withCategoryRoute.jsx b/src/pages/artikel/withCategoryRoute.jsx
new file mode 100644
index 0000000..3cf1a2a
--- /dev/null
+++ b/src/pages/artikel/withCategoryRoute.jsx
@@ -0,0 +1,19 @@
+import { useLocation, useNavigate, useParams } from 'react-router-dom';
+
+export default function withCategoryRoute(Wrapped) {
+ function CategoryRouteWrapper(props) {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const { '*': categorySlugPath = '' } = useParams();
+
+ return (
+
+ );
+ }
+ return CategoryRouteWrapper;
+}
diff --git a/src/store/categorySlice.js b/src/store/categorySlice.js
index a2fd399..6120904 100644
--- a/src/store/categorySlice.js
+++ b/src/store/categorySlice.js
@@ -1,5 +1,6 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
-import { article, category } from '../api';
+import { category, article } from '../api';
+import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import { catchBackendError, resolveBackendError } from '../utils/backendError';
import { logout } from './authSlice';
import { loadCurrentCompany } from './companySlice';
@@ -7,28 +8,35 @@ import { loadCurrentCompany } from './companySlice';
export const ROOT_CATEGORY_NAME = 'Home';
export function normalizeCategoryNode(item) {
+ requireObject(item, 'category');
+ requireDefined(item.id, 'category.id');
return {
...item,
id: item.id,
- parentId: item.parentId ?? 0,
- name: item.name?.trim() || null,
+ parentId: item.parentId,
+ name: item.name == null ? null : String(item.name).trim() || null,
};
}
-/** Backend: { tree: { tree: [...], articles: [] } } */
-export function parseCategoryTreeResponse(data, companyRootCategoryId = null) {
- const payload = data?.tree ?? data;
- const rawNodes = payload?.tree ?? payload?.categories ?? data?.categories ?? [];
- const list = Array.isArray(rawNodes) ? rawNodes : Object.values(rawNodes);
+/** Backend: { languageId, tree: [...], articles: [] } — rootCategoryId aus Company */
+export function parseCategoryTreeResponse(data, companyRootCategoryId) {
+ requireObject(data, 'category/tree');
+ const list = requireArray(data.tree, 'category/tree.tree');
+ requireArray(data.articles, 'category/tree.articles');
+
+ const rootCategoryId = data.rootCategoryId != null ? data.rootCategoryId : companyRootCategoryId;
+ requireDefined(rootCategoryId, 'rootCategoryId');
const categories = Object.fromEntries(
list.map((item) => [String(item.id), normalizeCategoryNode(item)]),
);
- const rootCategoryId = data?.rootCategoryId ?? companyRootCategoryId ?? null;
- const rootArticles = Array.isArray(payload?.articles) ? payload.articles : [];
-
- return { rootCategoryId, categories, rootArticles };
+ return {
+ rootCategoryId,
+ categories,
+ rootArticles: data.articles,
+ languageId: data.languageId,
+ };
}
export function categoryDisplayName(name, categoryId, rootCategoryId) {
@@ -38,33 +46,65 @@ export function categoryDisplayName(name, categoryId, rootCategoryId) {
return name?.trim() || '';
}
-export function normalizeCategoryMap(data) {
- return parseCategoryTreeResponse(data).categories;
+export function categorySlug(name, categoryId, rootCategoryId) {
+ if (rootCategoryId != null && String(categoryId) === String(rootCategoryId)) {
+ return ROOT_CATEGORY_NAME;
+ }
+ const trimmed = name?.trim();
+ return trimmed || null;
+}
+
+export function normalizeCategoryMap(data, companyRootCategoryId) {
+ return parseCategoryTreeResponse(data, companyRootCategoryId).categories;
}
export function getCategoryChildren(categories, parentId) {
if (!categories) return [];
const parentKey = String(parentId);
return Object.values(categories)
- .filter((item) => String(item.parentId ?? 0) === parentKey)
+ .filter((item) => item.parentId != null && String(item.parentId) === parentKey)
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.id - b.id);
}
+export function getCategoryBreadcrumbs(categories, currentCategoryId, rootCategoryId) {
+ if (!categories || currentCategoryId == null) return [];
+
+ const crumbs = [];
+ const seen = new Set();
+ let id = currentCategoryId;
+
+ while (id != null && !seen.has(String(id))) {
+ seen.add(String(id));
+ const node = categories[String(id)];
+ if (!node) break;
+
+ crumbs.unshift({
+ id: node.id,
+ name: categoryDisplayName(node.name, node.id, rootCategoryId),
+ slug: categorySlug(node.name, node.id, rootCategoryId),
+ });
+
+ const parentId = node.parentId;
+ if (parentId == null || parentId === 0) break;
+ if (String(parentId) === String(id)) break;
+ id = parentId;
+ }
+
+ return crumbs;
+}
+
export const loadCategoryTree = createAsyncThunk(
'category/loadTree',
- async (companyId, { rejectWithValue, getState }) => {
+ async (_, { rejectWithValue, getState }) => {
try {
- const data = await category.getTree({ companyId });
- const fallbackRootId = getState().company.current?.rootCategoryId ?? null;
- const { rootCategoryId, categories, rootArticles } = parseCategoryTreeResponse(
- data,
- fallbackRootId,
- );
+ const companyId = getState().company.current?.id;
+ const companyRootId = getState().company.current?.rootCategoryId;
+ requireDefined(companyRootId, 'company.rootCategoryId');
+ const data = await category.getTree();
+ const parsed = parseCategoryTreeResponse(data, companyRootId);
return {
companyId,
- rootCategoryId,
- categories,
- rootArticles,
+ ...parsed,
};
} catch (err) {
return catchBackendError(rejectWithValue)(err);
@@ -74,14 +114,51 @@ export const loadCategoryTree = createAsyncThunk(
export const loadCategoryArticles = createAsyncThunk(
'category/loadArticles',
- async ({ categoryId, offset = 0, maxCount = 200 } = {}, { rejectWithValue }) => {
+ async (categoryId, { rejectWithValue }) => {
try {
- const data = await article.byCategory({ categoryId, offset, maxCount });
- return {
- categoryId,
- recordset: data.recordset ?? [],
- length: data.length ?? data.recordset?.length ?? 0,
- };
+ const data = await category.getArticles(categoryId);
+ requireObject(data, `category/${categoryId}/articles`);
+ const articles = requireArray(data.articles, `category/${categoryId}/articles.articles`);
+ return { categoryId, articles };
+ } catch (err) {
+ return catchBackendError(rejectWithValue)(err);
+ }
+ },
+);
+
+export const createCategory = createAsyncThunk(
+ 'category/create',
+ async ({ parentId, name }, { rejectWithValue }) => {
+ try {
+ const data = await category.create({ parentId, name });
+ requireObject(data.category, 'POST /api/category.category');
+ return normalizeCategoryNode(data.category);
+ } catch (err) {
+ return catchBackendError(rejectWithValue)(err);
+ }
+ },
+);
+
+export const createArticle = createAsyncThunk(
+ 'category/createArticle',
+ async ({ parentId, name }, { rejectWithValue }) => {
+ try {
+ const data = await article.create({ parentId, name });
+ requireObject(data.article, 'POST /api/article.article');
+ requireDefined(data.article.id, 'article.id');
+ return data.article;
+ } catch (err) {
+ return catchBackendError(rejectWithValue)(err);
+ }
+ },
+);
+
+export const deleteCategory = createAsyncThunk(
+ 'category/delete',
+ async (categoryId, { rejectWithValue }) => {
+ try {
+ await category.delete(categoryId);
+ return categoryId;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
@@ -90,6 +167,7 @@ export const loadCategoryArticles = createAsyncThunk(
const initialState = {
companyId: null,
+ languageId: null,
categories: null,
rootCategoryId: null,
rootArticles: [],
@@ -107,10 +185,16 @@ const categorySlice = createSlice({
reducers: {
navigateToCategory(state, action) {
state.currentCategoryId = action.payload;
- state.articles = [];
- state.articlesTotal = 0;
- state.articlesStatus = 'idle';
state.error = null;
+ if (String(action.payload) === String(state.rootCategoryId)) {
+ state.articles = state.rootArticles;
+ state.articlesTotal = state.rootArticles.length;
+ state.articlesStatus = 'succeeded';
+ } else {
+ state.articles = [];
+ state.articlesTotal = 0;
+ state.articlesStatus = 'idle';
+ }
},
navigateBack(state) {
const current = state.categories?.[String(state.currentCategoryId)];
@@ -143,10 +227,11 @@ const categorySlice = createSlice({
.addCase(loadCategoryTree.fulfilled, (state, action) => {
state.treeStatus = 'succeeded';
state.companyId = action.payload.companyId;
+ state.languageId = action.payload.languageId;
state.categories = action.payload.categories;
state.rootCategoryId = action.payload.rootCategoryId;
state.currentCategoryId = action.payload.rootCategoryId;
- state.rootArticles = action.payload.rootArticles ?? [];
+ state.rootArticles = action.payload.rootArticles;
state.articles = state.rootArticles;
state.articlesTotal = state.rootArticles.length;
state.articlesStatus = 'succeeded';
@@ -161,8 +246,8 @@ const categorySlice = createSlice({
})
.addCase(loadCategoryArticles.fulfilled, (state, action) => {
state.articlesStatus = 'succeeded';
- state.articles = action.payload.recordset;
- state.articlesTotal = action.payload.length;
+ state.articles = action.payload.articles;
+ state.articlesTotal = action.payload.articles.length;
})
.addCase(loadCategoryArticles.rejected, (state, action) => {
state.articlesStatus = 'failed';
@@ -170,6 +255,32 @@ const categorySlice = createSlice({
state.articlesTotal = 0;
state.error = resolveBackendError(action);
})
+ .addCase(createCategory.fulfilled, (state, action) => {
+ if (!state.categories) state.categories = {};
+ state.categories[String(action.payload.id)] = action.payload;
+ })
+ .addCase(createArticle.fulfilled, (state, action) => {
+ const created = action.payload;
+ if (created == null) return;
+ if (String(created.parentId) === String(state.currentCategoryId)) {
+ state.articles = [...state.articles, created];
+ state.articlesTotal = state.articles.length;
+ state.articlesStatus = 'succeeded';
+ }
+ if (String(created.parentId) === String(state.rootCategoryId)) {
+ state.rootArticles = [...state.rootArticles, created];
+ }
+ })
+ .addCase(deleteCategory.fulfilled, (state, action) => {
+ if (!state.categories) return;
+ delete state.categories[String(action.payload)];
+ if (String(state.currentCategoryId) === String(action.payload)) {
+ state.currentCategoryId = state.rootCategoryId;
+ state.articles = state.rootArticles;
+ state.articlesTotal = state.rootArticles.length;
+ state.articlesStatus = 'succeeded';
+ }
+ })
.addCase(loadCurrentCompany.fulfilled, (state, action) => {
const nextCompanyId = action.payload?.id ?? null;
if (nextCompanyId != null && nextCompanyId !== state.companyId) {
@@ -206,6 +317,13 @@ export const selectCurrentCategory = (state) => {
export const selectChildCategories = (state) =>
getCategoryChildren(state.category.categories, state.category.currentCategoryId);
+export const selectCategoryBreadcrumbs = (state) =>
+ getCategoryBreadcrumbs(
+ state.category.categories,
+ state.category.currentCategoryId,
+ state.category.rootCategoryId,
+ );
+
export const selectCanNavigateBack = (state) => {
const { currentCategoryId, rootCategoryId, categories } = state.category;
if (currentCategoryId == null || rootCategoryId == null) return false;
diff --git a/src/store/companySlice.js b/src/store/companySlice.js
index a9578d2..41893fc 100644
--- a/src/store/companySlice.js
+++ b/src/store/companySlice.js
@@ -78,8 +78,8 @@ export const selectCurrentCompany = (state) => state.company.current;
export const selectCompanyId = (state) => state.company.current?.id ?? null;
export const selectCompanyStatus = (state) => state.company.status;
export const selectCompanyError = (state) => state.company.error;
-export const selectCompanyName = (state) => state.company.current?.name ?? '';
-export const selectCompanyIso639 = (state) => state.company.current?.iso639 ?? 'de';
+export const selectCompanyName = (state) => state.company.current?.name;
+export const selectCompanyIso639 = (state) => state.company.current?.iso639;
export const selectCompanyTaxrate = (state) => state.company.current?.taxrate ?? null;
export const selectCompanyReducedTaxrate = (state) => state.company.current?.reducedtaxrate ?? null;
export const selectCompanyRootCategoryId = (state) => state.company.current?.rootCategoryId ?? null;
diff --git a/src/utils/apiShape.js b/src/utils/apiShape.js
new file mode 100644
index 0000000..44b5473
--- /dev/null
+++ b/src/utils/apiShape.js
@@ -0,0 +1,20 @@
+export function requireObject(value, label) {
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) {
+ throw new Error(`${label}: Objekt erwartet`);
+ }
+ return value;
+}
+
+export function requireArray(value, label) {
+ if (!Array.isArray(value)) {
+ throw new Error(`${label}: Array erwartet`);
+ }
+ return value;
+}
+
+export function requireDefined(value, label) {
+ if (value == null) {
+ throw new Error(`${label}: fehlt`);
+ }
+ return value;
+}
diff --git a/src/utils/companyForm.js b/src/utils/companyForm.js
index e6076af..cb07b68 100644
--- a/src/utils/companyForm.js
+++ b/src/utils/companyForm.js
@@ -1,5 +1,6 @@
export const ISO639_OPTIONS = ['de', 'en'];
+/** Default für leeres Anlege-Formular (Browser-Sprache), nicht für API-Parsing. */
export function defaultIso639() {
return navigator.language?.toLowerCase().startsWith('de') ? 'de' : 'en';
}
@@ -10,21 +11,14 @@ export function parseTaxRate(value) {
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
}
-/** Until backend returns iso639 on company objects. */
-export function companyLanguageToIso639(company) {
- if (company?.iso639) return company.iso639;
- if (company?.languageId === 2) return 'en';
- return 'de';
-}
-
export function normalizeCompany(raw, overrides = {}) {
if (!raw) return null;
return {
id: raw.id,
- name: overrides.name ?? raw.name ?? '',
- iso639: overrides.iso639 ?? companyLanguageToIso639(raw),
- taxrate: overrides.taxrate ?? raw.taxrate ?? null,
- reducedtaxrate: overrides.reducedtaxrate ?? raw.reducedtaxrate ?? null,
- rootCategoryId: overrides.rootCategoryId ?? raw.rootCategoryId ?? null,
+ name: overrides.name !== undefined ? overrides.name : raw.name,
+ iso639: overrides.iso639 !== undefined ? overrides.iso639 : raw.iso639,
+ taxrate: overrides.taxrate !== undefined ? overrides.taxrate : raw.taxrate,
+ reducedtaxrate: overrides.reducedtaxrate !== undefined ? overrides.reducedtaxrate : raw.reducedtaxrate,
+ rootCategoryId: overrides.rootCategoryId !== undefined ? overrides.rootCategoryId : raw.rootCategoryId,
};
}
diff --git a/vite.config.js b/vite.config.js
index 2394f05..11a6fca 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -78,17 +78,22 @@ const sharedServer = {
const responseBody = responseChunks.length
? Buffer.concat(responseChunks).toString('utf8')
: '';
- const details = {
- status: proxyRes.statusCode,
- setCookie: cookies ?? '(none)',
- response: formatLoggedBody(proxyRes.statusCode, responseBody),
- };
+ const parts = [
+ '[vite-proxy]',
+ req.method,
+ req.url,
+ String(proxyRes.statusCode),
+ ];
if (hasRequestBody(req.method)) {
- details.body = req._proxyRequestBody
- ? truncate(req._proxyRequestBody)
- : '(empty)';
+ parts.push(
+ `body=${req._proxyRequestBody ? truncate(req._proxyRequestBody) : '(empty)'}`,
+ );
}
- console.log('[vite-proxy]', req.method, req.url, details);
+ if (cookies?.length) {
+ parts.push(`set-cookie=${cookies.map((c) => c.split(';')[0]).join(',')}`);
+ }
+ parts.push(formatLoggedBody(proxyRes.statusCode, responseBody));
+ console.log(parts.join(' '));
});
});
},