diff --git a/TODO.md b/TODO.md
index f16e73d..b54ffcc 100644
--- a/TODO.md
+++ b/TODO.md
@@ -15,7 +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 } }` |
+| **Kategorie verschieben** (Frontend fertig, Backend-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 }`.
diff --git a/package-lock.json b/package-lock.json
index a02c1ba..4335f79 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,6 +15,7 @@
"@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0",
"@reduxjs/toolkit": "^2.12.0",
+ "@shopify/draggable": "^1.2.1",
"notistack": "^3.0.2",
"react": "^19.2.8",
"react-dom": "^19.2.8",
@@ -2936,6 +2937,12 @@
"win32"
]
},
+ "node_modules/@shopify/draggable": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@shopify/draggable/-/draggable-1.2.1.tgz",
+ "integrity": "sha512-hYKhd99nl1mkO4ybA8i3rxvZgYS2A4Imcxpq7BoUu7h4LmFt997YxHLy8Mm5J7ST+G4DTQXqBsTx9mDid6piZg==",
+ "license": "MIT"
+ },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
diff --git a/package.json b/package.json
index 7e663c7..de50bae 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0",
"@reduxjs/toolkit": "^2.12.0",
+ "@shopify/draggable": "^1.2.1",
"notistack": "^3.0.2",
"react": "^19.2.8",
"react-dom": "^19.2.8",
diff --git a/src/api/index.js b/src/api/index.js
index c9ab62e..985c254 100644
--- a/src/api/index.js
+++ b/src/api/index.js
@@ -359,6 +359,18 @@ export const category = {
body: { parentId, categoryIds },
});
},
+
+ /**
+ * PATCH /api/category/:id [VORSCHLAG — Route/response fehlt noch in dashback]
+ * Body: { parentId } — moves the category under a new parent.
+ * → 200 { category: { id, parentId, name, slug } }
+ */
+ move(categoryId, parentId) {
+ return http(`/api/category/${categoryId}`, {
+ method: 'PATCH',
+ body: { parentId },
+ });
+ },
};
/**
diff --git a/src/components/tiles/CategoryTile.jsx b/src/components/tiles/CategoryTile.jsx
index 0dc6106..af2541b 100644
--- a/src/components/tiles/CategoryTile.jsx
+++ b/src/components/tiles/CategoryTile.jsx
@@ -10,8 +10,8 @@ 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.
+ * Top edit band: left = reorder handle (SortableJS), right = drop-into-subcategory
+ * handle (Shopify Draggable Droppable).
*/
function TopBand() {
return (
@@ -37,7 +37,7 @@ function TopBand() {
{
@@ -114,15 +121,117 @@ class ArtikelKategorienPage extends Component {
}
};
+ syncDroppable = () => {
+ if (this.props.renameMode) {
+ this.destroyDroppable();
+ this.activateDroppable();
+ } else {
+ this.destroyDroppable();
+ }
+ };
+
+ activateDroppable = () => {
+ const el = this.gridRef.current;
+ if (!el || this.droppable) return;
+ this.pendingDropId = null;
+ this.droppable = new Droppable(el, {
+ draggable: '.sortable-item',
+ dropzone: '.sortable-item',
+ handle: '.droppable-handle',
+ delay: 0,
+ mirror: { constrainDimensions: true },
+ // Droppable markiert sonst alle Dropzones gleichzeitig mit der
+ // --active-Klasse. Das deaktivieren wir und heben stattdessen nur das
+ // aktuell anvisierte Ziel über tree-drop-target hervor.
+ classes: { 'droppable:active': '', 'droppable:occupied': '' },
+ });
+ this.droppable.on('drag:start', this.handleTreeDragStart);
+ this.droppable.on('drag:over', this.handleDropOver);
+ this.droppable.on('drag:out', this.handleDropOut);
+ this.droppable.on('droppable:dropped', this.handleDropped);
+ this.droppable.on('droppable:returned', this.handleDroppedReturned);
+ this.droppable.on('drag:stop', this.handleDragStop);
+ };
+
+ handleTreeDragStart = () => {
+ // Während des Tree-Draggings native Textselektion unterbinden, damit der
+ // Cursor kein Text-I-Cursor wird und Textareas keine Hover-Selektion zeigen.
+ document.body.classList.add('tree-dragging');
+ };
+
+ destroyDroppable = () => {
+ if (this.droppable) {
+ this.droppable.destroy();
+ this.droppable = null;
+ }
+ this.pendingDropId = null;
+ this.clearDropTarget();
+ document.body.classList.remove('tree-dragging');
+ };
+
+ handleDropped = (event) => {
+ // Reiner DOM-Schutz: verhindert, dass Droppable das Quell-Element per
+ // appendChild in die Dropzone verschiebt. Das Ziel-Tracking läuft über
+ // drag:over/drag:out (pendingDropId), nicht über dieses Event.
+ event.cancel();
+ };
+
+ handleDropOver = (event) => {
+ const tile =
+ event?.over && typeof event.over.closest === 'function'
+ ? event.over.closest('.sortable-item')
+ : null;
+ if (!tile) return;
+ // Die gezogene Kachel selbst ist kein gültiges Drop-Ziel.
+ const sourceId = Number(event?.source?.dataset?.id);
+ const targetId = Number(tile.dataset.id);
+ if (sourceId && targetId === sourceId) {
+ this.pendingDropId = null;
+ return;
+ }
+ this.clearDropTarget();
+ this.pendingDropId = targetId;
+ this.dropTargetEl = tile;
+ tile.classList.add('tree-drop-target');
+ };
+
+ handleDropOut = () => {
+ this.pendingDropId = null;
+ this.clearDropTarget();
+ };
+
+ clearDropTarget = () => {
+ if (this.dropTargetEl) {
+ this.dropTargetEl.classList.remove('tree-drop-target');
+ this.dropTargetEl = null;
+ }
+ };
+
+ handleDroppedReturned = (event) => {
+ this.pendingDropId = null;
+ this.clearDropTarget();
+ event.cancel();
+ };
+
+ handleDragStop = (event) => {
+ document.body.classList.remove('tree-dragging');
+ const categoryId = Number(event?.source?.dataset?.id);
+ const targetId = this.pendingDropId;
+ this.pendingDropId = null;
+ this.clearDropTarget();
+ if (!categoryId || !targetId || categoryId === targetId) return;
+ void this.props
+ .moveCategory({ categoryId, parentId: targetId })
+ .unwrap()
+ .catch(notifyError);
+ };
+
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',
+ handle: '.sortable-handle',
animation: 150,
ghostClass: 'sortable-ghost',
dragClass: 'sortable-drag',
@@ -133,10 +242,6 @@ class ArtikelKategorienPage extends Component {
};
destroySortable = () => {
- const el = this.gridRef.current;
- if (el) {
- el.removeEventListener('mousedown', this.handleGridMouseDownCapture, true);
- }
if (this.sortable) {
this.sortable.destroy();
this.sortable = null;
@@ -153,50 +258,15 @@ class ArtikelKategorienPage extends Component {
.map(Number);
};
- 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');
- }
+ handleSortStart = () => {
// Snapshot der Reihenfolge vor dem ersten Drag.
this.checkpointOrder = this.sortableIds();
};
handleSort = () => {
- // 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 ids = this.sortableIds();
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 ||
@@ -212,24 +282,9 @@ 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);
@@ -545,6 +600,7 @@ const mapDispatchToProps = {
navigateToCategory,
renameCategory,
deleteCategory,
+ moveCategory,
reorderCategories,
};
diff --git a/src/store/categorySlice.js b/src/store/categorySlice.js
index 7898bcc..10f0e69 100644
--- a/src/store/categorySlice.js
+++ b/src/store/categorySlice.js
@@ -189,6 +189,24 @@ export const reorderCategories = createAsyncThunk(
},
);
+/**
+ * Move a category under a new parent (drop into subcategory).
+ * Backend: PATCH /api/category/:id Body: { parentId } → { category }.
+ * (Vorschlag — Route fehlt noch in dashback; siehe TODO.md.)
+ */
+export const moveCategory = createAsyncThunk(
+ 'category/move',
+ async ({ categoryId, parentId }, { rejectWithValue }) => {
+ try {
+ const data = await category.move(categoryId, parentId);
+ 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 }) => {
@@ -350,6 +368,14 @@ const categorySlice = createSlice({
}
}
})
+ .addCase(moveCategory.fulfilled, (state, action) => {
+ const moved = action.payload;
+ if (moved == null || state.categories == null) return;
+ state.categories[String(moved.id)] = {
+ ...(state.categories[String(moved.id)] ?? {}),
+ ...moved,
+ };
+ })
.addCase(createArticle.fulfilled, (state, action) => {
const created = action.payload;
if (created == null) return;