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 && (
)}
{/* 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.'}
);
}
}