This commit is contained in:
seb
2026-08-02 19:21:21 +02:00
parent b787549a85
commit 5309b1174b
6 changed files with 194 additions and 19 deletions

View File

@@ -13,6 +13,7 @@
| Brand setzen / entfernen | `PUT /api/article/:sku/brand` | `{ brandId: number \| null }` | `{ article }` (Detail) | | Brand setzen / entfernen | `PUT /api/article/:sku/brand` | `{ brandId: number \| null }` | `{ article }` (Detail) |
| Gewicht setzen | `PUT /api/article/:sku/weight` | `{ weight: number }` | `{ article }` (Detail) | | Gewicht setzen | `PUT /api/article/:sku/weight` | `{ weight: number }` | `{ article }` (Detail) |
| Kategorien setzen (replace-all) | `PUT /api/article/:sku/categories` | `{ categoryIds: number[] }` | `{ article }` (Detail) | | Kategorien setzen (replace-all) | `PUT /api/article/:sku/categories` | `{ categoryIds: number[] }` | `{ article }` (Detail) |
| Kategorie umbenennen | `PATCH /api/category/:id` | `{ name }` | `{ category: { id, name, slug } }` |
| Detail liefert neue Felder | `GET /api/article/:sku` | – | `{ article }` mit `eans`, `categoryIds`, `weight`, `brand` | | Detail liefert neue Felder | `GET /api/article/:sku` | – | `{ article }` mit `eans`, `categoryIds`, `weight`, `brand` |
`Brand` = `{ id, name, gsprpinfo \| null }`. `Brand` = `{ id, name, gsprpinfo \| null }`.
@@ -23,7 +24,7 @@ Alle PUT/PATCH-Endpoints der Artikel liefern dasselbe Shape wie `GET /api/articl
### API-Client (`src/api/index.js`) ### API-Client (`src/api/index.js`)
- Alle Calls über den bestehenden Client (`credentials: 'include'`) — Frontend spricht nur HTTP, nie direkt den Store. - Alle Calls über den bestehenden Client (`credentials: 'include'`) — Frontend spricht nur HTTP, nie direkt den Store.
- Neuen Methoden für: `brand.list` / `brand.upsert` / `brand.delete` und `article.setEansBySku` / `article.setBrandIdBySku` / `article.setWeightBySku` / `article.setCategoryIdsBySku`. - Neuen Methoden für: `brand.list` / `brand.upsert` / `brand.delete` und `article.setEansBySku` / `article.setBrandIdBySku` / `article.setWeightBySku` / `article.setCategoryIdsBySku` sowie `category.rename`.
- **Kein `companyId` in Request/Pfad/Query senden** — die Company kommt aus der Session. - **Kein `companyId` in Request/Pfad/Query senden** — die Company kommt aus der Session.
### Brand-UI ### Brand-UI
@@ -37,12 +38,14 @@ Alle PUT/PATCH-Endpoints der Artikel liefern dasselbe Shape wie `GET /api/articl
- **Brand**: Dropdown aus `GET /api/brand`; „Keine Brand“ ⇒ `brandId: null`. Deal nur mit Brands derselben Company (sonst `404 Brand not found`). - **Brand**: Dropdown aus `GET /api/brand`; „Keine Brand“ ⇒ `brandId: null`. Deal nur mit Brands derselben Company (sonst `404 Brand not found`).
- **Gewicht**: Zahlenfeld, `>= 0`. `400 Invalid weight` sonst. - **Gewicht**: Zahlenfeld, `>= 0`. `400 Invalid weight` sonst.
- **Kategorien**: Mehrfachauswahl aus der Category-Tree; nur Kategorien derselben Company (sonst `400 Invalid categoryIds`). Leere Liste entfernt alle. - **Kategorien**: Mehrfachauswahl aus der Category-Tree; nur Kategorien derselben Company (sonst `400 Invalid categoryIds`). Leere Liste entfernt alle.
- **Kategorie umbenennen**: Inline/Formular im Category-Tree → `PATCH /api/category/:id` mit `{ name }`. Leeres `name` ⇒ `400 Name required`. Nach Save das zurückgegebene `{ id, name, slug }` im Baum aktualisieren.
- Nach jedem Save das zurückgegebene Detail-Objekt in den Store übernehmen. - Nach jedem Save das zurückgegebene Detail-Objekt in den Store übernehmen.
### Fehlerbehandlung ### Fehlerbehandlung
- Fehler als Toast/Formular-Fehler abbilden: - Fehler als Toast/Formular-Fehler abbilden:
- `Brand in use` (409), `Invalid eans` / `Invalid weight` / `Invalid categoryIds` / `Invalid brand` (400) - `Brand in use` (409), `Invalid eans` / `Invalid weight` / `Invalid categoryIds` / `Invalid brand` (400)
- `Brand not found` / `Article not found` (404) - `Brand not found` / `Article not found` / `Category not found` (404)
- `Name required` (400) bei leerem Kategorienamen
- `No company selected` (400) → Company-Wechsel-Flow - `No company selected` (400) → Company-Wechsel-Flow
- `Not authorized` (403) - `Not authorized` (403)
@@ -50,4 +53,5 @@ Alle PUT/PATCH-Endpoints der Artikel liefern dasselbe Shape wie `GET /api/articl
- API-Doku: `../dashback/docs/api/README.md` und `../dashback/docs/api/routes/` - API-Doku: `../dashback/docs/api/README.md` und `../dashback/docs/api/routes/`
- `brand/list.md`, `brand/upsert.md`, `brand/delete.md` - `brand/list.md`, `brand/upsert.md`, `brand/delete.md`
- `article/set-eans.md`, `article/set-brand.md`, `article/set-weight.md`, `article/set-categories.md` - `article/set-eans.md`, `article/set-brand.md`, `article/set-weight.md`, `article/set-categories.md`
- `category/rename.md`
- Company-Scope: `google_auth.setSelectedCompany` (Store) — siehe `../dashdbschema/README.md`. - Company-Scope: `google_auth.setSelectedCompany` (Store) — siehe `../dashdbschema/README.md`.

View File

@@ -332,6 +332,19 @@ export const category = {
delete(categoryId) { delete(categoryId) {
return http(`/api/category/${categoryId}`, { method: 'DELETE' }); return http(`/api/category/${categoryId}`, { method: 'DELETE' });
}, },
/**
* PATCH /api/category/:id
* Body: { name } — renames and re-slugifies in the company default language.
* Empty name → 400 name is required.
* → 200 { category: { id, name, slug } }
*/
updateName(categoryId, name) {
return http(`/api/category/${categoryId}`, {
method: 'PATCH',
body: { name },
});
},
}; };
/** /**

View File

@@ -19,7 +19,7 @@ function isSubNavActive(pathname, basePath, segment) {
return pathname === subPath || pathname === `${subPath}/`; return pathname === subPath || pathname === `${subPath}/`;
} }
export default function PageLayout({ title, showTitle = true, subNav, basePath, children }) { export default function PageLayout({ title, showTitle = true, titleAction, 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();
@@ -81,9 +81,12 @@ export default function PageLayout({ title, showTitle = true, subNav, basePath,
<Box sx={{ flexGrow: 1, minWidth: 0 }}> <Box sx={{ flexGrow: 1, minWidth: 0 }}>
{showTitle ? ( {showTitle ? (
<Typography variant="h5" sx={{ fontWeight: 700, mb: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2 }}>
<Typography variant="h5" sx={{ fontWeight: 700 }}>
{title} {title}
</Typography> </Typography>
{titleAction}
</Box>
) : null} ) : null}
{children} {children}
</Box> </Box>

View File

@@ -1,10 +1,16 @@
import { Outlet, useLocation } from 'react-router-dom'; import { Outlet, useLocation } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import FormControlLabel from '@mui/material/FormControlLabel';
import Switch from '@mui/material/Switch';
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'; import { isKategorienPath } from '../pages/artikel/categoryRoute';
import { selectRenameMode, setRenameMode } from '../store/categorySlice';
export default function SectionLayout({ sectionPath }) { export default function SectionLayout({ sectionPath }) {
const { pathname } = useLocation(); const { pathname } = useLocation();
const dispatch = useDispatch();
const renameMode = useSelector(selectRenameMode);
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) => {
@@ -27,8 +33,31 @@ export default function SectionLayout({ sectionPath }) {
? title ? title
: (activeSub?.label ?? title); : (activeSub?.label ?? title);
const showsRenameSwitch = activeSub?.segment === 'kategorien';
const titleAction = showsRenameSwitch ? (
<FormControlLabel
control={
<Switch
size="small"
checked={renameMode}
onChange={(event) => dispatch(setRenameMode(event.target.checked))}
/>
}
label="Editieren"
labelPlacement="end"
sx={{ ml: 0 }}
/>
) : null;
return ( return (
<PageLayout title={pageTitle} showTitle={!isArticleDetail} subNav={subNav} basePath={sectionPath}> <PageLayout
title={pageTitle}
showTitle={!isArticleDetail}
subNav={subNav}
basePath={sectionPath}
titleAction={titleAction}
>
<Outlet /> <Outlet />
</PageLayout> </PageLayout>
); );

View File

@@ -2,15 +2,19 @@ import { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { enqueueSnackbar } from 'notistack';
import BackendError from '../../components/BackendError'; import BackendError from '../../components/BackendError';
import { NewCategoryTile, CategoryBreadcrumbs } from '../../components/category'; import { NewCategoryTile, CategoryBreadcrumbs } from '../../components/category';
import { NewArticleTile } from '../../components/article'; import { NewArticleTile } from '../../components/article';
import { notifyError } from '../../utils/notify';
import { import {
ArticleTile, ArticleTile,
BackTile, BackTile,
CategoryTile, CategoryTile,
TILE_SIZE, TILE_SIZE,
tileBaseSx,
} from '../../components/tiles'; } from '../../components/tiles';
import withCategoryRoute from './withCategoryRoute'; import withCategoryRoute from './withCategoryRoute';
import { artikelDetailPath, articleSku } from './articleRoute'; import { artikelDetailPath, articleSku } from './articleRoute';
@@ -26,6 +30,7 @@ import {
loadCategoryArticles, loadCategoryArticles,
loadCategoryTree, loadCategoryTree,
navigateToCategory, navigateToCategory,
renameCategory,
selectCanNavigateBack, selectCanNavigateBack,
selectCategoryArticles, selectCategoryArticles,
selectCategoryArticlesStatus, selectCategoryArticlesStatus,
@@ -34,6 +39,7 @@ import {
selectCategoryError, selectCategoryError,
selectCategoryTreeStatus, selectCategoryTreeStatus,
selectChildCategories, selectChildCategories,
selectRenameMode,
} from '../../store/categorySlice'; } from '../../store/categorySlice';
const gridSx = { const gridSx = {
@@ -43,6 +49,13 @@ const gridSx = {
}; };
class ArtikelKategorienPage extends Component { class ArtikelKategorienPage extends Component {
constructor(props) {
super(props);
this.state = { drafts: {} };
this.handleRenameSubmit = this.handleRenameSubmit.bind(this);
this.handleDraftChange = this.handleDraftChange.bind(this);
}
componentDidMount() { componentDidMount() {
this.ensureTreeLoaded(); this.ensureTreeLoaded();
this.syncCategoryFromUrl(); this.syncCategoryFromUrl();
@@ -127,6 +140,29 @@ class ArtikelKategorienPage extends Component {
navigate(buildCategoryPath(categories, rootCategoryId, categoryId)); navigate(buildCategoryPath(categories, rootCategoryId, categoryId));
}; };
handleDraftChange(categoryId, value) {
this.setState((state) => ({
drafts: { ...state.drafts, [categoryId]: value },
}));
}
handleRenameSubmit = async (categoryId, name) => {
const trimmed = String(name ?? '').trim();
if (!trimmed) {
enqueueSnackbar('Name ist erforderlich.', { variant: 'error' });
return;
}
try {
await this.props.renameCategory({
categoryId,
name: trimmed,
}).unwrap();
enqueueSnackbar('Kategorie umbenannt.', { variant: 'success' });
} catch (err) {
notifyError(err);
}
};
handleRetryTree = () => { handleRetryTree = () => {
if (this.props.companyId) this.props.loadCategoryTree(); if (this.props.companyId) this.props.loadCategoryTree();
}; };
@@ -161,6 +197,7 @@ class ArtikelKategorienPage extends Component {
currentCategoryId, currentCategoryId,
childCategories, childCategories,
articles, articles,
renameMode,
} = this.props; } = this.props;
if (!companyId) { if (!companyId) {
@@ -218,15 +255,62 @@ class ArtikelKategorienPage extends Component {
<BackTile onClick={this.handleNavigateBack} /> <BackTile onClick={this.handleNavigateBack} />
)} )}
{childCategories.map((item) => ( {childCategories.map((item) => {
const label = categoryDisplayName(item.name, item.id, rootCategoryId);
if (renameMode) {
const draftName =
this.state.drafts[item.id] !== undefined
? this.state.drafts[item.id]
: label;
return (
<Box
key={item.id}
component="form"
onSubmit={(event) => {
event.preventDefault();
void this.handleRenameSubmit(item.id, this.state.drafts[item.id] ?? label);
}}
sx={{
...tileBaseSx,
cursor: 'default',
alignItems: 'center',
justifyContent: 'center',
my: 0.5,
bgcolor: 'action.hover',
'&:hover': { bgcolor: 'action.hover' },
}}
>
<TextField
fullWidth
size="small"
variant="standard"
value={draftName}
onChange={(e) => this.handleDraftChange(item.id, e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
sx={{
mx: 0.5,
'& .MuiInput-underline:before, & .MuiInput-underline:after': {
borderBottom: 'none',
},
'& .MuiInputBase-input': {
textAlign: 'center',
fontWeight: 600,
},
}}
/>
</Box>
);
}
return (
<CategoryTile <CategoryTile
key={item.id} key={item.id}
label={categoryDisplayName(item.name, item.id, rootCategoryId)} label={label}
onClick={() => this.navigateToCategoryById(item.id)} onClick={() => this.navigateToCategoryById(item.id)}
/> />
))} );
})}
{articles.map((item) => ( {!renameMode && articles.map((item) => (
<ArticleTile <ArticleTile
key={item.id} key={item.id}
label={item.name != null ? item.name.trim() : undefined} label={item.name != null ? item.name.trim() : undefined}
@@ -237,10 +321,12 @@ class ArtikelKategorienPage extends Component {
))} ))}
<NewCategoryTile parentId={currentCategoryId} /> <NewCategoryTile parentId={currentCategoryId} />
{!renameMode && (
<NewArticleTile <NewArticleTile
parentId={currentCategoryId} parentId={currentCategoryId}
onOpenArticle={this.handleArticleClick} onOpenArticle={this.handleArticleClick}
/> />
)}
</Box> </Box>
{showArticlesLoading && ( {showArticlesLoading && (
@@ -265,12 +351,14 @@ const mapStateToProps = (state) => ({
childCategories: selectChildCategories(state), childCategories: selectChildCategories(state),
articles: selectCategoryArticles(state), articles: selectCategoryArticles(state),
canGoBack: selectCanNavigateBack(state), canGoBack: selectCanNavigateBack(state),
renameMode: selectRenameMode(state),
}); });
const mapDispatchToProps = { const mapDispatchToProps = {
loadCategoryTree, loadCategoryTree,
loadCategoryArticles, loadCategoryArticles,
navigateToCategory, navigateToCategory,
renameCategory,
}; };
export default withCategoryRoute(connect(mapStateToProps, mapDispatchToProps)(ArtikelKategorienPage)); export default withCategoryRoute(connect(mapStateToProps, mapDispatchToProps)(ArtikelKategorienPage));

View File

@@ -153,6 +153,21 @@ export const createArticle = createAsyncThunk(
}, },
); );
export const renameCategory = createAsyncThunk(
'category/rename',
async ({ categoryId, name }, { rejectWithValue }) => {
try {
const trimmed = String(name ?? '').trim();
if (!trimmed) throw new Error('Name required');
const data = await category.updateName(categoryId, trimmed);
requireObject(data.category, 'PATCH /api/category/:id.category');
return normalizeCategoryNode(data.category);
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
export const deleteCategory = createAsyncThunk( export const deleteCategory = createAsyncThunk(
'category/delete', 'category/delete',
async (categoryId, { rejectWithValue }) => { async (categoryId, { rejectWithValue }) => {
@@ -174,6 +189,7 @@ const initialState = {
currentCategoryId: null, currentCategoryId: null,
articles: [], articles: [],
articlesTotal: 0, articlesTotal: 0,
renameMode: false,
treeStatus: 'idle', treeStatus: 'idle',
articlesStatus: 'idle', articlesStatus: 'idle',
error: null, error: null,
@@ -217,6 +233,9 @@ const categorySlice = createSlice({
resetCategoryState() { resetCategoryState() {
return initialState; return initialState;
}, },
setRenameMode(state, action) {
state.renameMode = Boolean(action.payload);
},
removeArticleBySku(state, action) { removeArticleBySku(state, action) {
const sku = action.payload; const sku = action.payload;
state.articles = state.articles.filter((row) => row.sku !== sku); state.articles = state.articles.filter((row) => row.sku !== sku);
@@ -274,6 +293,22 @@ const categorySlice = createSlice({
if (!state.categories) state.categories = {}; if (!state.categories) state.categories = {};
state.categories[String(action.payload.id)] = action.payload; state.categories[String(action.payload.id)] = action.payload;
}) })
.addCase(renameCategory.fulfilled, (state, action) => {
const { id, name } = action.payload;
if (id == null || name == null) return;
const key = String(id);
const existing = state.categories?.[key];
if (existing) {
state.categories[key] = { ...existing, name };
} else if (state.categories) {
state.categories[key] = action.payload;
}
state.rootArticles = state.rootArticles.map((row) =>
row?.categoryId != null && String(row.categoryId) === key
? { ...row, name }
: row,
);
})
.addCase(createArticle.fulfilled, (state, action) => { .addCase(createArticle.fulfilled, (state, action) => {
const created = action.payload; const created = action.payload;
if (created == null) return; if (created == null) return;
@@ -313,10 +348,13 @@ export const {
navigateToCategory, navigateToCategory,
navigateBack, navigateBack,
resetCategoryState, resetCategoryState,
setRenameMode,
removeArticleBySku, removeArticleBySku,
patchArticleInLists, patchArticleInLists,
} = categorySlice.actions; } = categorySlice.actions;
export const selectRenameMode = (state) => state.category.renameMode;
export const selectCategoryMap = (state) => state.category.categories; export const selectCategoryMap = (state) => state.category.categories;
export const selectCurrentCategoryId = (state) => state.category.currentCategoryId; export const selectCurrentCategoryId = (state) => state.category.currentCategoryId;
export const selectCategoryRootId = (state) => state.category.rootCategoryId; export const selectCategoryRootId = (state) => state.category.rootCategoryId;