From 5309b1174bbf08883d23927ad43f0c00200061f6 Mon Sep 17 00:00:00 2001 From: seb Date: Sun, 2 Aug 2026 19:21:21 +0200 Subject: [PATCH] u --- TODO.md | 8 ++- src/api/index.js | 13 ++++ src/components/PageLayout.jsx | 11 +-- src/components/SectionLayout.jsx | 31 ++++++++- src/pages/artikel/kategorien.jsx | 112 +++++++++++++++++++++++++++---- src/store/categorySlice.js | 38 +++++++++++ 6 files changed, 194 insertions(+), 19 deletions(-) diff --git a/TODO.md b/TODO.md index 4fe2531..57a375b 100644 --- a/TODO.md +++ b/TODO.md @@ -13,6 +13,7 @@ | Brand setzen / entfernen | `PUT /api/article/:sku/brand` | `{ brandId: number \| null }` | `{ 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) | +| 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` | `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`) - 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. ### 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`). - **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. +- **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. ### Fehlerbehandlung - Fehler als Toast/Formular-Fehler abbilden: - `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 - `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/` - `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` + - `category/rename.md` - Company-Scope: `google_auth.setSelectedCompany` (Store) — siehe `../dashdbschema/README.md`. diff --git a/src/api/index.js b/src/api/index.js index 37fdc8a..45fa93d 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -332,6 +332,19 @@ export const category = { delete(categoryId) { 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 }, + }); + }, }; /** diff --git a/src/components/PageLayout.jsx b/src/components/PageLayout.jsx index a63d5bb..5f69124 100644 --- a/src/components/PageLayout.jsx +++ b/src/components/PageLayout.jsx @@ -19,7 +19,7 @@ function isSubNavActive(pathname, basePath, segment) { 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 navigate = useNavigate(); const { pathname } = useLocation(); @@ -81,9 +81,12 @@ export default function PageLayout({ title, showTitle = true, subNav, basePath, {showTitle ? ( - - {title} - + + + {title} + + {titleAction} + ) : null} {children} diff --git a/src/components/SectionLayout.jsx b/src/components/SectionLayout.jsx index 76cd763..e538c96 100644 --- a/src/components/SectionLayout.jsx +++ b/src/components/SectionLayout.jsx @@ -1,10 +1,16 @@ 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 { NAV_ITEMS, SUB_NAV } from '../nav'; import { isKategorienPath } from '../pages/artikel/categoryRoute'; +import { selectRenameMode, setRenameMode } from '../store/categorySlice'; export default function SectionLayout({ sectionPath }) { const { pathname } = useLocation(); + const dispatch = useDispatch(); + const renameMode = useSelector(selectRenameMode); const title = NAV_ITEMS.find((item) => item.path === sectionPath)?.label ?? ''; const subNav = SUB_NAV[sectionPath]; const activeSub = subNav?.find((item) => { @@ -27,8 +33,31 @@ export default function SectionLayout({ sectionPath }) { ? title : (activeSub?.label ?? title); + const showsRenameSwitch = activeSub?.segment === 'kategorien'; + + const titleAction = showsRenameSwitch ? ( + dispatch(setRenameMode(event.target.checked))} + /> + } + label="Editieren" + labelPlacement="end" + sx={{ ml: 0 }} + /> + ) : null; + return ( - + ); diff --git a/src/pages/artikel/kategorien.jsx b/src/pages/artikel/kategorien.jsx index b565492..5a1d3d8 100644 --- a/src/pages/artikel/kategorien.jsx +++ b/src/pages/artikel/kategorien.jsx @@ -2,15 +2,19 @@ import { Component } from 'react'; import { connect } from 'react-redux'; import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; +import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; +import { enqueueSnackbar } from 'notistack'; import BackendError from '../../components/BackendError'; import { NewCategoryTile, CategoryBreadcrumbs } from '../../components/category'; import { NewArticleTile } from '../../components/article'; +import { notifyError } from '../../utils/notify'; import { ArticleTile, BackTile, CategoryTile, TILE_SIZE, + tileBaseSx, } from '../../components/tiles'; import withCategoryRoute from './withCategoryRoute'; import { artikelDetailPath, articleSku } from './articleRoute'; @@ -26,6 +30,7 @@ import { loadCategoryArticles, loadCategoryTree, navigateToCategory, + renameCategory, selectCanNavigateBack, selectCategoryArticles, selectCategoryArticlesStatus, @@ -34,6 +39,7 @@ import { selectCategoryError, selectCategoryTreeStatus, selectChildCategories, + selectRenameMode, } from '../../store/categorySlice'; const gridSx = { @@ -43,6 +49,13 @@ const gridSx = { }; class ArtikelKategorienPage extends Component { + constructor(props) { + super(props); + this.state = { drafts: {} }; + this.handleRenameSubmit = this.handleRenameSubmit.bind(this); + this.handleDraftChange = this.handleDraftChange.bind(this); + } + componentDidMount() { this.ensureTreeLoaded(); this.syncCategoryFromUrl(); @@ -127,6 +140,29 @@ class ArtikelKategorienPage extends Component { 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 = () => { if (this.props.companyId) this.props.loadCategoryTree(); }; @@ -161,6 +197,7 @@ class ArtikelKategorienPage extends Component { currentCategoryId, childCategories, articles, + renameMode, } = this.props; if (!companyId) { @@ -218,15 +255,62 @@ class ArtikelKategorienPage extends Component { )} - {childCategories.map((item) => ( - this.navigateToCategoryById(item.id)} - /> - ))} + {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 ( + { + 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' }, + }} + > + 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, + }, + }} + /> + + ); + } + return ( + this.navigateToCategoryById(item.id)} + /> + ); + })} - {articles.map((item) => ( + {!renameMode && articles.map((item) => ( - + {!renameMode && ( + + )} {showArticlesLoading && ( @@ -265,12 +351,14 @@ const mapStateToProps = (state) => ({ childCategories: selectChildCategories(state), articles: selectCategoryArticles(state), canGoBack: selectCanNavigateBack(state), + renameMode: selectRenameMode(state), }); const mapDispatchToProps = { loadCategoryTree, loadCategoryArticles, navigateToCategory, + renameCategory, }; export default withCategoryRoute(connect(mapStateToProps, mapDispatchToProps)(ArtikelKategorienPage)); diff --git a/src/store/categorySlice.js b/src/store/categorySlice.js index b11d5b9..fb487f3 100644 --- a/src/store/categorySlice.js +++ b/src/store/categorySlice.js @@ -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( 'category/delete', async (categoryId, { rejectWithValue }) => { @@ -174,6 +189,7 @@ const initialState = { currentCategoryId: null, articles: [], articlesTotal: 0, + renameMode: false, treeStatus: 'idle', articlesStatus: 'idle', error: null, @@ -217,6 +233,9 @@ const categorySlice = createSlice({ resetCategoryState() { return initialState; }, + setRenameMode(state, action) { + state.renameMode = Boolean(action.payload); + }, removeArticleBySku(state, action) { const sku = action.payload; state.articles = state.articles.filter((row) => row.sku !== sku); @@ -274,6 +293,22 @@ const categorySlice = createSlice({ if (!state.categories) state.categories = {}; 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) => { const created = action.payload; if (created == null) return; @@ -313,10 +348,13 @@ export const { navigateToCategory, navigateBack, resetCategoryState, + setRenameMode, removeArticleBySku, patchArticleInLists, } = categorySlice.actions; +export const selectRenameMode = (state) => state.category.renameMode; + export const selectCategoryMap = (state) => state.category.categories; export const selectCurrentCategoryId = (state) => state.category.currentCategoryId; export const selectCategoryRootId = (state) => state.category.rootCategoryId;