From 49057dc0d6da6df7f6defeb33820b3ab8dd5b68c Mon Sep 17 00:00:00 2001 From: seb Date: Tue, 22 Sep 2026 13:45:22 +0200 Subject: [PATCH] u --- src/automats/categoryAutomat.js | 15 +- .../article/CategoryPickerDialog.jsx | 398 ++++++--- src/components/article/NewArticleTile.jsx | 3 +- .../article/boxes/ArticleViewName.jsx | 65 +- src/components/category/CategoryTree.jsx | 796 ++++++++++++++++++ src/components/category/index.js | 2 + src/components/tiles/CategoryTile.jsx | 122 +-- src/index.css | 29 - src/pages/artikel/CategoryDroppable.js | 113 --- src/pages/artikel/kategorien.jsx | 87 +- src/utils/categoryUtils.js | 24 + 11 files changed, 1233 insertions(+), 421 deletions(-) create mode 100644 src/components/category/CategoryTree.jsx delete mode 100644 src/pages/artikel/CategoryDroppable.js 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 ( - - Kategorie zuweisen - - {categories && rootCategoryId != null ? ( - - - - ) : ( - - Lade Kategorien… + handleConfirmWithId = (targetId) => { + if (targetId == null) return; + const { disabledIds = EMPTY_ARRAY, excludeIds = EMPTY_ARRAY, mode = 'assign', onSelect, onSelectCategory, onClose } = this.props; + if (disabledIds.some((id) => String(id) === String(targetId))) return; + if (excludeIds.some((id) => String(id) === String(targetId))) return; + + const { categories } = this.state; + const targetNode = categories ? categories[String(targetId)] : null; + if (mode === 'single') { + onSelectCategory?.(targetId, targetNode); + onSelect?.(targetId, targetNode); + } else { + const currentCategoryIds = this.props.currentCategoryIds ?? EMPTY_ARRAY; + const ids = currentCategoryIds.filter((id) => String(id) !== String(targetId)); + ids.push(targetId); + onSelectCategory?.(targetId, targetNode); + onSelect?.(ids); + } + onClose?.(); + }; + + handleConfirm = () => { + this.handleConfirmWithId(this.state.selectedId); + }; + + render() { + const { + open, + onClose, + currentCategoryIds = EMPTY_ARRAY, + title, + confirmLabel, + mode = 'assign', + disabledIds = EMPTY_ARRAY, + excludeIds = EMPTY_ARRAY, + } = this.props; + + const { categories, rootCategoryId, selectedId } = this.state; + + const dialogTitle = title || (mode === 'single' ? 'Kategorie auswählen' : 'Kategorie zuweisen'); + const actionLabel = confirmLabel || (mode === 'single' ? 'Auswählen' : 'Zuweisen'); + + const selectedNode = categories && selectedId != null ? categories[String(selectedId)] : null; + const isAlreadyAssigned = + mode !== 'single' && currentCategoryIds.some((id) => String(id) === String(selectedId)); + const isSelectedDisabled = + selectedId == null || + disabledIds.some((id) => String(id) === String(selectedId)) || + excludeIds.some((id) => String(id) === String(selectedId)); + + const breadcrumbs = + categories && selectedId != null + ? getCategoryBreadcrumbs(categories, selectedId, rootCategoryId) + : EMPTY_ARRAY; + + return ( + + {/* Header */} + + + + + + {dialogTitle} + + + + + - )} - - - - - - - ); + + {/* 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) + + )} + + + + + + + + ); + } } 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 && ( + e.stopPropagation()} + > + {allowRename && ( + { + this.handleCloseMenu(); + onOpenRename?.(node); + }} + > + + + + Umbenennen + + )} + {allowDelete && ( + { + this.handleCloseMenu(); + onOpenDelete?.(node); + }} + sx={{ color: 'error.main' }} + > + + + + Löschen + + )} + + )} + + + + {/* 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; +}