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 AppDialog from '../AppDialog'; 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 node of Object.values(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() { this._unsub = categoryAutomat.subscribe((s) => { this.setState({ categories: s.categories, rootCategoryId: s.rootCategoryId ?? this.props.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' }); const nextCategories = categoryAutomat.state.categories ?? { ...(this.state.categories || {}), [String(created.id)]: created, }; this.setState({ addDialogOpen: false, categories: nextCategories, }); 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 { const updated = await categoryAutomat.actions.renameCategory({ categoryId: renameNode.id, name, }); enqueueSnackbar(`Kategorie in „${name}“ umbenannt.`, { variant: 'success' }); const nextCategories = categoryAutomat.state.categories ?? { ...(this.state.categories || {}), [String(updated.id)]: updated, }; this.setState({ renameDialogOpen: false, categories: nextCategories, }); } 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 { if (getCategoryChildren(this.state.categories, deleteNode.id).length > 0) { await categoryAutomat.actions.deleteCategoryRecursive(deleteNode.id); } else { 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, categories: categoryAutomat.state.categories, }); } 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, 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 }} /> {(() => { const canSave = Boolean(renameValue.trim()) && !isSubmitting && renameValue.trim() !== (renameTarget?.name ?? '').trim(); return ( ); })()} {/* 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.'} ); } }