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