diff --git a/src/automats/categoryAutomat.js b/src/automats/categoryAutomat.js
index 4f719b8..58b0e99 100644
--- a/src/automats/categoryAutomat.js
+++ b/src/automats/categoryAutomat.js
@@ -239,6 +239,9 @@ const actions = {
* @param {{ categoryId: number|string, name: string }} param0
*/
async renameCategory({ categoryId, name }) {
+ if (String(categoryId) === String(categoryAutomat.state.rootCategoryId)) {
+ throw new Error('Home-Kategorie kann nicht umbenannt werden.');
+ }
const trimmed = String(name ?? '').trim();
if (!trimmed) throw new Error('Name required');
const res = await categoryApi.updateName(categoryId, trimmed);
@@ -258,15 +261,15 @@ const actions = {
/**
* Reorder sibling categories.
- * @param {{ categoryIds: number[] }} param0
+ * @param {{ parentId?: number|string, categoryIds: number[] }} param0
*/
- async reorderCategories({ categoryIds }) {
- const { currentCategoryId, categories } = categoryAutomat.state;
- const res = await categoryApi.reorder(currentCategoryId, categoryIds);
+ async reorderCategories({ parentId, categoryIds }) {
+ const targetParentId = parentId != null ? parentId : categoryAutomat.state.currentCategoryId;
+ const res = await categoryApi.reorder(targetParentId, categoryIds);
const ordered = res.categoryIds;
- const parentKey = String(currentCategoryId);
+ const parentKey = String(targetParentId);
const idToSort = new Map(ordered.map((id, index) => [String(id), index]));
- const patched = { ...categories };
+ const patched = { ...categoryAutomat.state.categories };
for (const node of Object.values(patched)) {
if (node.parentId != null && String(node.parentId) === parentKey && idToSort.has(String(node.id))) {
patched[String(node.id)] = { ...node, sort: idToSort.get(String(node.id)) };
diff --git a/src/components/article/CategoryPickerDialog.jsx b/src/components/article/CategoryPickerDialog.jsx
index 5d6712c..bc89045 100644
--- a/src/components/article/CategoryPickerDialog.jsx
+++ b/src/components/article/CategoryPickerDialog.jsx
@@ -1,158 +1,280 @@
-import { useState } from 'react';
+import { PureComponent } from 'react';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
-import List from '@mui/material/List';
-import ListItemButton from '@mui/material/ListItemButton';
-import ListItemText from '@mui/material/ListItemText';
-import Collapse from '@mui/material/Collapse';
-import ExpandLess from '@mui/icons-material/ExpandLess';
-import ExpandMore from '@mui/icons-material/ExpandMore';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import IconButton from '@mui/material/IconButton';
+import CloseIcon from '@mui/icons-material/Close';
import CheckIcon from '@mui/icons-material/Check';
import FolderIcon from '@mui/icons-material/Folder';
-import FolderOpenIcon from '@mui/icons-material/FolderOpen';
-import Box from '@mui/material/Box';
-import { getCategoryChildren, categoryDisplayName } from '../../utils/categoryUtils';
-function CategoryNode({
- categories,
- categoryId,
- rootCategoryId,
- selectedId,
- onSelect,
- expandedIds,
- onToggleExpand,
- depth = 0,
-}) {
- const node = categories[String(categoryId)];
- if (!node) return null;
+import Breadcrumbs from '@mui/material/Breadcrumbs';
+import NavigateNextIcon from '@mui/icons-material/NavigateNext';
- const children = getCategoryChildren(categories, categoryId);
- const hasChildren = children.length > 0;
- const isSelected = selectedId != null && String(selectedId) === String(categoryId);
- const isExpanded = expandedIds.has(categoryId);
- const label = categoryDisplayName(node.name, node.id, rootCategoryId);
+import CategoryTree from '../category/CategoryTree';
+import categoryAutomat from '../../automats/categoryAutomat';
+import { getCategoryBreadcrumbs, categoryDisplayName } from '../../utils/categoryUtils';
- return (
- <>
- onSelect(categoryId)}
- sx={{ pl: 1 + depth * 1.5 }}
- >
-
- {hasChildren ? (
- isExpanded ? :
- ) : null}
-
-
- {isSelected && }
- {hasChildren && (
- isExpanded ? (
- { e.stopPropagation(); onToggleExpand(categoryId, false); }} />
- ) : (
- { e.stopPropagation(); onToggleExpand(categoryId, true); }} />
- )
- )}
-
- {hasChildren && (
-
-
- {children.map((child) => (
-
- ))}
-
-
- )}
- >
- );
-}
+const EMPTY_ARRAY = [];
-function buildDefaultExpanded(categories, rootCategoryId, currentCategoryIds) {
- const ids = new Set();
- if (!categories || !rootCategoryId) return ids;
- for (const catId of currentCategoryIds ?? []) {
- let id = catId;
- while (id != null) {
- const node = categories[String(id)];
- if (!node) break;
- ids.add(id);
- if (node.parentId == null || node.parentId === 0) break;
- id = node.parentId;
+export default class CategoryPickerDialog extends PureComponent {
+ constructor(props) {
+ super(props);
+ const categories = props.categories ?? categoryAutomat.state.categories;
+ const rootCategoryId = props.rootCategoryId ?? categoryAutomat.state.rootCategoryId;
+ const currentCategoryIds = props.currentCategoryIds ?? EMPTY_ARRAY;
+
+ let selectedId = null;
+ if (props.selectedId != null) {
+ selectedId = props.selectedId;
+ } else if (currentCategoryIds.length > 0) {
+ selectedId = currentCategoryIds[0];
+ } else if (rootCategoryId != null) {
+ selectedId = rootCategoryId;
+ }
+
+ this.state = {
+ categories,
+ rootCategoryId,
+ selectedId,
+ };
+ }
+
+ componentDidMount() {
+ if (this.props.categories === undefined || this.props.rootCategoryId === undefined) {
+ this._unsub = categoryAutomat.subscribe((s) => {
+ this.setState((prev) => ({
+ categories: this.props.categories ?? s.categories,
+ rootCategoryId: this.props.rootCategoryId ?? s.rootCategoryId,
+ }));
+ });
}
}
- return ids;
-}
-export default function CategoryPickerDialog({
- open,
- onClose,
- categories,
- rootCategoryId,
- currentCategoryIds,
- onSelect,
-}) {
- const [selectedId, setSelectedId] = useState(null);
- const [expandedIds, setExpandedIds] = useState(
- () => buildDefaultExpanded(categories, rootCategoryId, currentCategoryIds),
- );
+ componentDidUpdate(prevProps) {
+ if (this.props.open && !prevProps.open) {
+ const currentCategoryIds = this.props.currentCategoryIds ?? EMPTY_ARRAY;
+ let nextSelectedId = null;
+ if (this.props.selectedId != null) {
+ nextSelectedId = this.props.selectedId;
+ } else if (currentCategoryIds.length > 0) {
+ nextSelectedId = currentCategoryIds[0];
+ } else if (this.state.rootCategoryId != null) {
+ nextSelectedId = this.state.rootCategoryId;
+ }
+ this.setState({ selectedId: nextSelectedId });
+ }
- const handleToggleExpand = (id, expand) => {
- setExpandedIds((prev) => {
- const next = new Set(prev);
- if (expand) next.add(id);
- else next.delete(id);
- return next;
- });
+ if (
+ this.props.categories !== prevProps.categories ||
+ this.props.rootCategoryId !== prevProps.rootCategoryId
+ ) {
+ this.setState({
+ categories: this.props.categories ?? categoryAutomat.state.categories,
+ rootCategoryId: this.props.rootCategoryId ?? categoryAutomat.state.rootCategoryId,
+ });
+ }
+ }
+
+ componentWillUnmount() {
+ this._unsub?.();
+ }
+
+ handleSelect = (id) => {
+ this.setState({ selectedId: id });
};
- const handleConfirm = () => {
- if (selectedId == null) return;
- const categoryIds = currentCategoryIds.filter((id) => String(id) !== String(selectedId));
- categoryIds.push(selectedId);
- onSelect?.(categoryIds);
- onClose();
+ handleDoubleClick = (id) => {
+ this.handleConfirmWithId(id);
};
- return (
-
- );
+
+ {/* Breadcrumb Path Preview */}
+ {breadcrumbs.length > 0 && (
+ }
+ sx={{ mt: 1, '& .MuiBreadcrumbs-li': { fontSize: '0.8rem' } }}
+ >
+ {breadcrumbs.map((bc, idx) => {
+ const isLast = idx === breadcrumbs.length - 1;
+ return (
+
+ {categoryDisplayName(bc.name, bc.id, rootCategoryId)}
+
+ );
+ })}
+
+ )}
+
+
+ {/* Tree Content */}
+
+
+
+
+ {/* Footer / Actions */}
+
+ {isAlreadyAssigned && (
+
+ (bereits zugewiesen)
+
+ )}
+
+
+
+ }
+ onClick={this.handleConfirm}
+ disabled={isSelectedDisabled}
+ >
+ {actionLabel}
+
+
+
+
+ );
+ }
}
diff --git a/src/components/article/NewArticleTile.jsx b/src/components/article/NewArticleTile.jsx
index 4575422..33ecc33 100644
--- a/src/components/article/NewArticleTile.jsx
+++ b/src/components/article/NewArticleTile.jsx
@@ -40,7 +40,7 @@ class NewArticleTile extends Component {
}
render() {
- const { parentId, disabled } = this.props;
+ const { parentId, disabled, sx } = this.props;
return (
<>
@@ -48,6 +48,7 @@ class NewArticleTile extends Component {
label="Neuer Artikel"
disabled={disabled || parentId == null}
onClick={this.handleOpen}
+ sx={sx}
/>
{
@@ -101,20 +101,52 @@ class CategoryPills extends Component {
onCategoryIdsChange?.(remaining);
};
- handleOpenPicker = () => {
- this.setState({ pickerOpen: true });
+ handleOpenAddPicker = () => {
+ this.setState({ pickerOpen: true, targetPillId: null });
+ };
+
+ handleOpenMovePicker = (pillId) => {
+ this.setState({ pickerOpen: true, targetPillId: pillId });
};
handleClosePicker = () => {
- this.setState({ pickerOpen: false });
+ this.setState({ pickerOpen: false, targetPillId: null });
+ };
+
+ handleCategorySelect = (selectedCategoryId) => {
+ const { categoryIds, onCategoryIdsChange } = this.props;
+ const { targetPillId } = this.state;
+
+ if (targetPillId != null) {
+ const updated = categoryIds.map((id) =>
+ String(id) === String(targetPillId) ? selectedCategoryId : id
+ );
+ const deduped = [...new Set(updated)];
+ onCategoryIdsChange?.(deduped);
+ } else {
+ if (!categoryIds.some((id) => String(id) === String(selectedCategoryId))) {
+ onCategoryIdsChange?.([...categoryIds, selectedCategoryId]);
+ }
+ }
};
render() {
- const { categoryIds, editMode, onCategoryIdsChange } = this.props;
- const { pickerOpen } = this.state;
+ const { categoryIds, editMode } = this.props;
+ const { pickerOpen, targetPillId } = this.state;
const { categories, rootCategoryId } = this.context.getCategoryState() ?? {};
const labels = categoryLabels(categoryIds, categories, rootCategoryId);
+ const targetNode = categories && targetPillId != null ? categories[String(targetPillId)] : null;
+ const targetLabel = targetNode
+ ? categoryDisplayName(targetNode.name, targetNode.id, rootCategoryId)
+ : '';
+
+ const dialogTitle =
+ targetPillId != null
+ ? (targetLabel ? `Artikel aus Kategorie „${targetLabel}“ verschieben in…` : 'Artikel aus Kategorie verschieben in…')
+ : 'Kategorie hinzufügen';
+ const confirmLabel = targetPillId != null ? 'Hierher verschieben' : 'Hinzufügen';
+
return (
<>
@@ -124,17 +156,28 @@ class CategoryPills extends Component {
label={label}
size="small"
variant="outlined"
+ onClick={editMode ? () => this.handleOpenMovePicker(id) : undefined}
onDelete={
editMode && categoryIds.length > 1
? () => this.handleRemoveCategory(id)
: undefined
}
+ sx={{
+ cursor: editMode ? 'pointer' : 'default',
+ ...(editMode
+ ? {
+ '&:hover': {
+ bgcolor: 'action.hover',
+ },
+ }
+ : {}),
+ }}
/>
))}
{editMode ? (
@@ -148,7 +191,11 @@ class CategoryPills extends Component {
categories={categories}
rootCategoryId={rootCategoryId}
currentCategoryIds={categoryIds}
- onSelect={onCategoryIdsChange}
+ selectedId={targetPillId ?? rootCategoryId}
+ title={dialogTitle}
+ confirmLabel={confirmLabel}
+ mode="single"
+ onSelectCategory={this.handleCategorySelect}
/>
) : null}
>
diff --git a/src/components/category/CategoryTree.jsx b/src/components/category/CategoryTree.jsx
new file mode 100644
index 0000000..f2f3c13
--- /dev/null
+++ b/src/components/category/CategoryTree.jsx
@@ -0,0 +1,796 @@
+import { PureComponent } from 'react';
+import Box from '@mui/material/Box';
+import List from '@mui/material/List';
+import ListItemButton from '@mui/material/ListItemButton';
+import ListItemIcon from '@mui/material/ListItemIcon';
+import ListItemText from '@mui/material/ListItemText';
+import IconButton from '@mui/material/IconButton';
+import TextField from '@mui/material/TextField';
+import InputAdornment from '@mui/material/InputAdornment';
+import Typography from '@mui/material/Typography';
+import Chip from '@mui/material/Chip';
+import Tooltip from '@mui/material/Tooltip';
+import Dialog from '@mui/material/Dialog';
+import DialogTitle from '@mui/material/DialogTitle';
+import DialogContent from '@mui/material/DialogContent';
+import DialogContentText from '@mui/material/DialogContentText';
+import DialogActions from '@mui/material/DialogActions';
+import Button from '@mui/material/Button';
+import Menu from '@mui/material/Menu';
+import MenuItem from '@mui/material/MenuItem';
+import FolderIcon from '@mui/icons-material/Folder';
+import AddIcon from '@mui/icons-material/Add';
+import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
+import EditIcon from '@mui/icons-material/Edit';
+import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
+import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
+import SearchIcon from '@mui/icons-material/Search';
+import ClearIcon from '@mui/icons-material/Clear';
+import MoreVertIcon from '@mui/icons-material/MoreVert';
+import CheckCircleIcon from '@mui/icons-material/CheckCircle';
+import { enqueueSnackbar } from 'notistack';
+
+import categoryAutomat from '../../automats/categoryAutomat';
+import { getCategoryChildren, categoryDisplayName } from '../../utils/categoryUtils';
+import { notifyError } from '../../utils/notify';
+
+const EMPTY_ARRAY = [];
+const EMPTY_SET = new Set();
+
+/** Helper: gather all ancestor IDs of matching categories */
+function getMatchingAndAncestorIds(categories, query, rootCategoryId, excludeIds = EMPTY_ARRAY) {
+ const matches = new Set();
+ if (!categories || !query.trim()) return matches;
+ const q = query.trim().toLowerCase();
+
+ for (const [idStr, node] of Object.entries(categories)) {
+ if (excludeIds.some((id) => String(id) === String(node.id))) continue;
+ const label = categoryDisplayName(node.name, node.id, rootCategoryId).toLowerCase();
+ if (label.includes(q)) {
+ matches.add(node.id);
+ let parentId = node.parentId;
+ while (parentId != null && parentId !== 0) {
+ if (excludeIds.some((id) => String(id) === String(parentId))) break;
+ matches.add(parentId);
+ const parent = categories[String(parentId)];
+ if (!parent || parent.parentId == null || parent.parentId === 0) break;
+ parentId = parent.parentId;
+ }
+ }
+ }
+ return matches;
+}
+
+/** Individual Category Tree Node - Always Expanded */
+class CategoryTreeNode extends PureComponent {
+ state = { menuAnchor: null };
+
+ handleOpenMenu = (e) => {
+ e.stopPropagation();
+ this.setState({ menuAnchor: e.currentTarget });
+ };
+
+ handleCloseMenu = () => {
+ this.setState({ menuAnchor: null });
+ };
+
+ handleSelect = () => {
+ const { categoryId, categories, onSelect, disabledIds = EMPTY_ARRAY } = this.props;
+ const isDisabled = disabledIds.some((id) => String(id) === String(categoryId));
+ if (!isDisabled) {
+ const node = categories?.[String(categoryId)];
+ onSelect?.(categoryId, node);
+ }
+ };
+
+ handleDoubleClick = () => {
+ const { categoryId, categories, onDoubleClick, disabledIds = EMPTY_ARRAY } = this.props;
+ const isDisabled = disabledIds.some((id) => String(id) === String(categoryId));
+ if (!isDisabled) {
+ const node = categories?.[String(categoryId)];
+ onDoubleClick?.(categoryId, node);
+ }
+ };
+
+ render() {
+ const {
+ categories,
+ categoryId,
+ rootCategoryId,
+ selectedId,
+ onSelect,
+ onDoubleClick,
+ searchMatches,
+ query,
+ depth = 0,
+ alreadyAssignedIds = EMPTY_ARRAY,
+ disabledIds = EMPTY_ARRAY,
+ excludeIds = EMPTY_ARRAY,
+ allowAdd = true,
+ allowDelete = true,
+ allowReorder = true,
+ allowRename = true,
+ onOpenAdd,
+ onOpenRename,
+ onOpenDelete,
+ onMoveSibling,
+ isFirstSibling = false,
+ isLastSibling = false,
+ renderItemBadge,
+ } = this.props;
+
+ if (excludeIds.some((id) => String(id) === String(categoryId))) {
+ return null;
+ }
+
+ const node = categories?.[String(categoryId)];
+ if (!node) return null;
+
+ const isRoot = rootCategoryId != null && String(categoryId) === String(rootCategoryId);
+ const children = getCategoryChildren(categories, categoryId).filter(
+ (c) => !excludeIds.some((id) => String(id) === String(c.id)),
+ );
+ const hasChildren = children.length > 0;
+ const isSelected = selectedId != null && String(selectedId) === String(categoryId);
+ const isAlreadyAssigned = alreadyAssignedIds.some((id) => String(id) === String(categoryId));
+ const isDisabled = disabledIds.some((id) => String(id) === String(categoryId));
+ const label = categoryDisplayName(node.name, node.id, rootCategoryId);
+
+ // If search filter is active and this node is not part of the matching set, skip
+ if (query && searchMatches && !searchMatches.has(categoryId)) {
+ return null;
+ }
+
+ const { menuAnchor } = this.state;
+
+ return (
+ <>
+
+ {/* Folder icon */}
+
+
+
+
+ {/* Label */}
+
+
+ {label}
+
+
+ {/* Child count chip */}
+ {hasChildren && (
+
+ {children.length}
+
+ )}
+
+ {/* Already assigned badge */}
+ {isAlreadyAssigned && (
+ }
+ label="Zugewiesen"
+ size="small"
+ color="success"
+ variant="outlined"
+ sx={{ height: 20, fontSize: '0.65rem', '& .MuiChip-label': { px: 0.6 } }}
+ />
+ )}
+
+ {/* Custom badge slot */}
+ {renderItemBadge?.(node)}
+
+ }
+ sx={{ my: 0 }}
+ />
+
+ {/* Action buttons (hover or selected) */}
+ e.stopPropagation()}
+ >
+ {/* Quick reorder buttons */}
+ {allowReorder && !isRoot && (
+ <>
+
+
+ onMoveSibling?.(node, 'up')}
+ sx={{ p: 0.3 }}
+ >
+
+
+
+
+
+
+ onMoveSibling?.(node, 'down')}
+ sx={{ p: 0.3 }}
+ >
+
+
+
+
+ >
+ )}
+
+ {/* Quick Add Subcategory button */}
+ {allowAdd && (
+
+ onOpenAdd?.(node)}
+ sx={{ p: 0.3 }}
+ >
+
+
+
+ )}
+
+ {/* More actions menu (not available on root category) */}
+ {!isRoot && (allowRename || allowDelete) && (
+
+
+
+ )}
+
+ {!isRoot && (
+
+ )}
+
+
+
+ {/* Child categories - always expanded */}
+ {hasChildren && (
+
+ {/* Subtle guide line on the left */}
+
+ {children.map((child, idx) => (
+
+ ))}
+
+ )}
+ >
+ );
+ }
+}
+
+/**
+ * Shared CategoryTree Component (PureComponent, zero hooks, always expanded)
+ */
+export default class CategoryTree extends PureComponent {
+ constructor(props) {
+ super(props);
+ const categories = props.categories ?? categoryAutomat.state.categories;
+ const rootCategoryId = props.rootCategoryId ?? categoryAutomat.state.rootCategoryId;
+
+ this.state = {
+ categories,
+ rootCategoryId,
+ searchQuery: '',
+ addDialogOpen: false,
+ addParentNode: null,
+ addCategoryName: '',
+ isSubmitting: false,
+ renameDialogOpen: false,
+ renameNode: null,
+ renameValue: '',
+ deleteDialogOpen: false,
+ deleteNode: null,
+ };
+ }
+
+ componentDidMount() {
+ if (this.props.categories === undefined || this.props.rootCategoryId === undefined) {
+ this._unsub = categoryAutomat.subscribe((s) => {
+ this.setState({
+ categories: this.props.categories ?? s.categories,
+ rootCategoryId: this.props.rootCategoryId ?? s.rootCategoryId,
+ });
+ });
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (
+ this.props.categories !== prevProps.categories ||
+ this.props.rootCategoryId !== prevProps.rootCategoryId
+ ) {
+ this.setState({
+ categories: this.props.categories ?? categoryAutomat.state.categories,
+ rootCategoryId: this.props.rootCategoryId ?? categoryAutomat.state.rootCategoryId,
+ });
+ }
+ }
+
+ componentWillUnmount() {
+ this._unsub?.();
+ }
+
+ // Dialog state: Add
+ handleOpenAdd = (parentNode) => {
+ const { rootCategoryId, categories } = this.state;
+ this.setState({
+ addParentNode: parentNode || (rootCategoryId ? categories?.[String(rootCategoryId)] : null),
+ addCategoryName: '',
+ addDialogOpen: true,
+ });
+ };
+
+ handleConfirmAdd = async () => {
+ const { addCategoryName, addParentNode } = this.state;
+ const name = addCategoryName.trim();
+ if (!name || !addParentNode) return;
+ this.setState({ isSubmitting: true });
+ try {
+ const created = await categoryAutomat.actions.createCategory({
+ parentId: addParentNode.id,
+ name,
+ });
+ enqueueSnackbar(`Kategorie „${name}“ angelegt.`, { variant: 'success' });
+ this.setState({ addDialogOpen: false });
+ this.props.onSelect?.(created.id, created);
+ } catch (err) {
+ notifyError(err);
+ } finally {
+ this.setState({ isSubmitting: false });
+ }
+ };
+
+ // Dialog state: Rename
+ handleOpenRename = (node) => {
+ const { rootCategoryId } = this.state;
+ if (!node || (rootCategoryId != null && String(node.id) === String(rootCategoryId))) return;
+ this.setState({
+ renameNode: node,
+ renameValue: node.name || '',
+ renameDialogOpen: true,
+ });
+ };
+
+ handleConfirmRename = async () => {
+ const { renameNode, renameValue } = this.state;
+ const name = renameValue.trim();
+ if (!name || !renameNode) return;
+ this.setState({ isSubmitting: true });
+ try {
+ await categoryAutomat.actions.renameCategory({
+ categoryId: renameNode.id,
+ name,
+ });
+ enqueueSnackbar(`Kategorie in „${name}“ umbenannt.`, { variant: 'success' });
+ this.setState({ renameDialogOpen: false });
+ } catch (err) {
+ notifyError(err);
+ } finally {
+ this.setState({ isSubmitting: false });
+ }
+ };
+
+ // Dialog state: Delete
+ handleOpenDelete = (node) => {
+ this.setState({
+ deleteNode: node,
+ deleteDialogOpen: true,
+ });
+ };
+
+ handleConfirmDelete = async () => {
+ const { deleteNode, rootCategoryId } = this.state;
+ if (!deleteNode) return;
+ this.setState({ isSubmitting: true });
+ try {
+ await categoryAutomat.actions.deleteCategory(deleteNode.id);
+ enqueueSnackbar(`Kategorie „${deleteNode.name}“ gelöscht.`, { variant: 'success' });
+ if (this.props.selectedId != null && String(this.props.selectedId) === String(deleteNode.id)) {
+ this.props.onSelect?.(deleteNode.parentId ?? rootCategoryId);
+ }
+ this.setState({ deleteDialogOpen: false });
+ } catch (err) {
+ notifyError(err);
+ } finally {
+ this.setState({ isSubmitting: false });
+ }
+ };
+
+ // Reordering siblings (up/down)
+ handleMoveSibling = async (node, direction) => {
+ const { categories } = this.state;
+ if (!categories || node.parentId == null) return;
+ const siblings = getCategoryChildren(categories, node.parentId);
+ const currentIndex = siblings.findIndex((s) => String(s.id) === String(node.id));
+ if (currentIndex === -1) return;
+
+ const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
+ if (targetIndex < 0 || targetIndex >= siblings.length) return;
+
+ const reordered = [...siblings];
+ const [moved] = reordered.splice(currentIndex, 1);
+ reordered.splice(targetIndex, 0, moved);
+
+ const categoryIds = reordered.map((s) => s.id);
+ try {
+ await categoryAutomat.actions.reorderCategories({
+ parentId: node.parentId,
+ categoryIds,
+ });
+ } catch (err) {
+ notifyError(err);
+ }
+ };
+
+ render() {
+ const {
+ selectedId,
+ onSelect,
+ onDoubleClick,
+ alreadyAssignedIds = EMPTY_ARRAY,
+ disabledIds = EMPTY_ARRAY,
+ excludeIds = EMPTY_ARRAY,
+ allowAdd = true,
+ allowDelete = true,
+ allowReorder = true,
+ allowRename = true,
+ showSearch = true,
+ renderItemBadge,
+ sx = {},
+ } = this.props;
+
+ const {
+ categories,
+ rootCategoryId,
+ searchQuery,
+ addDialogOpen,
+ addParentNode,
+ addCategoryName,
+ isSubmitting,
+ renameDialogOpen,
+ renameNode,
+ renameValue,
+ deleteDialogOpen,
+ deleteNode,
+ } = this.state;
+
+ const searchMatches = searchQuery.trim()
+ ? getMatchingAndAncestorIds(categories, searchQuery, rootCategoryId, excludeIds)
+ : EMPTY_SET;
+
+ return (
+
+ {/* Search */}
+ {showSearch && (
+
+ this.setState({ searchQuery: e.target.value })}
+ fullWidth
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ endAdornment: searchQuery ? (
+
+ this.setState({ searchQuery: '' })}>
+
+
+
+ ) : null,
+ }}
+ sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
+ />
+
+ )}
+
+ {/* Tree Content - Always Expanded */}
+
+ {categories && rootCategoryId != null ? (
+
+
+
+ ) : (
+
+ Lade Kategorien…
+
+ )}
+
+
+ {/* Add Dialog */}
+ !isSubmitting && this.setState({ addDialogOpen: false })}
+ maxWidth="xs"
+ fullWidth
+ >
+ Neue Unterkategorie anlegen
+
+
+ Unterkategorie wird erstellt in:{' '}
+
+ {addParentNode
+ ? categoryDisplayName(addParentNode.name, addParentNode.id, rootCategoryId)
+ : 'Home'}
+
+
+ this.setState({ addCategoryName: e.target.value })}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && addCategoryName.trim() && !isSubmitting) {
+ e.preventDefault();
+ void this.handleConfirmAdd();
+ }
+ }}
+ fullWidth
+ size="small"
+ />
+
+
+
+
+
+
+
+ {/* Rename Dialog */}
+ !isSubmitting && this.setState({ renameDialogOpen: false })}
+ maxWidth="xs"
+ fullWidth
+ >
+ Kategorie umbenennen
+
+ this.setState({ renameValue: e.target.value })}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && renameValue.trim() && !isSubmitting) {
+ e.preventDefault();
+ void this.handleConfirmRename();
+ }
+ }}
+ fullWidth
+ size="small"
+ sx={{ mt: 1 }}
+ />
+
+
+
+
+
+
+
+ {/* Delete Confirmation Dialog */}
+ !isSubmitting && this.setState({ deleteDialogOpen: false })}
+ maxWidth="xs"
+ fullWidth
+ >
+ Kategorie löschen?
+
+
+ Möchten Sie die Kategorie „{deleteNode?.name}“ wirklich löschen?
+ {deleteNode &&
+ getCategoryChildren(categories, deleteNode.id).length > 0 &&
+ ' Alle Unterkategorien und darin enthaltenen Artikelzuweisungen werden ebenfalls entfernt.'}
+
+
+
+
+
+
+
+
+ );
+ }
+}
diff --git a/src/components/category/index.js b/src/components/category/index.js
index 637eb84..d6df946 100644
--- a/src/components/category/index.js
+++ b/src/components/category/index.js
@@ -2,3 +2,5 @@ export { default as CategoryNameDialog } from './CategoryNameDialog';
export { default as CategoryBreadcrumbs } from './CategoryBreadcrumbs';
export { default as NewCategoryTile } from './NewCategoryTile';
export { default as EditCategoryDialog } from './EditCategoryDialog';
+export { default as CategoryTree } from './CategoryTree';
+export { default as CategoryPickerDialog } from '../article/CategoryPickerDialog';
diff --git a/src/components/tiles/CategoryTile.jsx b/src/components/tiles/CategoryTile.jsx
index 6f5c1dc..9f34abd 100644
--- a/src/components/tiles/CategoryTile.jsx
+++ b/src/components/tiles/CategoryTile.jsx
@@ -5,9 +5,6 @@ import IconButton from '@mui/material/IconButton';
import Switch from '@mui/material/Switch';
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 SettingsIcon from '@mui/icons-material/Settings';
import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
import PhotoCameraIcon from '@mui/icons-material/PhotoCamera';
@@ -20,7 +17,7 @@ import { tileBaseSx } from 'dashgui/tiles';
* Icons are pinned to exact thirds (left / center / right) so they stay
* evenly spread regardless of icon size or button padding.
*/
-function TopBand({ onSettingsClick, settingsTab, terminalCount }) {
+function TopBand({ onSettingsClick, settingsTab, terminalCount, onPlacementClick }) {
const isTerminalMode = settingsTab >= 0 && settingsTab < terminalCount;
const isPhotoMode = settingsTab === -2;
@@ -76,14 +73,16 @@ function TopBand({ onSettingsClick, settingsTab, terminalCount }) {
)}
{
+ e.stopPropagation();
+ onPlacementClick?.();
+ }}
sx={{
...handleSx,
right: 0,
- cursor: 'grab',
- '&:active': { cursor: 'grabbing' },
+ cursor: 'pointer',
}}
>
@@ -156,83 +155,6 @@ function MiddleBand({ settingsTab, nameInput, terminals, categoryPosterminalIds,
);
}
-/**
- * Bottom row (1/3): split into two sub-rows (each 1/6 of tile).
- * Upper: click zone → navigate into category.
- * Lower: gray band — left chevron | delete | right chevron.
- */
-function BottomRow({ onOpen, editBand }) {
- return (
-
- {/* Upper half: click-through to open category */}
- {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- onOpen?.();
- }
- }}
- sx={{
- flex: 1,
- cursor: 'pointer',
- '&:hover': { bgcolor: 'action.hover' },
- }}
- />
-
- {/* Lower half: reorder & delete */}
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
/**
* Renders a ThumbHash preview and the full thumbnail as backdrop for a category tile.
@@ -373,13 +295,13 @@ export class CategoryTile extends Component {
fullHash,
onClick,
nameInput,
- editBand,
- onOpen,
terminals,
categoryPosterminalIds,
onTerminalToggle,
onPhotoUpload,
onPhotoDelete,
+ onPlacementClick,
+ onOpen,
...rest
} = this.props;
const { settingsTab } = this.state;
@@ -396,6 +318,7 @@ export class CategoryTile extends Component {
onSettingsClick={this.handleSettingsToggle}
settingsTab={settingsTab}
terminalCount={terminals?.length || 0}
+ onPlacementClick={onPlacementClick}
/>
-
+ {/* Lower third: clickable in edit mode to open/navigate into category */}
+ {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ (onOpen || onClick)?.();
+ }
+ }}
+ sx={{
+ position: 'absolute',
+ bottom: 0,
+ left: 0,
+ right: 0,
+ height: '33.33%',
+ cursor: 'pointer',
+ '&:hover': {
+ bgcolor: 'action.hover',
+ },
+ }}
+ />
>
) : (
this.renderLabel()
diff --git a/src/index.css b/src/index.css
index a43b96d..edb809c 100644
--- a/src/index.css
+++ b/src/index.css
@@ -28,32 +28,3 @@ html {
cursor: grabbing !important;
user-select: none !important;
}
-
-/* Shopify Droppable: aktuell anvisierte Ziel-Kachel hervorheben */
-.tree-drop-target {
- outline: 3px solid #1976d2;
- outline-offset: -3px;
- box-shadow: 0 0 0 4px rgba(25, 118, 210, 0.35);
-}
-
-/* Während des Tree-Draggings keine Hover-/Textselektions-Effekte auf den
- Ziel-Kacheln: interaktive Kinder (Buttons, Inputs) vom Maus-Event abkoppeln,
- damit keine :hover ausgelöst wird und der Cursor durchgängig 'grabbing' bleibt. */
-body.tree-dragging {
- user-select: none;
- -webkit-user-select: none;
- cursor: grabbing;
-}
-
-body.tree-dragging,
-body.tree-dragging * {
- cursor: grabbing !important;
-}
-
-body.tree-dragging button,
-body.tree-dragging input,
-body.tree-dragging textarea,
-body.tree-dragging .MuiButtonBase-root,
-body.tree-dragging .MuiInputBase-root {
- pointer-events: none !important;
-}
diff --git a/src/pages/artikel/CategoryDroppable.js b/src/pages/artikel/CategoryDroppable.js
deleted file mode 100644
index 3409ed7..0000000
--- a/src/pages/artikel/CategoryDroppable.js
+++ /dev/null
@@ -1,113 +0,0 @@
-import { Droppable } from '@shopify/draggable';
-import { notifyError } from '../../utils/notify';
-
-/**
- * Encapsulates @shopify/draggable Droppable logic for moving a category
- * into another category (tree drag-and-drop).
- *
- * Usage:
- * this.droppable = new CategoryDroppable(containerEl, { onMove });
- * this.droppable.activate();
- * this.droppable.destroy();
- */
-export default class CategoryDroppable {
- #instance = null;
- #container = null;
- #onMove = null;
- #pendingDropId = null;
- #dropTargetEl = null;
-
- constructor(container, { onMove }) {
- this.#container = container;
- this.#onMove = onMove;
- }
-
- activate() {
- const el = this.#container;
- if (!el || this.#instance) return;
-
- this.#pendingDropId = null;
- this.#instance = new Droppable(el, {
- draggable: '.sortable-item',
- dropzone: '.sortable-item',
- handle: '.droppable-handle',
- delay: 0,
- mirror: { constrainDimensions: true },
- classes: { 'droppable:active': '', 'droppable:occupied': '' },
- });
- this.#instance.on('drag:start', this.#handleTreeDragStart);
- this.#instance.on('drag:over', this.#handleDropOver);
- this.#instance.on('drag:out', this.#handleDropOut);
- this.#instance.on('droppable:dropped', this.#handleDropped);
- this.#instance.on('droppable:returned', this.#handleDroppedReturned);
- this.#instance.on('drag:stop', this.#handleDragStop);
- }
-
- destroy() {
- if (this.#instance) {
- this.#instance.destroy();
- this.#instance = null;
- }
- this.#pendingDropId = null;
- this.#clearDropTarget();
- document.body.classList.remove('tree-dragging');
- }
-
- // --- private ---
-
- #handleTreeDragStart = () => {
- document.body.classList.add('tree-dragging');
- };
-
- #handleDropped = (event) => {
- event.cancel();
- };
-
- #handleDropOver = (event) => {
- const tile =
- event?.over && typeof event.over.closest === 'function'
- ? event.over.closest('.sortable-item')
- : null;
- if (!tile) return;
- 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();
- };
-
- #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.#onMove({ categoryId, parentId: targetId })
- .unwrap()
- .catch(notifyError);
- };
-
- #clearDropTarget = () => {
- if (this.#dropTargetEl) {
- this.#dropTargetEl.classList.remove('tree-drop-target');
- this.#dropTargetEl = null;
- }
- };
-}
diff --git a/src/pages/artikel/kategorien.jsx b/src/pages/artikel/kategorien.jsx
index a94f08f..05832bd 100644
--- a/src/pages/artikel/kategorien.jsx
+++ b/src/pages/artikel/kategorien.jsx
@@ -6,7 +6,7 @@ import Typography from '@mui/material/Typography';
import { enqueueSnackbar } from 'notistack';
import BackendError from '../../components/BackendError';
import CategorySortable from './CategorySortable';
-import CategoryDroppable from './CategoryDroppable';
+import CategoryPickerDialog from '../../components/article/CategoryPickerDialog';
import { NewCategoryTile, CategoryBreadcrumbs } from '../../components/category';
import { NewArticleTile } from '../../components/article';
import { notifyError } from '../../utils/notify';
@@ -28,7 +28,12 @@ import categoryAutomat from '../../automats/categoryAutomat';
import posterminalAutomat from '../../automats/posterminalAutomat';
import companyAutomat from '../../automats/companyAutomat';
import { setLastArticleSku } from '../../automats';
-import { categoryDisplayName, getCategoryBreadcrumbs, getCategoryChildren } from '../../utils/categoryUtils';
+import {
+ categoryDisplayName,
+ getCategoryBreadcrumbs,
+ getCategoryChildren,
+ getCategoryDescendantIds,
+} from '../../utils/categoryUtils';
// ─── Helper selectors for this page ────────────────────────────────────────────
function selectFromCategory(s) {
@@ -76,10 +81,10 @@ class ArtikelKategorienPage extends PureComponent {
...selectFromCategory(categoryAutomat.state),
...selectTerminals(posterminalAutomat.state),
companyId: companyAutomat.state.current?.id ?? null,
+ moveCategory: null,
};
this.gridRef = createRef();
this.sortableModule = null;
- this.droppableModule = null;
this.handleRenameSubmit = this.handleRenameSubmit.bind(this);
this.handleDraftChange = this.handleDraftChange.bind(this);
}
@@ -96,7 +101,6 @@ class ArtikelKategorienPage extends PureComponent {
this.syncCategoryFromUrl();
this.ensureArticlesLoaded();
this.syncSortable();
- this.syncDroppable();
}
componentDidUpdate(prevState) {
@@ -121,7 +125,6 @@ class ArtikelKategorienPage extends PureComponent {
}
if (this.state.renameMode !== prevState.renameMode) {
this.syncSortable();
- this.syncDroppable();
}
}
@@ -130,7 +133,6 @@ class ArtikelKategorienPage extends PureComponent {
this._unsubTerm?.();
this._unsubCompany?.();
this.sortableModule?.destroy();
- this.droppableModule?.destroy();
}
#ensureModules = () => {
@@ -142,12 +144,6 @@ class ArtikelKategorienPage extends PureComponent {
categoryAutomat.actions.reorderCategories({ categoryIds }),
});
}
- if (!this.droppableModule) {
- this.droppableModule = new CategoryDroppable(el, {
- onMove: ({ categoryId, parentId }) =>
- categoryAutomat.actions.moveCategory({ categoryId, parentId }),
- });
- }
};
syncSortable = () => {
@@ -160,20 +156,6 @@ class ArtikelKategorienPage extends PureComponent {
}
};
- syncDroppable = () => {
- this.#ensureModules();
- if (this.state.renameMode) {
- this.droppableModule?.destroy();
- this.droppableModule?.activate();
- } else {
- this.droppableModule?.destroy();
- }
- };
-
- handleMoveCategory = (category, direction) => {
- this.sortableModule?.handleMoveCategory(category.id, direction);
- };
-
ensureTreeLoaded = () => {
const { companyId, treeStatus } = this.state;
if (!companyId || treeStatus !== 'idle') return;
@@ -252,10 +234,28 @@ class ArtikelKategorienPage extends PureComponent {
}
};
- handleDeleteCategory = async (category) => {
+ handleOpenMoveCategory = (category) => {
+ this.setState({ moveCategory: category });
+ };
+
+ handleCloseMoveCategory = () => {
+ this.setState({ moveCategory: null });
+ };
+
+ handleConfirmMoveCategory = async (targetParentId) => {
+ const { moveCategory } = this.state;
+ if (!moveCategory || targetParentId == null) return;
+ if (String(targetParentId) === String(moveCategory.parentId)) {
+ this.setState({ moveCategory: null });
+ return;
+ }
try {
- await categoryAutomat.actions.deleteCategory(category.id);
- enqueueSnackbar('Kategorie gelöscht.', { variant: 'success' });
+ await categoryAutomat.actions.moveCategory({
+ categoryId: moveCategory.id,
+ parentId: targetParentId,
+ });
+ enqueueSnackbar(`Kategorie „${moveCategory.name}“ verschoben.`, { variant: 'success' });
+ this.setState({ moveCategory: null });
} catch (err) {
notifyError(err);
}
@@ -286,6 +286,7 @@ class ArtikelKategorienPage extends PureComponent {
render() {
const {
companyId,
+ categories,
rootCategoryId,
treeStatus,
articlesStatus,
@@ -298,6 +299,7 @@ class ArtikelKategorienPage extends PureComponent {
renameMode,
terminals,
drafts,
+ moveCategory,
} = this.state;
if (!companyId) {
@@ -387,6 +389,7 @@ class ArtikelKategorienPage extends PureComponent {
})
.catch(notifyError);
},
+ onPlacementClick: () => this.handleOpenMoveCategory(item),
onOpen: () => this.navigateToCategoryById(item.id),
onPhotoUpload: () => {
const input = document.createElement('input');
@@ -439,13 +442,6 @@ class ArtikelKategorienPage extends PureComponent {
}}
/>
),
- editBand: {
- onMoveLeft: () => this.handleMoveCategory(item, -1),
- onMoveRight: () => this.handleMoveCategory(item, 1),
- onDelete: () => this.handleDeleteCategory(item),
- canLeft: index > 0,
- canRight: index < childCategories.length - 1,
- },
}
: {
label,
@@ -462,13 +458,14 @@ class ArtikelKategorienPage extends PureComponent {
);
})}
- {!renameMode && articles.map((item) => (
+ {!renameMode && articles.map((item, index) => (
this.handleArticleClick(item)}
+ sx={index === 0 ? { gridColumnStart: 1 } : undefined}
/>
))}
@@ -477,6 +474,7 @@ class ArtikelKategorienPage extends PureComponent {
)}
@@ -486,6 +484,21 @@ class ArtikelKategorienPage extends PureComponent {
)}
+
+ {moveCategory && (
+
+ )}
>
);
}
diff --git a/src/utils/categoryUtils.js b/src/utils/categoryUtils.js
index f84d3a3..3d9a8cf 100644
--- a/src/utils/categoryUtils.js
+++ b/src/utils/categoryUtils.js
@@ -109,3 +109,27 @@ export function getCategoryBreadcrumbs(categories, currentCategoryId, rootCatego
export function normalizeCategoryMap(data, companyRootCategoryId) {
return parseCategoryTreeResponse(data, companyRootCategoryId).categories;
}
+
+/**
+ * Returns an array containing the category id and all of its recursive descendant ids.
+ * Useful for preventing cyclic moves.
+ * @param {object} categories - id -> node map
+ * @param {number|string} categoryId - Target category id
+ * @returns {number[]} Array of category ids
+ */
+export function getCategoryDescendantIds(categories, categoryId) {
+ const result = [];
+ if (!categories || categoryId == null) return result;
+ const numId = Number(categoryId);
+ result.push(numId);
+ const queue = [numId];
+ while (queue.length > 0) {
+ const currentId = queue.shift();
+ const children = getCategoryChildren(categories, currentId);
+ for (const child of children) {
+ result.push(Number(child.id));
+ queue.push(Number(child.id));
+ }
+ }
+ return result;
+}