diff --git a/TODO.md b/TODO.md index 3313a9a..f16e73d 100644 --- a/TODO.md +++ b/TODO.md @@ -15,6 +15,7 @@ | 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 }` | +| **Kategorie verschieben** (VORSCHLAG, offen) | `PATCH /api/category/:id` | `{ parentId: number }` | `{ category: { id, parentId, name, slug } }` | | Detail liefert neue Felder | `GET /api/article/:sku` | – | `{ article }` mit `eans`, `categoryIds`, `weight`, `brand` | `Brand` = `{ id, name, gsprpinfo \| null }`. @@ -43,6 +44,15 @@ Alle PUT/PATCH-Endpoints der Artikel liefern dasselbe Shape wie `GET /api/articl - **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. +### Kategorie in Unterkategorie ablegen (Tree-Drop) — [VORSCHLAG an dashback, offen] +- **UI fertig**: oberes Drittel der Edit-Kachel zeigt links Move-Icon (`DragHandle`), rechts Tree-Icon (`AccountTree`). Beide sind Drag-Handles (`handle: .sortable-handle, .sortable-tree`). Ziehen am **Tree**-Icon sortiert die Geschwister **nicht** neu um (`handleSort` ignoriert Tree-Draggs). +- **Backend-Vorschlag** (fehlt noch in `dashback/docs/api/routes/`): + - `PATCH /api/category/:id` erweitern: optional `parentId` im Body, zusätzlich zu `name`. + - Request: `{ parentId: number }` → verschiebt die Kategorie unter den neuen Eltern. `{ name }` → nur Umbenennen (bestehend). + - Response: `{ category: { id, parentId, name, slug } }`. + - Erforderliche Validierung: Ziel-Eltern existiert (404), kein Zyklus/kein Verschieben in sich selbst oder eigene Nachkommen (400), Company-Scope (403). +- **Bis der Vertrag feststeht**: keine Frontend-Implementierung des Drops (Regel `no-assumed-api`). + ### Fehlerbehandlung - Fehler als Toast/Formular-Fehler abbilden: - `Brand in use` (409), `Invalid eans` / `Invalid weight` / `Invalid categoryIds` / `Invalid brand` (400) diff --git a/src/components/tiles/CategoryTile.jsx b/src/components/tiles/CategoryTile.jsx index 1910197..0dc6106 100644 --- a/src/components/tiles/CategoryTile.jsx +++ b/src/components/tiles/CategoryTile.jsx @@ -1,8 +1,161 @@ -import FolderIcon from '@mui/icons-material/Folder'; -import { Tile } from './Tile'; +import { Component } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import DragHandleIcon from '@mui/icons-material/DragHandle'; +import AccountTreeIcon from '@mui/icons-material/AccountTree'; +import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; +import { Tile, tileBaseSx } from './Tile'; +/** + * Top edit band (upper third) with a move handle on the left and a tree + * (drop-into-subcategory) handle on the right. + */ +function TopBand() { + return ( + + + + + + + + + ); +} + +/** + * Shared grey name band spanning the middle third. + */ +function NameBand({ label, nameInput }) { + if (label == null) return null; + return ( + + {nameInput ?? ( + + {label} + + )} + + ); +} + +/** + * Bottom edit band with left/right chevron reorder buttons and a center delete. + */ +function EditBand({ onMoveLeft, onMoveRight, onDelete, canLeft, canRight }) { + return ( + + + + + + + + + + + + ); +} + +/** + * Category tile (normal mode): no icon, click navigates to the category. + */ export class CategoryTile extends Tile { renderIcon() { - return ; + return null; + } + + renderLabel() { + const { label, nameInput } = this.props; + return ; + } +} + +/** + * Category tile for edit mode: no ButtonBase (no ripple, no click navigation), + * always shows the bottom edit band with chevron reorder/delete actions. + */ +export class CategoryEditTile extends Component { + render() { + const { label, nameInput, editBand, sx, ...rest } = this.props; + return ( + + + + + + ); } } diff --git a/src/components/tiles/Tile.jsx b/src/components/tiles/Tile.jsx index 84d850e..38e9f59 100644 --- a/src/components/tiles/Tile.jsx +++ b/src/components/tiles/Tile.jsx @@ -60,6 +60,10 @@ export class Tile extends Component { ); } + renderExtra() { + return null; + } + render() { const { sx, disabled } = this.props; return ( @@ -70,6 +74,7 @@ export class Tile extends Component { onClick={this.handleClick} sx={{ ...tileBaseSx, + position: 'relative', ...(disabled ? { opacity: 0.5, pointerEvents: 'none' } : {}), @@ -79,6 +84,7 @@ export class Tile extends Component { {this.renderIcon()} {this.renderLabel()} {this.renderSecondary()} + {this.renderExtra()} ); } diff --git a/src/components/tiles/index.js b/src/components/tiles/index.js index fb452d6..96ff3ed 100644 --- a/src/components/tiles/index.js +++ b/src/components/tiles/index.js @@ -1,5 +1,5 @@ export { TILE_SIZE, Tile, tileBaseSx } from './Tile'; export { BackTile } from './BackTile'; -export { CategoryTile } from './CategoryTile'; +export { CategoryTile, CategoryEditTile } from './CategoryTile'; export { ArticleTile } from './ArticleTile'; export { ActionTile } from './ActionTile'; diff --git a/src/index.css b/src/index.css index c82ec07..2473b69 100644 --- a/src/index.css +++ b/src/index.css @@ -22,3 +22,8 @@ body { opacity: 1 !important; box-shadow: 0 8px 20px rgba(0, 0, 0, 0.28); } + +/* Tree-Drag (In-Unterkategorie-ablegen): kein Ghost/Shadow, Kacheln bleiben fest */ +.sortable-tree-ghost { + display: none !important; +} diff --git a/src/pages/artikel/kategorien.jsx b/src/pages/artikel/kategorien.jsx index 434b611..2d3801f 100644 --- a/src/pages/artikel/kategorien.jsx +++ b/src/pages/artikel/kategorien.jsx @@ -13,9 +13,9 @@ import { notifyError } from '../../utils/notify'; import { ArticleTile, BackTile, + CategoryEditTile, CategoryTile, TILE_SIZE, - tileBaseSx, } from '../../components/tiles'; import withCategoryRoute from './withCategoryRoute'; import { artikelDetailPath, articleSku } from './articleRoute'; @@ -28,6 +28,7 @@ import { } from './categoryRoute'; import { categoryDisplayName, + deleteCategory, loadCategoryArticles, loadCategoryTree, navigateToCategory, @@ -57,6 +58,7 @@ class ArtikelKategorienPage extends Component { this.gridRef = createRef(); this.sortable = null; this.checkpointOrder = null; + this.treeDragId = null; this.handleRenameSubmit = this.handleRenameSubmit.bind(this); this.handleDraftChange = this.handleDraftChange.bind(this); } @@ -103,6 +105,9 @@ class ArtikelKategorienPage extends Component { syncSortable = () => { if (this.props.renameMode) { + // Erst alte Instanz sicher los, falls irgendwann noch vorhanden, + // damit Sortable die aktuelle (Edit-)DOM-Struktur bindet. + this.destroySortable(); this.activateSortable(); } else { this.destroySortable(); @@ -112,18 +117,26 @@ class ArtikelKategorienPage extends Component { activateSortable = () => { const el = this.gridRef.current; if (!el || this.sortable) return; + // Capture-Listener im Grid: läuft vor SortableJS' Listener und vor Reacts + // synthetischem mousedown — erkennt die Drag-Quelle (Tree vs. Move) zuverlässig. + el.addEventListener('mousedown', this.handleGridMouseDownCapture, true); this.sortable = Sortable.create(el, { draggable: '.sortable-item', + handle: '.sortable-handle, .sortable-tree', animation: 150, ghostClass: 'sortable-ghost', dragClass: 'sortable-drag', - filter: '.not-sortable', + filter: '.not-sortable, .sortable-handle-btn', onStart: this.handleSortStart, onEnd: this.handleSort, }); }; destroySortable = () => { + const el = this.gridRef.current; + if (el) { + el.removeEventListener('mousedown', this.handleGridMouseDownCapture, true); + } if (this.sortable) { this.sortable.destroy(); this.sortable = null; @@ -140,15 +153,50 @@ class ArtikelKategorienPage extends Component { .map(Number); }; - handleSortStart = () => { + handleGridMouseDownCapture = (event) => { + // Native Capture-Phase: läuft VOR SortableJS' Bubbling-Listener und vor + // Reacts synthetischem Event. Das echte DOM-Ziel wird so zuverlässig erkannt. + const target = event.target; + if (!target || typeof target.closest !== 'function') return; + const treeEl = target.closest('.sortable-tree'); + const isTree = treeEl != null; + this.treeDragId = isTree + ? Number(treeEl?.closest('.sortable-item')?.dataset?.id) + : null; + // Beim Tree-Drag Sortable-Deaktivierung (sort=false) setzen, damit nichts umsortiert. + this.sortable?.option('sort', !isTree); + }; + + handleSortStart = (evt) => { + const isTree = this.treeDragId != null; + if (isTree) { + // Kein Ghost/Shadow — Kacheln bleiben fest, nur das Ziel wird sichtbar. + this.sortable?.option('ghostClass', 'sortable-tree-ghost'); + this.sortable?.option('dragClass', ''); + } else { + this.sortable?.option('ghostClass', 'sortable-ghost'); + this.sortable?.option('dragClass', 'sortable-drag'); + } // Snapshot der Reihenfolge vor dem ersten Drag. this.checkpointOrder = this.sortableIds(); }; handleSort = () => { - const ids = this.sortableIds(); + // Sortable-Klassen für den nächsten Drag zurücksetzen. + this.sortable?.option('ghostClass', 'sortable-ghost'); + this.sortable?.option('dragClass', 'sortable-drag'); + this.sortable?.option('sort', true); + // Tree-Drag (in Unterkategorie ablegen): Geschwister-Reihenfolge nicht ändern. const before = this.checkpointOrder ?? []; this.checkpointOrder = null; + if (this.treeDragId != null) { + // Kacheln visuell an Original-Position zurückdrehen — kein Reorder-Call. + this.treeDragId = null; + if (before.length) this.revertDomOrder(before); + this.clearSortableState(); + return; + } + const ids = this.sortableIds(); if ( before.length === 0 || before.length !== ids.length || @@ -164,9 +212,36 @@ class ArtikelKategorienPage extends Component { // Reihenfolge im DOM zurückdrehen, ohne React neu zu rendern — // die Sortable-Instanz bleibt dadurch funktionsfähig. this.revertDomOrder(before); + this.clearSortableState(); }); }; + /** + * Entfernt verbliebene SortableJS-Drag/Chosen/Ghost-Klassen von allen + * Kacheln. Nötig, weil revertDomOrder die DOM-Knoten umsortiert und + * SortableJS sein internes Cleanup dann ggf. nicht mehr korrekt anwendet. + */ + clearSortableState = () => { + const container = this.gridRef.current; + if (!container) return; + const classes = ['sortable-chosen', 'sortable-drag', 'sortable-ghost', 'sortable-tree-ghost']; + for (const el of container.querySelectorAll('.sortable-item')) { + el.classList.remove(...classes); + } + }; + + handleMoveCategory = (category, direction) => { + const ids = this.sortableIds(); + const index = ids.indexOf(category.id); + if (index === -1) return; + const target = index + direction; + if (target < 0 || target >= ids.length) return; // nicht verschiebbar + const next = [...ids]; + next[index] = ids[target]; + next[target] = category.id; + void this.props.reorderCategories({ categoryIds: next }).unwrap().catch(notifyError); + }; + revertDomOrder = (order) => { const container = this.gridRef.current; if (!container) return; @@ -260,6 +335,15 @@ class ArtikelKategorienPage extends Component { } }; + handleDeleteCategory = async (category) => { + try { + await this.props.deleteCategory(category.id).unwrap(); + enqueueSnackbar('Kategorie gelöscht.', { variant: 'success' }); + } catch (err) { + notifyError(err); + } + }; + handleRetryTree = () => { if (this.props.companyId) this.props.loadCategoryTree(); }; @@ -352,7 +436,7 @@ class ArtikelKategorienPage extends Component { )} - {childCategories.map((item) => { + {childCategories.map((item, index) => { const label = categoryDisplayName(item.name, item.id, rootCategoryId); if (renameMode) { const draftName = @@ -360,44 +444,46 @@ class ArtikelKategorienPage extends Component { ? this.state.drafts[item.id] : label; return ( - { - event.preventDefault(); - void this.handleRenameSubmit(item.id, this.state.drafts[item.id] ?? label); + label={draftName} + nameInput={ + this.handleDraftChange(item.id, e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + void this.handleRenameSubmit(item.id, draftName); + } + }} + variant="standard" + size="small" + slotProps={{ inputLabel: { shrink: true } }} + sx={{ + width: '100%', + '& .MuiInput-underline:before, & .MuiInput-underline:after': { + borderBottom: 'none', + }, + '& .MuiInputBase-input': { + textAlign: 'center', + fontWeight: 600, + py: '6px', + }, + }} + /> + } + editBand={{ + onMoveLeft: () => this.handleMoveCategory(item, -1), + onMoveRight: () => this.handleMoveCategory(item, 1), + onDelete: () => this.handleDeleteCategory(item), + canLeft: index > 0, + canRight: index < childCategories.length - 1, }} - sx={{ - ...tileBaseSx, - cursor: 'grab', - 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 ( @@ -458,6 +544,7 @@ const mapDispatchToProps = { loadCategoryArticles, navigateToCategory, renameCategory, + deleteCategory, reorderCategories, };