This commit is contained in:
seb
2026-07-31 17:51:57 +02:00
parent 797ea59bed
commit a6a9777f31
33 changed files with 1432 additions and 422 deletions

View File

@@ -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
// ❌
<TableCell>{row.cName || '—'}</TableCell>
label={`Kategorie ${id}`}
// ✅
<TableCell>{row.cName}</TableCell>
// 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

View File

@@ -11,6 +11,7 @@ import SectionLayout from './components/SectionLayout';
const ArtikelUebersichtPage = lazy(() => import('./pages/artikel/index')); const ArtikelUebersichtPage = lazy(() => import('./pages/artikel/index'));
const ArtikelKategorienPage = lazy(() => import('./pages/artikel/kategorien')); const ArtikelKategorienPage = lazy(() => import('./pages/artikel/kategorien'));
const RedirectArtikelKategorien = lazy(() => import('./pages/artikel/RedirectArtikelKategorien'));
const ArtikelLagerPage = lazy(() => import('./pages/artikel/lager')); const ArtikelLagerPage = lazy(() => import('./pages/artikel/lager'));
const ArtikelSkuPage = lazy(() => import('./pages/artikel/sku')); const ArtikelSkuPage = lazy(() => import('./pages/artikel/sku'));
const LieferungPage = lazy(() => import('./pages/lieferung/index')); const LieferungPage = lazy(() => import('./pages/lieferung/index'));
@@ -73,9 +74,12 @@ class App extends Component {
<Route index element={<Navigate to="/artikel" replace />} /> <Route index element={<Navigate to="/artikel" replace />} />
<Route path="lieferung" element={<LieferungPage />} /> <Route path="lieferung" element={<LieferungPage />} />
<Route path="infos" element={<InfosPage />} /> <Route path="infos" element={<InfosPage />} />
<Route path="kategorien/*" element={<SectionLayout sectionPath="/artikel" />}>
<Route path="*" element={<ArtikelKategorienPage />} />
</Route>
<Route path="artikel" element={<SectionLayout sectionPath="/artikel" />}> <Route path="artikel" element={<SectionLayout sectionPath="/artikel" />}>
<Route index element={<ArtikelUebersichtPage />} /> <Route index element={<ArtikelUebersichtPage />} />
<Route path="kategorien" element={<ArtikelKategorienPage />} /> <Route path="kategorien/*" element={<RedirectArtikelKategorien />} />
<Route path="lager" element={<ArtikelLagerPage />} /> <Route path="lager" element={<ArtikelLagerPage />} />
<Route path=":sku" element={<ArtikelSkuPage />} /> <Route path=":sku" element={<ArtikelSkuPage />} />
</Route> </Route>

View File

@@ -90,33 +90,54 @@ export const article = {
return http(`/api/article/search?${params}`); return http(`/api/article/search?${params}`);
}, },
get({ target } = {}) { /** GET /api/article/:sku → { article: { id, sku, name, slug, net, taxRate, fatherArticleId, fatherSku, … } } */
const params = new URLSearchParams({ target }); getBySku(sku) {
return http(`/api/article/get?${params}`); 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 * PATCH /api/article/:sku
* Articles assigned to a category (for tile view). * Body: partial fields, e.g. { name }
* → 200 { article }
*/ */
byCategory({ categoryId, offset, maxCount } = {}) { update(sku, body) {
const params = new URLSearchParams({ categoryId: String(categoryId) }); return http(`/api/article/${encodeURIComponent(sku)}`, {
if (offset) params.set('offset', String(offset)); method: 'PATCH',
if (maxCount != null) params.set('maxCount', String(maxCount)); body,
return http(`/api/article/byCategory?${params}`); });
}, },
}; };
export const category = { export const category = {
/** /** GET /api/category/tree → { languageId, tree, articles } (company from session) */
* GET /api/category/tree?companyId= getTree() {
* Flat map of categories keyed by id; rootCategoryId resolved server-side. return http('/api/category/tree');
*/ },
getTree({ companyId } = {}) {
const params = new URLSearchParams(); /** GET /api/category/:id/articles → { articles: [...] } */
if (companyId != null) params.set('companyId', String(companyId)); getArticles(categoryId) {
const qs = params.toString(); return http(`/api/category/${categoryId}/articles`);
return http(`/api/category/tree${qs ? `?${qs}` : ''}`); },
/** 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' });
}, },
}; };

View File

@@ -60,7 +60,7 @@ class CompanySettingsDialog extends Component {
syncFormFromCompany(company) { syncFormFromCompany(company) {
this.setState({ this.setState({
name: company.name ?? '', name: company.name ?? '',
iso639: company.iso639 ?? 'de', iso639: company.iso639,
taxrate: company.taxrate != null ? String(company.taxrate) : '', taxrate: company.taxrate != null ? String(company.taxrate) : '',
reducedtaxrate: company.reducedtaxrate != null ? String(company.reducedtaxrate) : '', reducedtaxrate: company.reducedtaxrate != null ? String(company.reducedtaxrate) : '',
}); });

View File

@@ -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 (
<Dialog open={open} onClose={this.handleClose} maxWidth="xs" fullWidth>
<DialogTitle>{title}</DialogTitle>
<Box component="form" onSubmit={this.handleSubmit} noValidate>
<DialogContent>
{error ? (
isBackendFailure(error) ? (
<BackendError error={error} sx={{ mb: 2 }} />
) : (
<Alert severity="error" sx={{ mb: 2 }}>
{typeof error === 'string' ? error : error.message}
</Alert>
)
) : null}
<TextField
fullWidth
required
autoFocus
label="Name"
value={name}
disabled={busy}
onChange={(e) => this.setState({ name: e.target.value })}
autoComplete="off"
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button type="button" onClick={this.handleClose} disabled={busy}>
Abbrechen
</Button>
<Button type="submit" variant="contained" disabled={busy || !canSave}>
{submitLabel}
</Button>
</DialogActions>
</Box>
</Dialog>
);
}
}
export default NameDialog;

View File

@@ -4,21 +4,24 @@ import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { artikelQueryPath } from '../pages/artikel/artikelQuery'; import { artikelQueryPath } from '../pages/artikel/artikelQuery';
import { defaultCategoryPath, isKategorienPath } from '../pages/artikel/categoryRoute';
const SUB_NAV_WIDTH = 80; const SUB_NAV_WIDTH = 80;
function isSubNavActive(pathname, basePath, segment, knownSegments) { function isSubNavActive(pathname, basePath, segment, knownSegments) {
if (!segment) { if (!segment) {
if (pathname === basePath || pathname === `${basePath}/`) return true; if (pathname === basePath || pathname === `${basePath}/`) return true;
// /artikel/:sku detail — highlight overview, not kategorien/lager
const rest = pathname.slice(basePath.length).replace(/^\//, ''); const rest = pathname.slice(basePath.length).replace(/^\//, '');
return Boolean(rest) && !rest.includes('/') && !knownSegments.has(rest); return Boolean(rest) && !rest.includes('/') && !knownSegments.has(rest);
} }
if (segment === 'kategorien') {
return isKategorienPath(pathname);
}
const subPath = `${basePath}/${segment}`; const subPath = `${basePath}/${segment}`;
return pathname === subPath || pathname === `${subPath}/`; 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 hasSubNav = subNav?.length > 0;
const navigate = useNavigate(); const navigate = useNavigate();
const { pathname } = useLocation(); const { pathname } = useLocation();
@@ -55,6 +58,10 @@ export default function PageLayout({ title, subNav, basePath, children }) {
navigate(artikelQueryPath()); navigate(artikelQueryPath());
return; return;
} }
if (segment === 'kategorien') {
navigate(defaultCategoryPath());
return;
}
navigate(segment ? `${basePath}/${segment}` : basePath); navigate(segment ? `${basePath}/${segment}` : basePath);
}} }}
sx={(theme) => ({ sx={(theme) => ({
@@ -77,9 +84,11 @@ export default function PageLayout({ title, subNav, basePath, children }) {
</Box> </Box>
<Box sx={{ flexGrow: 1, minWidth: 0 }}> <Box sx={{ flexGrow: 1, minWidth: 0 }}>
{showTitle ? (
<Typography variant="h5" sx={{ fontWeight: 700, mb: 2 }}> <Typography variant="h5" sx={{ fontWeight: 700, mb: 2 }}>
{title} {title}
</Typography> </Typography>
) : null}
{children} {children}
</Box> </Box>
</Box> </Box>

View File

@@ -1,27 +1,34 @@
import { Outlet, useLocation } from 'react-router-dom'; import { Outlet, useLocation } from 'react-router-dom';
import PageLayout from './PageLayout'; import PageLayout from './PageLayout';
import { NAV_ITEMS, SUB_NAV } from '../nav'; import { NAV_ITEMS, SUB_NAV } from '../nav';
import { isKategorienPath } from '../pages/artikel/categoryRoute';
export default function SectionLayout({ sectionPath }) { export default function SectionLayout({ sectionPath }) {
const { pathname } = useLocation(); const { pathname } = useLocation();
const title = NAV_ITEMS.find((item) => item.path === sectionPath)?.label ?? ''; const title = NAV_ITEMS.find((item) => item.path === sectionPath)?.label ?? '';
const subNav = SUB_NAV[sectionPath]; const subNav = SUB_NAV[sectionPath];
const activeSub = subNav?.find((item) => { const activeSub = subNav?.find((item) => {
if (item.segment === 'kategorien') {
return isKategorienPath(pathname);
}
const subPath = item.segment ? `${sectionPath}/${item.segment}` : sectionPath; const subPath = item.segment ? `${sectionPath}/${item.segment}` : sectionPath;
return pathname === subPath || pathname === `${subPath}/`; return pathname === subPath || pathname === `${subPath}/`;
}); });
const knownSegments = new Set((subNav ?? []).map((item) => item.segment).filter(Boolean)); const knownSegments = new Set((subNav ?? []).map((item) => item.segment).filter(Boolean));
const rest = pathname.slice(sectionPath.length).replace(/^\//, '').split('/')[0]; const rest = pathname.slice(sectionPath.length).replace(/^\//, '').split('/')[0];
const detailSku = const isArticleDetail =
sectionPath === '/artikel' && rest && !knownSegments.has(rest) sectionPath === '/artikel' &&
? decodeURIComponent(rest) !isKategorienPath(pathname) &&
: null; rest &&
!knownSegments.has(rest);
const pageTitle = detailSku ?? (activeSub && subNav ? activeSub.label : title); const pageTitle = isArticleDetail
? title
: (activeSub?.label ?? title);
return ( return (
<PageLayout title={pageTitle} subNav={subNav} basePath={sectionPath}> <PageLayout title={pageTitle} showTitle={!isArticleDetail} subNav={subNav} basePath={sectionPath}>
<Outlet /> <Outlet />
</PageLayout> </PageLayout>
); );

View File

@@ -0,0 +1,13 @@
import NameDialog from '../NameDialog';
export default function ArticleNameDialog({ open, onClose, onSubmit }) {
return (
<NameDialog
open={open}
title="Neuer Artikel"
submitLabel="Anlegen"
onClose={onClose}
onSubmit={onSubmit}
/>
);
}

View File

@@ -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 (
<Box sx={{ mb: 1 }}>
{label ? (
<Typography variant="caption" color="text.secondary" display="block">
{label}
</Typography>
) : null}
<Typography variant="body2" component="div">
{children}
</Typography>
</Box>
);
}
function ArticlePhoto() {
return (
<Box
sx={{
width: 200,
height: 200,
maxWidth: '100%',
bgcolor: 'action.hover',
borderRadius: 1,
}}
/>
);
}
const headerTileSx = {
p: 1.25,
minHeight: 0,
};
function CountLine({ label, count }) {
if (count == null) return null;
return (
<Typography variant="body2">
{label}: {count}
</Typography>
);
}
function RelationLine({ label, active, children }) {
return (
<Typography variant="body2" sx={{ mb: 0.5 }}>
{label}:{' '}
{active ? (
children ?? 'ja'
) : (
<Typography component="span" variant="body2" color="text.secondary">
nein
</Typography>
)}
</Typography>
);
}
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 (
<PanelTileGrid>
<EditablePanelTile
wide={3}
minHeight={0}
sx={headerTileSx}
value={{ name: articleData.name ?? '' }}
onSave={handleSaveName}
isDirty={nameDraftDirty}
canSave={nameDraftValid}
renderDisplay={({ name }) => (
<Box
sx={{
display: 'flex',
alignItems: 'center',
minHeight: { xs: 0, sm: 200 },
pr: 4,
}}
>
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.25 }}>
{name}
</Typography>
</Box>
)}
renderEdit={(draft, onChange, inputRef) => (
<Box
sx={{
display: 'flex',
alignItems: 'center',
minHeight: { xs: 0, sm: 200 },
}}
>
<TextField
inputRef={inputRef}
fullWidth
variant="standard"
value={draft.name}
onChange={(event) => onChange({ name: event.target.value })}
sx={{
'& .MuiInput-input': {
fontSize: '1.25rem',
fontWeight: 700,
lineHeight: 1.25,
},
}}
/>
</Box>
)}
/>
<PanelTile wide={1} minHeight={0} sx={headerTileSx}>
<ArticlePhoto />
</PanelTile>
<PanelTile wide={1} title="Bestand">
<PanelValue>
{articleData.stock != null ? formatStock(articleData.stock) : null}
</PanelValue>
</PanelTile>
<PanelTile wide={1} title="Marke">
<PanelValue>{articleData.brand}</PanelValue>
</PanelTile>
<PanelTile wide={1} title="SKU / EAN">
<PanelValue label="SKU">{articleData.sku}</PanelValue>
<PanelValue label="EAN">{articleData.ean}</PanelValue>
</PanelTile>
<PanelTile wide={1} title="Preis">
<PanelValue label="Netto">
{articleData.net != null ? formatPrice(articleData.net) : null}
</PanelValue>
<PanelValue label="MwSt.">
{articleData.taxRate != null ? `${articleData.taxRate} %` : null}
</PanelValue>
<PanelValue label="Brutto">
{brutto != null ? formatPrice(brutto) : null}
</PanelValue>
</PanelTile>
<PanelTile wide={3} title="SEO & Beschreibung" minHeight={200}>
<PanelValue label="Slug">{articleData.slug}</PanelValue>
<PanelValue label="SEO-Titel">{articleData.seoTitle}</PanelValue>
<PanelValue label="SEO-Beschreibung">{articleData.seoDescription}</PanelValue>
<PanelValue label="Beschreibung">{articleData.description}</PanelValue>
</PanelTile>
<PanelTile wide={1} title="Listen" minHeight={200}>
<RelationLine
label="Vaterartikel"
active={articleData.childCount > 0}
>
{articleData.childCount}
</RelationLine>
<RelationLine
label="Kindartikel"
active={articleData.fatherArticleId != null}
>
{articleData.fatherSku ? (
<Link
component={RouterLink}
to={`/artikel/${encodeURIComponent(articleData.fatherSku)}`}
>
{articleData.fatherSku}
</Link>
) : null}
</RelationLine>
<CountLine label="Optionen" count={articleData.optionsCount} />
<CountLine label="Stücklisten" count={articleData.articleListCount} />
<CountLine label="Varianten" count={articleData.childCount} />
</PanelTile>
</PanelTileGrid>
);
}

View File

@@ -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 (
<>
<ActionTile
label="Neuer Artikel"
disabled={disabled || parentId == null}
onClick={this.handleOpen}
/>
<ArticleNameDialog
open={this.state.open}
onClose={this.handleClose}
onSubmit={this.handleSubmit}
/>
</>
);
}
}
const mapDispatchToProps = {
createArticle: createArticleThunk,
};
export default connect(null, mapDispatchToProps)(NewArticleTile);

View File

@@ -0,0 +1,3 @@
export { default as ArticleView } from './ArticleView';
export { default as ArticleNameDialog } from './ArticleNameDialog';
export { default as NewArticleTile } from './NewArticleTile';

View File

@@ -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 (
<Breadcrumbs sx={{ mb: 2 }} aria-label="Kategoriepfad">
{visible.map((crumb, index) => (
<Link
key={crumb.id}
component="button"
type="button"
variant="body2"
underline="always"
color="inherit"
aria-current={index === visible.length - 1 ? 'page' : undefined}
onClick={() => this.handleNavigate(crumb.id)}
sx={{
fontWeight: 600,
lineHeight: 1.43,
p: 0,
minWidth: 0,
}}
>
{crumb.name}
</Link>
))}
</Breadcrumbs>
);
}
}
export default CategoryBreadcrumbs;

View File

@@ -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 (
<NameDialog
open={open}
title={TITLES[mode]}
submitLabel={SUBMIT_LABELS[mode]}
initialName={initialName}
onClose={onClose}
onSubmit={onSubmit}
/>
);
}

View File

@@ -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 (
<CategoryNameDialog
open={open}
mode="edit"
initialName={initialName}
onClose={onClose}
onSubmit={onSubmit}
/>
);
}
}
export default EditCategoryDialog;

View File

@@ -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 (
<>
<ActionTile
label="Neue Kategorie"
disabled={disabled || parentId == null}
onClick={this.handleOpen}
/>
<CategoryNameDialog
open={this.state.open}
mode="create"
onClose={this.handleClose}
onSubmit={this.handleSubmit}
/>
</>
);
}
}
const mapDispatchToProps = {
createCategory: createCategoryThunk,
};
export default connect(null, mapDispatchToProps)(NewCategoryTile);

View File

@@ -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';

View File

@@ -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 (
<Typography
variant="overline"
color="text.secondary"
sx={{ display: 'block', mb: 1, pr: editing ? 7 : 4 }}
>
{title}
</Typography>
);
}
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 (
<IconButton
size="small"
onClick={this.handleStartEdit}
aria-label="Bearbeiten"
sx={{ ...actionSx, right: 4 }}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
);
}
return (
<>
<IconButton
size="small"
onClick={this.handleCancel}
disabled={saving}
aria-label="Abbrechen"
sx={{ ...actionSx, right: 40 }}
>
<CloseOutlinedIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={this.handleSave}
disabled={!this.canSave()}
aria-label="Speichern"
color={this.canSave() ? 'primary' : 'default'}
sx={{ ...actionSx, right: 4 }}
>
{saving ? (
<CircularProgress size={18} color="inherit" />
) : (
<SaveOutlinedIcon fontSize="small" />
)}
</IconButton>
</>
);
}
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 (
<Paper
variant="outlined"
sx={panelTilePaperSx({ wide, sx, minHeight, pending })}
>
{this.renderActions()}
{this.renderTitle()}
<Box sx={{ flex: 1, minWidth: 0, pr: editing ? 1 : 0 }}>
{editing && draft != null
? renderEdit(draft, this.handleDraftChange, this.inputRef)
: renderDisplay(savedValue)}
</Box>
{error ? (
<Alert severity="error" sx={{ mt: 1, py: 0 }}>
{error}
</Alert>
) : null}
</Paper>
);
}
}

View File

@@ -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 (
<Typography
variant="overline"
color="text.secondary"
sx={{ display: 'block', mb: 1 }}
>
{title}
</Typography>
);
}
render() {
const { wide = 1, children, sx, minHeight = 120 } = this.props;
return (
<Paper
variant="outlined"
sx={panelTilePaperSx({ wide, sx, minHeight })}
>
{this.renderTitle()}
<Box sx={{ flex: 1, minWidth: 0 }}>{children}</Box>
</Paper>
);
}
}
export function PanelTileGrid({ children, sx }) {
return (
<Box
sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(2, 1fr)',
md: `repeat(${GRID_COLUMNS}, 1fr)`,
},
gap: 2,
...sx,
}}
>
{children}
</Box>
);
}
export { GRID_COLUMNS };

View File

@@ -0,0 +1,3 @@
export { PanelTile, PanelTileGrid, GRID_COLUMNS } from './PanelTile';
export { EditablePanelTile } from './EditablePanelTile';
export { PENDING_EDIT_BORDER } from './panelTileLayout';

View File

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

View File

@@ -2,6 +2,7 @@ import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { Tile } from './Tile'; import { Tile } from './Tile';
import { formatPrice } from '../../utils/price'; import { formatPrice } from '../../utils/price';
import { articleSku } from '../../pages/artikel/articleRoute';
export class ArticleTile extends Tile { export class ArticleTile extends Tile {
renderIcon() { renderIcon() {
@@ -11,14 +12,17 @@ export class ArticleTile extends Tile {
renderSecondary() { renderSecondary() {
const { article } = this.props; const { article } = this.props;
if (!article) return null; if (!article) return null;
const sku = articleSku(article);
return ( return (
<> <>
{sku ? (
<Typography variant="caption" color="text.secondary" noWrap sx={{ maxWidth: '100%' }}> <Typography variant="caption" color="text.secondary" noWrap sx={{ maxWidth: '100%' }}>
{article.sku} {sku}
</Typography> </Typography>
{article.price != null && ( ) : null}
{article.net != null && (
<Typography variant="caption" fontWeight={600}> <Typography variant="caption" fontWeight={600}>
{formatPrice(article.price)} {formatPrice(article.net)}
</Typography> </Typography>
)} )}
</> </>

View File

@@ -1,5 +1,5 @@
import { Component } from 'react'; import { Component } from 'react';
import Paper from '@mui/material/Paper'; import ButtonBase from '@mui/material/ButtonBase';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
export const TILE_SIZE = 140; export const TILE_SIZE = 140;
@@ -55,8 +55,7 @@ export class Tile extends Component {
render() { render() {
const { sx, disabled } = this.props; const { sx, disabled } = this.props;
return ( return (
<Paper <ButtonBase
variant="outlined"
component="button" component="button"
type="button" type="button"
disabled={disabled} disabled={disabled}
@@ -66,6 +65,8 @@ export class Tile extends Component {
border: '1px solid', border: '1px solid',
borderColor: 'divider', borderColor: 'divider',
bgcolor: 'background.paper', bgcolor: 'background.paper',
borderRadius: 1,
overflow: 'hidden',
...(disabled ...(disabled
? { opacity: 0.5, pointerEvents: 'none' } ? { opacity: 0.5, pointerEvents: 'none' }
: {}), : {}),
@@ -75,7 +76,7 @@ export class Tile extends Component {
{this.renderIcon()} {this.renderIcon()}
{this.renderLabel()} {this.renderLabel()}
{this.renderSecondary()} {this.renderSecondary()}
</Paper> </ButtonBase>
); );
} }
} }

View File

@@ -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 <Navigate to={`${KATEGORIEN_BASE}${suffix}`} replace />;
}

View File

@@ -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)}`;
}

View File

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

View File

@@ -4,28 +4,36 @@ import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import BackendError from '../../components/BackendError'; import BackendError from '../../components/BackendError';
import { NewCategoryTile, CategoryBreadcrumbs } from '../../components/category';
import { NewArticleTile } from '../../components/article';
import { import {
ActionTile,
ArticleTile, ArticleTile,
BackTile, BackTile,
CategoryTile, CategoryTile,
TILE_SIZE, TILE_SIZE,
} from '../../components/tiles'; } from '../../components/tiles';
import withNavigate from '../../components/withNavigate'; import withCategoryRoute from './withCategoryRoute';
import { artikelDetailPath, articleSku } from './articleRoute';
import { selectCompanyId } from '../../store/companySlice'; import { selectCompanyId } from '../../store/companySlice';
import {
buildCategoryPath,
defaultCategoryPath,
resolveCategoryIdFromSlugPath,
ROOT_CATEGORY_NAME,
} from './categoryRoute';
import { import {
categoryDisplayName, categoryDisplayName,
loadCategoryArticles, loadCategoryArticles,
loadCategoryTree, loadCategoryTree,
navigateBack,
navigateToCategory, navigateToCategory,
selectCanNavigateBack, selectCanNavigateBack,
selectCategoryArticles, selectCategoryArticles,
selectCategoryArticlesStatus, selectCategoryArticlesStatus,
selectCategoryBreadcrumbs,
selectCategoryMap,
selectCategoryError, selectCategoryError,
selectCategoryTreeStatus, selectCategoryTreeStatus,
selectChildCategories, selectChildCategories,
selectCurrentCategory,
} from '../../store/categorySlice'; } from '../../store/categorySlice';
const gridSx = { const gridSx = {
@@ -37,6 +45,7 @@ const gridSx = {
class ArtikelKategorienPage extends Component { class ArtikelKategorienPage extends Component {
componentDidMount() { componentDidMount() {
this.ensureTreeLoaded(); this.ensureTreeLoaded();
this.syncCategoryFromUrl();
this.ensureArticlesLoaded(); this.ensureArticlesLoaded();
} }
@@ -44,13 +53,23 @@ class ArtikelKategorienPage extends Component {
if (this.props.companyId !== prevProps.companyId) { if (this.props.companyId !== prevProps.companyId) {
this.ensureTreeLoaded(); this.ensureTreeLoaded();
} }
if (this.props.currentCategoryId !== prevProps.currentCategoryId) {
this.ensureArticlesLoaded();
}
if ( if (
this.props.treeStatus === 'succeeded' && this.props.treeStatus === 'succeeded' &&
prevProps.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(); this.ensureArticlesLoaded();
} }
} }
@@ -58,35 +77,76 @@ class ArtikelKategorienPage extends Component {
ensureTreeLoaded = () => { ensureTreeLoaded = () => {
const { companyId, treeStatus } = this.props; const { companyId, treeStatus } = this.props;
if (!companyId || treeStatus !== 'idle') return; if (!companyId || treeStatus !== 'idle') return;
this.props.loadCategoryTree(companyId); this.props.loadCategoryTree();
}; };
ensureArticlesLoaded = () => { ensureArticlesLoaded = () => {
const { currentCategoryId, articlesStatus, rootCategoryId } = this.props; const { currentCategoryId, articlesStatus, rootCategoryId, treeStatus } = this.props;
if (!currentCategoryId || articlesStatus !== 'idle') return; if (treeStatus !== 'succeeded' || !currentCategoryId || articlesStatus !== 'idle') return;
// Root articles come from tree response; only fetch for subcategories.
if (String(currentCategoryId) === String(rootCategoryId)) 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 = () => { handleRetryTree = () => {
const { companyId } = this.props; if (this.props.companyId) this.props.loadCategoryTree();
if (companyId) this.props.loadCategoryTree(companyId);
}; };
handleRetryArticles = () => { handleRetryArticles = () => {
const { currentCategoryId } = this.props; const { currentCategoryId } = this.props;
if (currentCategoryId) { if (currentCategoryId) {
this.props.loadCategoryArticles({ categoryId: currentCategoryId }); this.props.loadCategoryArticles(currentCategoryId);
} }
}; };
handleCategoryClick = (categoryId) => { handleNavigateBack = () => {
this.props.navigateToCategory(categoryId); const { breadcrumbs } = this.props;
if (breadcrumbs.length < 2) return;
this.navigateToCategoryById(breadcrumbs[breadcrumbs.length - 2].id);
}; };
handleArticleClick = (sku) => { handleArticleClick = (article) => {
this.props.navigate(`/artikel/${encodeURIComponent(sku)}`); const path = artikelDetailPath(article);
if (path) this.props.navigate(path);
}; };
render() { render() {
@@ -97,7 +157,7 @@ class ArtikelKategorienPage extends Component {
articlesStatus, articlesStatus,
error, error,
canGoBack, canGoBack,
currentCategory, breadcrumbs,
currentCategoryId, currentCategoryId,
childCategories, childCategories,
articles, articles,
@@ -128,28 +188,22 @@ class ArtikelKategorienPage extends Component {
); );
} }
if (!rootCategoryId) { if (rootCategoryId == null) {
return ( return (
<Typography color="text.secondary"> <Typography color="error">
Kategoriebaum ist leer. rootCategoryId fehlt.
</Typography> </Typography>
); );
} }
const categoryName = categoryDisplayName(
currentCategory?.name,
currentCategoryId,
rootCategoryId,
);
const showArticlesLoading = articlesStatus === 'loading'; const showArticlesLoading = articlesStatus === 'loading';
return ( return (
<> <>
{categoryName ? ( <CategoryBreadcrumbs
<Typography variant="subtitle1" sx={{ mb: 2, fontWeight: 600 }}> crumbs={breadcrumbs}
{categoryName} onNavigate={this.navigateToCategoryById}
</Typography> />
) : null}
{error && articlesStatus === 'failed' && ( {error && articlesStatus === 'failed' && (
<BackendError <BackendError
@@ -161,35 +215,31 @@ class ArtikelKategorienPage extends Component {
<Box sx={gridSx}> <Box sx={gridSx}>
{canGoBack && ( {canGoBack && (
<BackTile onClick={this.props.navigateBack} /> <BackTile onClick={this.handleNavigateBack} />
)} )}
{childCategories.map((item) => ( {childCategories.map((item) => (
<CategoryTile <CategoryTile
key={item.id} key={item.id}
label={categoryDisplayName(item.name, item.id, rootCategoryId)} label={categoryDisplayName(item.name, item.id, rootCategoryId)}
onClick={() => this.handleCategoryClick(item.id)} onClick={() => this.navigateToCategoryById(item.id)}
/> />
))} ))}
{articles.map((item) => ( {articles.map((item) => (
<ArticleTile <ArticleTile
key={item.id ?? item.sku} key={item.id}
label={item.name?.trim() || undefined} label={item.name != null ? item.name.trim() : undefined}
article={item} article={item}
onClick={() => this.handleArticleClick(item.sku)} disabled={!articleSku(item)}
onClick={() => this.handleArticleClick(item)}
/> />
))} ))}
<ActionTile <NewCategoryTile parentId={currentCategoryId} />
label="Neue Kategorie" <NewArticleTile
disabled parentId={currentCategoryId}
onClick={() => {}} onOpenArticle={this.handleArticleClick}
/>
<ActionTile
label="Neuer Artikel"
disabled
onClick={() => {}}
/> />
</Box> </Box>
@@ -198,7 +248,6 @@ class ArtikelKategorienPage extends Component {
<CircularProgress size={28} /> <CircularProgress size={28} />
</Box> </Box>
)} )}
</> </>
); );
} }
@@ -206,12 +255,13 @@ class ArtikelKategorienPage extends Component {
const mapStateToProps = (state) => ({ const mapStateToProps = (state) => ({
companyId: selectCompanyId(state), companyId: selectCompanyId(state),
categories: selectCategoryMap(state),
rootCategoryId: state.category.rootCategoryId, rootCategoryId: state.category.rootCategoryId,
treeStatus: selectCategoryTreeStatus(state), treeStatus: selectCategoryTreeStatus(state),
articlesStatus: selectCategoryArticlesStatus(state), articlesStatus: selectCategoryArticlesStatus(state),
error: selectCategoryError(state), error: selectCategoryError(state),
currentCategoryId: state.category.currentCategoryId, currentCategoryId: state.category.currentCategoryId,
currentCategory: selectCurrentCategory(state), breadcrumbs: selectCategoryBreadcrumbs(state),
childCategories: selectChildCategories(state), childCategories: selectChildCategories(state),
articles: selectCategoryArticles(state), articles: selectCategoryArticles(state),
canGoBack: selectCanNavigateBack(state), canGoBack: selectCanNavigateBack(state),
@@ -221,7 +271,6 @@ const mapDispatchToProps = {
loadCategoryTree, loadCategoryTree,
loadCategoryArticles, loadCategoryArticles,
navigateToCategory, navigateToCategory,
navigateBack,
}; };
export default withNavigate(connect(mapStateToProps, mapDispatchToProps)(ArtikelKategorienPage)); export default withCategoryRoute(connect(mapStateToProps, mapDispatchToProps)(ArtikelKategorienPage));

View File

@@ -1,276 +1,18 @@
import { useCallback, useEffect, useState } from 'react'; 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 Alert from '@mui/material/Alert';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress'; 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 { article } from '../../api';
import { ArticleView } from '../../components/article';
import BackendError from '../../components/BackendError'; import BackendError from '../../components/BackendError';
import { requireObject } from '../../utils/apiShape';
import { backendErrorFrom, isBackendFailure } from '../../utils/backendError'; 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 (
<Box sx={{ display: 'grid', gridTemplateColumns: '160px 1fr', gap: 1, py: 0.75 }}>
<Typography variant="body2" color="text.secondary">
{label}
</Typography>
<Typography variant="body2" component="div">
{children}
</Typography>
</Box>
);
}
function Section({ title, children }) {
if (!children) return null;
return (
<Box sx={{ mt: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1 }}>
{title}
</Typography>
{children}
</Box>
);
}
export function ArticleView({ article: a }) {
const brutto =
a.fVKNEtto != null ? getBrutto(a.fVKNEtto, a.kSteuerklasse) : null;
return (
<Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 1, mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mr: 1 }}>
{a.cName}
</Typography>
<Chip
size="small"
label={a.cAktiv === 'Y' ? 'Aktiv' : 'Inaktiv'}
color={a.cAktiv === 'Y' ? 'success' : 'default'}
variant="outlined"
/>
</Box>
<Paper variant="outlined" sx={{ p: 2 }}>
<DetailRow label="Artikelnr.">{a.cArtNr}</DetailRow>
<DetailRow label="Barcode">{a.cBarcode}</DetailRow>
<DetailRow label="Hersteller">{a.cHersteller}</DetailRow>
<DetailRow label="VK Netto">
{a.fVKNEtto != null ? formatPrice(a.fVKNEtto) : null}
</DetailRow>
<DetailRow label="VK Brutto">
{brutto != null ? formatPrice(brutto) : null}
</DetailRow>
<DetailRow label="UVP">{a.fUVP != null ? formatPrice(a.fUVP) : null}</DetailRow>
<DetailRow label="Bestand">{formatStock(a.fBestand)}</DetailRow>
<DetailRow label="Garage">{a.garage != null ? formatStock(a.garage) : null}</DetailRow>
<DetailRow label="Keller">{a.keller != null ? formatStock(a.keller) : null}</DetailRow>
<DetailRow label="Versandklasse">{a.tVersandklassecName}</DetailRow>
<DetailRow label="Gewicht">
{a.fGewicht != null
? `${new Intl.NumberFormat('de-DE').format(a.fGewicht)} kg`
: null}
</DetailRow>
{a.vater?.cArtNr && (
<DetailRow label="Vaterartikel">
<Link component={RouterLink} to={`/artikel/${encodeURIComponent(a.vater.cArtNr)}`}>
{a.vater.cArtNr}
</Link>
</DetailRow>
)}
{(a.cEigenschaftName || a.cEigenschaftWertName) && (
<DetailRow label="Variante">
{[a.cEigenschaftName, a.cEigenschaftWertName].filter(Boolean).join(': ')}
</DetailRow>
)}
</Paper>
{a.cKurzBeschreibung && (
<Section title="Kurzbeschreibung">
<Typography variant="body2">{a.cKurzBeschreibung}</Typography>
</Section>
)}
{a.cBeschreibung && (
<Section title="Beschreibung">
<Box
sx={{ typography: 'body2', '& p': { mt: 0 } }}
dangerouslySetInnerHTML={{ __html: a.cBeschreibung }}
/>
</Section>
)}
{a.EANs?.length > 0 && (
<Section title="EANs">
<Typography variant="body2">
{a.EANs.map((row) => row.cEAN).filter(Boolean).join(', ')}
</Typography>
</Section>
)}
{a.KATs?.length > 0 && (
<Section title="Kategorien">
<Typography variant="body2">
{a.KATs.map((row) => row.kKategorie).join(', ')}
</Typography>
</Section>
)}
{a.staffel?.length > 0 && (
<Section title="Staffelpreise">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Menge</TableCell>
<TableCell align="right">VK Brutto</TableCell>
</TableRow>
</TableHead>
<TableBody>
{a.staffel.map((row, i) => (
<TableRow key={i}>
<TableCell>{row.menge}</TableCell>
<TableCell align="right">{row.fVKBrutto}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Section>
)}
{a.lief?.length > 0 && (
<Section title="Lieferanten">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Firma</TableCell>
<TableCell>Lief.-Artikelnr.</TableCell>
<TableCell align="right">EK Netto</TableCell>
<TableCell align="right">Lager</TableCell>
</TableRow>
</TableHead>
<TableBody>
{a.lief.map((row, i) => (
<TableRow key={i}>
<TableCell>
{row.cFirma}
{row.nStandard ? ' ★' : ''}
</TableCell>
<TableCell>{row.cLiefArtNr || '—'}</TableCell>
<TableCell align="right">
{row.fEKNetto != null ? formatPrice(row.fEKNetto) : '—'}
</TableCell>
<TableCell align="right">{formatStock(row.fLagerbestand)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Section>
)}
{a.merkmale?.length > 0 && (
<Section title="Merkmale">
<Paper variant="outlined" sx={{ p: 2 }}>
{a.merkmale.map((row, i) => (
<DetailRow key={i} label={row.cName}>
{row.cWert}
</DetailRow>
))}
</Paper>
</Section>
)}
{a.children?.length > 0 && (
<Section title="Varianten">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Artikelnr.</TableCell>
<TableCell>Name</TableCell>
</TableRow>
</TableHead>
<TableBody>
{a.children.map((row) => (
<TableRow key={row.cArtNr || row.kArtikel} hover>
<TableCell>
{row.cArtNr ? (
<Link
component={RouterLink}
to={`/artikel/${encodeURIComponent(row.cArtNr)}`}
>
{row.cArtNr}
</Link>
) : (
'—'
)}
</TableCell>
<TableCell>{row.cName || '—'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Section>
)}
{a.liste?.length > 0 && (
<Section title="Stückliste">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Artikelnr.</TableCell>
<TableCell>Name</TableCell>
<TableCell align="right">Menge</TableCell>
</TableRow>
</TableHead>
<TableBody>
{a.liste.map((row, i) => (
<TableRow key={i}>
<TableCell>
{row.cArtNr ? (
<Link
component={RouterLink}
to={`/artikel/${encodeURIComponent(row.cArtNr)}`}
>
{row.cArtNr}
</Link>
) : (
'—'
)}
</TableCell>
<TableCell>{row.cName || '—'}</TableCell>
<TableCell align="right">{formatStock(row.fAnzahl ?? row.menge)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Section>
)}
{a.cAnmerkung && (
<Section title="Interne Anmerkung">
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{a.cAnmerkung}
</Typography>
</Section>
)}
</Box>
);
}
export default function ArtikelSkuPage() { export default function ArtikelSkuPage() {
const { sku } = useParams(); const { sku: skuParam } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const target = sku ? decodeURIComponent(sku) : ''; const sku = skuParam ? decodeURIComponent(skuParam) : '';
const [data, setData] = useState(null); const [data, setData] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -295,7 +37,7 @@ export default function ArtikelSkuPage() {
let cancelled = false; let cancelled = false;
async function load() { async function load() {
if (!target) { if (!sku) {
setError('Keine Artikelnr. angegeben'); setError('Keine Artikelnr. angegeben');
setData(null); setData(null);
setLoading(false); setLoading(false);
@@ -305,10 +47,11 @@ export default function ArtikelSkuPage() {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { 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) { if (!cancelled) {
setData(res.article ?? null); setData(res.article);
if (!res.article) setError('Artikel nicht gefunden');
} }
} catch (err) { } catch (err) {
if (!cancelled) { if (!cancelled) {
@@ -324,7 +67,7 @@ export default function ArtikelSkuPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [target, reloadKey]); }, [sku, reloadKey]);
if (loading) { if (loading) {
return ( return (
@@ -352,5 +95,5 @@ export default function ArtikelSkuPage() {
if (!data) return null; if (!data) return null;
return <ArticleView article={data} />; return <ArticleView article={data} onArticleUpdated={setData} />;
} }

View File

@@ -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 (
<Wrapped
{...props}
navigate={navigate}
location={location}
categorySlugPath={categorySlugPath}
/>
);
}
return CategoryRouteWrapper;
}

View File

@@ -1,5 +1,6 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; 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 { catchBackendError, resolveBackendError } from '../utils/backendError';
import { logout } from './authSlice'; import { logout } from './authSlice';
import { loadCurrentCompany } from './companySlice'; import { loadCurrentCompany } from './companySlice';
@@ -7,28 +8,35 @@ import { loadCurrentCompany } from './companySlice';
export const ROOT_CATEGORY_NAME = 'Home'; export const ROOT_CATEGORY_NAME = 'Home';
export function normalizeCategoryNode(item) { export function normalizeCategoryNode(item) {
requireObject(item, 'category');
requireDefined(item.id, 'category.id');
return { return {
...item, ...item,
id: item.id, id: item.id,
parentId: item.parentId ?? 0, parentId: item.parentId,
name: item.name?.trim() || null, name: item.name == null ? null : String(item.name).trim() || null,
}; };
} }
/** Backend: { tree: { tree: [...], articles: [] } } */ /** Backend: { languageId, tree: [...], articles: [] } — rootCategoryId aus Company */
export function parseCategoryTreeResponse(data, companyRootCategoryId = null) { export function parseCategoryTreeResponse(data, companyRootCategoryId) {
const payload = data?.tree ?? data; requireObject(data, 'category/tree');
const rawNodes = payload?.tree ?? payload?.categories ?? data?.categories ?? []; const list = requireArray(data.tree, 'category/tree.tree');
const list = Array.isArray(rawNodes) ? rawNodes : Object.values(rawNodes); requireArray(data.articles, 'category/tree.articles');
const rootCategoryId = data.rootCategoryId != null ? data.rootCategoryId : companyRootCategoryId;
requireDefined(rootCategoryId, 'rootCategoryId');
const categories = Object.fromEntries( const categories = Object.fromEntries(
list.map((item) => [String(item.id), normalizeCategoryNode(item)]), list.map((item) => [String(item.id), normalizeCategoryNode(item)]),
); );
const rootCategoryId = data?.rootCategoryId ?? companyRootCategoryId ?? null; return {
const rootArticles = Array.isArray(payload?.articles) ? payload.articles : []; rootCategoryId,
categories,
return { rootCategoryId, categories, rootArticles }; rootArticles: data.articles,
languageId: data.languageId,
};
} }
export function categoryDisplayName(name, categoryId, rootCategoryId) { export function categoryDisplayName(name, categoryId, rootCategoryId) {
@@ -38,33 +46,65 @@ export function categoryDisplayName(name, categoryId, rootCategoryId) {
return name?.trim() || ''; return name?.trim() || '';
} }
export function normalizeCategoryMap(data) { export function categorySlug(name, categoryId, rootCategoryId) {
return parseCategoryTreeResponse(data).categories; 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) { export function getCategoryChildren(categories, parentId) {
if (!categories) return []; if (!categories) return [];
const parentKey = String(parentId); const parentKey = String(parentId);
return Object.values(categories) 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); .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( export const loadCategoryTree = createAsyncThunk(
'category/loadTree', 'category/loadTree',
async (companyId, { rejectWithValue, getState }) => { async (_, { rejectWithValue, getState }) => {
try { try {
const data = await category.getTree({ companyId }); const companyId = getState().company.current?.id;
const fallbackRootId = getState().company.current?.rootCategoryId ?? null; const companyRootId = getState().company.current?.rootCategoryId;
const { rootCategoryId, categories, rootArticles } = parseCategoryTreeResponse( requireDefined(companyRootId, 'company.rootCategoryId');
data, const data = await category.getTree();
fallbackRootId, const parsed = parseCategoryTreeResponse(data, companyRootId);
);
return { return {
companyId, companyId,
rootCategoryId, ...parsed,
categories,
rootArticles,
}; };
} catch (err) { } catch (err) {
return catchBackendError(rejectWithValue)(err); return catchBackendError(rejectWithValue)(err);
@@ -74,14 +114,51 @@ export const loadCategoryTree = createAsyncThunk(
export const loadCategoryArticles = createAsyncThunk( export const loadCategoryArticles = createAsyncThunk(
'category/loadArticles', 'category/loadArticles',
async ({ categoryId, offset = 0, maxCount = 200 } = {}, { rejectWithValue }) => { async (categoryId, { rejectWithValue }) => {
try { try {
const data = await article.byCategory({ categoryId, offset, maxCount }); const data = await category.getArticles(categoryId);
return { requireObject(data, `category/${categoryId}/articles`);
categoryId, const articles = requireArray(data.articles, `category/${categoryId}/articles.articles`);
recordset: data.recordset ?? [], return { categoryId, articles };
length: data.length ?? data.recordset?.length ?? 0, } 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) { } catch (err) {
return catchBackendError(rejectWithValue)(err); return catchBackendError(rejectWithValue)(err);
} }
@@ -90,6 +167,7 @@ export const loadCategoryArticles = createAsyncThunk(
const initialState = { const initialState = {
companyId: null, companyId: null,
languageId: null,
categories: null, categories: null,
rootCategoryId: null, rootCategoryId: null,
rootArticles: [], rootArticles: [],
@@ -107,10 +185,16 @@ const categorySlice = createSlice({
reducers: { reducers: {
navigateToCategory(state, action) { navigateToCategory(state, action) {
state.currentCategoryId = action.payload; state.currentCategoryId = action.payload;
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.articles = [];
state.articlesTotal = 0; state.articlesTotal = 0;
state.articlesStatus = 'idle'; state.articlesStatus = 'idle';
state.error = null; }
}, },
navigateBack(state) { navigateBack(state) {
const current = state.categories?.[String(state.currentCategoryId)]; const current = state.categories?.[String(state.currentCategoryId)];
@@ -143,10 +227,11 @@ const categorySlice = createSlice({
.addCase(loadCategoryTree.fulfilled, (state, action) => { .addCase(loadCategoryTree.fulfilled, (state, action) => {
state.treeStatus = 'succeeded'; state.treeStatus = 'succeeded';
state.companyId = action.payload.companyId; state.companyId = action.payload.companyId;
state.languageId = action.payload.languageId;
state.categories = action.payload.categories; state.categories = action.payload.categories;
state.rootCategoryId = action.payload.rootCategoryId; state.rootCategoryId = action.payload.rootCategoryId;
state.currentCategoryId = action.payload.rootCategoryId; state.currentCategoryId = action.payload.rootCategoryId;
state.rootArticles = action.payload.rootArticles ?? []; state.rootArticles = action.payload.rootArticles;
state.articles = state.rootArticles; state.articles = state.rootArticles;
state.articlesTotal = state.rootArticles.length; state.articlesTotal = state.rootArticles.length;
state.articlesStatus = 'succeeded'; state.articlesStatus = 'succeeded';
@@ -161,8 +246,8 @@ const categorySlice = createSlice({
}) })
.addCase(loadCategoryArticles.fulfilled, (state, action) => { .addCase(loadCategoryArticles.fulfilled, (state, action) => {
state.articlesStatus = 'succeeded'; state.articlesStatus = 'succeeded';
state.articles = action.payload.recordset; state.articles = action.payload.articles;
state.articlesTotal = action.payload.length; state.articlesTotal = action.payload.articles.length;
}) })
.addCase(loadCategoryArticles.rejected, (state, action) => { .addCase(loadCategoryArticles.rejected, (state, action) => {
state.articlesStatus = 'failed'; state.articlesStatus = 'failed';
@@ -170,6 +255,32 @@ const categorySlice = createSlice({
state.articlesTotal = 0; state.articlesTotal = 0;
state.error = resolveBackendError(action); 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) => { .addCase(loadCurrentCompany.fulfilled, (state, action) => {
const nextCompanyId = action.payload?.id ?? null; const nextCompanyId = action.payload?.id ?? null;
if (nextCompanyId != null && nextCompanyId !== state.companyId) { if (nextCompanyId != null && nextCompanyId !== state.companyId) {
@@ -206,6 +317,13 @@ export const selectCurrentCategory = (state) => {
export const selectChildCategories = (state) => export const selectChildCategories = (state) =>
getCategoryChildren(state.category.categories, state.category.currentCategoryId); 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) => { export const selectCanNavigateBack = (state) => {
const { currentCategoryId, rootCategoryId, categories } = state.category; const { currentCategoryId, rootCategoryId, categories } = state.category;
if (currentCategoryId == null || rootCategoryId == null) return false; if (currentCategoryId == null || rootCategoryId == null) return false;

View File

@@ -78,8 +78,8 @@ export const selectCurrentCompany = (state) => state.company.current;
export const selectCompanyId = (state) => state.company.current?.id ?? null; export const selectCompanyId = (state) => state.company.current?.id ?? null;
export const selectCompanyStatus = (state) => state.company.status; export const selectCompanyStatus = (state) => state.company.status;
export const selectCompanyError = (state) => state.company.error; export const selectCompanyError = (state) => state.company.error;
export const selectCompanyName = (state) => state.company.current?.name ?? ''; export const selectCompanyName = (state) => state.company.current?.name;
export const selectCompanyIso639 = (state) => state.company.current?.iso639 ?? 'de'; export const selectCompanyIso639 = (state) => state.company.current?.iso639;
export const selectCompanyTaxrate = (state) => state.company.current?.taxrate ?? null; export const selectCompanyTaxrate = (state) => state.company.current?.taxrate ?? null;
export const selectCompanyReducedTaxrate = (state) => state.company.current?.reducedtaxrate ?? null; export const selectCompanyReducedTaxrate = (state) => state.company.current?.reducedtaxrate ?? null;
export const selectCompanyRootCategoryId = (state) => state.company.current?.rootCategoryId ?? null; export const selectCompanyRootCategoryId = (state) => state.company.current?.rootCategoryId ?? null;

20
src/utils/apiShape.js Normal file
View File

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

View File

@@ -1,5 +1,6 @@
export const ISO639_OPTIONS = ['de', 'en']; export const ISO639_OPTIONS = ['de', 'en'];
/** Default für leeres Anlege-Formular (Browser-Sprache), nicht für API-Parsing. */
export function defaultIso639() { export function defaultIso639() {
return navigator.language?.toLowerCase().startsWith('de') ? 'de' : 'en'; return navigator.language?.toLowerCase().startsWith('de') ? 'de' : 'en';
} }
@@ -10,21 +11,14 @@ export function parseTaxRate(value) {
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; 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 = {}) { export function normalizeCompany(raw, overrides = {}) {
if (!raw) return null; if (!raw) return null;
return { return {
id: raw.id, id: raw.id,
name: overrides.name ?? raw.name ?? '', name: overrides.name !== undefined ? overrides.name : raw.name,
iso639: overrides.iso639 ?? companyLanguageToIso639(raw), iso639: overrides.iso639 !== undefined ? overrides.iso639 : raw.iso639,
taxrate: overrides.taxrate ?? raw.taxrate ?? null, taxrate: overrides.taxrate !== undefined ? overrides.taxrate : raw.taxrate,
reducedtaxrate: overrides.reducedtaxrate ?? raw.reducedtaxrate ?? null, reducedtaxrate: overrides.reducedtaxrate !== undefined ? overrides.reducedtaxrate : raw.reducedtaxrate,
rootCategoryId: overrides.rootCategoryId ?? raw.rootCategoryId ?? null, rootCategoryId: overrides.rootCategoryId !== undefined ? overrides.rootCategoryId : raw.rootCategoryId,
}; };
} }

View File

@@ -78,17 +78,22 @@ const sharedServer = {
const responseBody = responseChunks.length const responseBody = responseChunks.length
? Buffer.concat(responseChunks).toString('utf8') ? Buffer.concat(responseChunks).toString('utf8')
: ''; : '';
const details = { const parts = [
status: proxyRes.statusCode, '[vite-proxy]',
setCookie: cookies ?? '(none)', req.method,
response: formatLoggedBody(proxyRes.statusCode, responseBody), req.url,
}; String(proxyRes.statusCode),
];
if (hasRequestBody(req.method)) { if (hasRequestBody(req.method)) {
details.body = req._proxyRequestBody parts.push(
? truncate(req._proxyRequestBody) `body=${req._proxyRequestBody ? truncate(req._proxyRequestBody) : '(empty)'}`,
: '(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(' '));
}); });
}); });
}, },