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 ( + + {title} + + + {error ? ( + isBackendFailure(error) ? ( + + ) : ( + + {typeof error === 'string' ? error : error.message} + + ) + ) : null} + + this.setState({ name: e.target.value })} + autoComplete="off" + /> + + + + + + + + ); + } +} + +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 && ( -
- - {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(' ')); }); }); },