diff --git a/TODO.md b/TODO.md index 57a375b..3313a9a 100644 --- a/TODO.md +++ b/TODO.md @@ -14,6 +14,7 @@ | 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 } }` | +| Kategorie-Reihenfolge | `PUT /api/category/order` | `{ parentId, categoryIds: number[] }` | `{ categoryIds }` | | Detail liefert neue Felder | `GET /api/article/:sku` | – | `{ article }` mit `eans`, `categoryIds`, `weight`, `brand` | `Brand` = `{ id, name, gsprpinfo \| null }`. @@ -39,6 +40,7 @@ Alle PUT/PATCH-Endpoints der Artikel liefern dasselbe Shape wie `GET /api/articl - **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. +- **Kategorie-Reihenfolge (Drag & Drop)**: nach Sortierung → `PUT /api/category/order` mit `{ parentId, categoryIds }` (die neuen Kind-IDs in gewünschter Reihenfolge). `parentId` ist der aktuelle Elternknoten. `400 Invalid category order` bei falscher Menge/Duplikaten; nur Children desselben Elternteils angeben. - Nach jedem Save das zurückgegebene Detail-Objekt in den Store übernehmen. ### Fehlerbehandlung diff --git a/package-lock.json b/package-lock.json index 2b37e1f..a02c1ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "react-dom": "^19.2.8", "react-redux": "^9.3.0", "react-router-dom": "^7.18.2", + "sortablejs": "^1.15.7", "thumbhash": "^0.1.1" }, "devDependencies": { @@ -6469,6 +6470,12 @@ "node": ">=20.0.0" } }, + "node_modules/sortablejs": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz", + "integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==", + "license": "MIT" + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", diff --git a/package.json b/package.json index 3f18bad..7e663c7 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "react-dom": "^19.2.8", "react-redux": "^9.3.0", "react-router-dom": "^7.18.2", + "sortablejs": "^1.15.7", "thumbhash": "^0.1.1" }, "devDependencies": { diff --git a/src/api/index.js b/src/api/index.js index 45fa93d..c9ab62e 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -345,6 +345,20 @@ export const category = { body: { name }, }); }, + + /** + * PUT /api/category/order + * Body: { parentId, categoryIds: number[] } — sibling order of the children + * under `parentId` (top-down). categoryIds must be exactly the current child + * set of parentId (same count, no foreign/duplicate ids) → 400 Invalid category order. + * → 200 { categoryIds: number[] } + */ + reorder(parentId, categoryIds) { + return http('/api/category/order', { + method: 'PUT', + body: { parentId, categoryIds }, + }); + }, }; /** diff --git a/src/index.css b/src/index.css index 68f0a94..c82ec07 100644 --- a/src/index.css +++ b/src/index.css @@ -10,3 +10,15 @@ body { overscroll-behavior-y: none; touch-action: manipulation; } + +/* SortableJS: Kategorie-Reorder im Edit-Modus */ +.sortable-ghost { + outline: 2px dashed #2e7d32; + outline-offset: 2px; + background-color: rgba(46, 125, 50, 0.12) !important; +} + +.sortable-drag { + opacity: 1 !important; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.28); +} diff --git a/src/pages/artikel/kategorien.jsx b/src/pages/artikel/kategorien.jsx index 5a1d3d8..434b611 100644 --- a/src/pages/artikel/kategorien.jsx +++ b/src/pages/artikel/kategorien.jsx @@ -1,10 +1,11 @@ -import { Component } from 'react'; +import { Component, createRef } 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 Sortable from 'sortablejs'; import BackendError from '../../components/BackendError'; import { NewCategoryTile, CategoryBreadcrumbs } from '../../components/category'; import { NewArticleTile } from '../../components/article'; @@ -31,6 +32,7 @@ import { loadCategoryTree, navigateToCategory, renameCategory, + reorderCategories, selectCanNavigateBack, selectCategoryArticles, selectCategoryArticlesStatus, @@ -52,6 +54,9 @@ class ArtikelKategorienPage extends Component { constructor(props) { super(props); this.state = { drafts: {} }; + this.gridRef = createRef(); + this.sortable = null; + this.checkpointOrder = null; this.handleRenameSubmit = this.handleRenameSubmit.bind(this); this.handleDraftChange = this.handleDraftChange.bind(this); } @@ -60,6 +65,7 @@ class ArtikelKategorienPage extends Component { this.ensureTreeLoaded(); this.syncCategoryFromUrl(); this.ensureArticlesLoaded(); + this.syncSortable(); } componentDidUpdate(prevProps) { @@ -85,8 +91,99 @@ class ArtikelKategorienPage extends Component { if (this.props.currentCategoryId !== prevProps.currentCategoryId) { this.ensureArticlesLoaded(); } + + if (this.props.renameMode !== prevProps.renameMode) { + this.syncSortable(); + } } + componentWillUnmount() { + this.destroySortable(); + } + + syncSortable = () => { + if (this.props.renameMode) { + this.activateSortable(); + } else { + this.destroySortable(); + } + }; + + activateSortable = () => { + const el = this.gridRef.current; + if (!el || this.sortable) return; + this.sortable = Sortable.create(el, { + draggable: '.sortable-item', + animation: 150, + ghostClass: 'sortable-ghost', + dragClass: 'sortable-drag', + filter: '.not-sortable', + onStart: this.handleSortStart, + onEnd: this.handleSort, + }); + }; + + destroySortable = () => { + if (this.sortable) { + this.sortable.destroy(); + this.sortable = null; + } + this.checkpointOrder = null; + }; + + sortableIds = () => { + const container = this.gridRef.current; + if (!container) return []; + return [...container.querySelectorAll('.sortable-item')] + .map((el) => el.dataset.id) + .filter(Boolean) + .map(Number); + }; + + handleSortStart = () => { + // Snapshot der Reihenfolge vor dem ersten Drag. + this.checkpointOrder = this.sortableIds(); + }; + + handleSort = () => { + const ids = this.sortableIds(); + const before = this.checkpointOrder ?? []; + this.checkpointOrder = null; + if ( + before.length === 0 || + before.length !== ids.length || + before.every((id, index) => id === ids[index]) + ) { + return; // nichts geändert + } + void this.props + .reorderCategories({ categoryIds: ids }) + .unwrap() + .catch((err) => { + notifyError(err); + // Reihenfolge im DOM zurückdrehen, ohne React neu zu rendern — + // die Sortable-Instanz bleibt dadurch funktionsfähig. + this.revertDomOrder(before); + }); + }; + + revertDomOrder = (order) => { + const container = this.gridRef.current; + if (!container) return; + const items = [...container.querySelectorAll('.sortable-item')]; + const byId = new Map(items.map((el) => [Number(el.dataset.id), el])); + // Erste nicht-sortierbare Kachel (= BackTile o. Ä.), davor werden die Items eingefügt. + const anchor = + [...container.children].find((el) => !el.classList.contains('sortable-item')) ?? + null; + for (const id of order) { + const el = byId.get(id); + if (!el) continue; + if (anchor) container.insertBefore(el, anchor); + else container.appendChild(el); + } + }; + ensureTreeLoaded = () => { const { companyId, treeStatus } = this.props; if (!companyId || treeStatus !== 'idle') return; @@ -250,7 +347,7 @@ class ArtikelKategorienPage extends Component { /> )} - + {canGoBack && ( )} @@ -265,6 +362,8 @@ class ArtikelKategorienPage extends Component { return ( { event.preventDefault(); @@ -272,7 +371,7 @@ class ArtikelKategorienPage extends Component { }} sx={{ ...tileBaseSx, - cursor: 'default', + cursor: 'grab', alignItems: 'center', justifyContent: 'center', my: 0.5, @@ -359,6 +458,7 @@ const mapDispatchToProps = { loadCategoryArticles, navigateToCategory, renameCategory, + reorderCategories, }; export default withCategoryRoute(connect(mapStateToProps, mapDispatchToProps)(ArtikelKategorienPage)); diff --git a/src/store/categorySlice.js b/src/store/categorySlice.js index fb487f3..7898bcc 100644 --- a/src/store/categorySlice.js +++ b/src/store/categorySlice.js @@ -168,6 +168,27 @@ export const renameCategory = createAsyncThunk( }, ); +/** + * Reorder sibling categories (new order of children under the current parent). + * `PUT /api/category/order` → 200 { categoryIds: number[] } + */ +export const reorderCategories = createAsyncThunk( + 'category/reorder', + async ({ categoryIds }, { rejectWithValue, getState }) => { + try { + const ids = requireArray(categoryIds, 'categoryIds'); + const parentId = getState().category.currentCategoryId; + requireDefined(parentId, 'currentCategoryId'); + const data = await category.reorder(parentId, ids); + requireObject(data, 'PUT /api/category/order'); + requireArray(data.categoryIds, 'categoryIds'); + return { parentId, categoryIds: requireArray(data.categoryIds, 'categoryIds') }; + } catch (err) { + return catchBackendError(rejectWithValue)(err); + } + }, +); + export const deleteCategory = createAsyncThunk( 'category/delete', async (categoryId, { rejectWithValue }) => { @@ -309,6 +330,26 @@ const categorySlice = createSlice({ : row, ); }) + .addCase(reorderCategories.fulfilled, (state, action) => { + const { parentId, categoryIds } = action.payload; + if (!state.categories) return; + const parentKey = String(parentId); + const idToSort = new Map( + categoryIds.map((id, index) => [String(id), index]), + ); + for (const node of Object.values(state.categories)) { + if ( + node.parentId != null && + String(node.parentId) === parentKey && + idToSort.has(String(node.id)) + ) { + state.categories[String(node.id)] = { + ...node, + sort: idToSort.get(String(node.id)), + }; + } + } + }) .addCase(createArticle.fulfilled, (state, action) => { const created = action.payload; if (created == null) return;