Files
dashfront/src/components/category/CategoryTree.jsx
2026-10-30 04:16:56 +01:00

829 lines
27 KiB
JavaScript

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 (
<>
<ListItemButton
disableRipple
selected={isSelected}
disabled={isDisabled}
onClick={this.handleSelect}
onDoubleClick={this.handleDoubleClick}
sx={{
py: 0.75,
px: 1,
pl: 1 + depth * 2.2,
borderRadius: 1,
mx: 0.5,
my: 0.25,
position: 'relative',
transition: 'all 0.15s ease',
opacity: isDisabled ? 0.4 : 1,
'&:hover .category-actions': {
opacity: isDisabled ? 0 : 1,
pointerEvents: isDisabled ? 'none' : 'auto',
},
'&.Mui-selected': {
bgcolor: 'action.selected',
fontWeight: 600,
'&:hover': {
bgcolor: 'action.selected',
},
},
}}
>
{/* Folder icon */}
<ListItemIcon sx={{ minWidth: 28, color: isSelected ? 'primary.main' : 'text.secondary' }}>
<FolderIcon fontSize="small" />
</ListItemIcon>
{/* Label */}
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography
variant="body2"
sx={{
fontWeight: isSelected ? 600 : 400,
color: isSelected ? 'primary.main' : 'text.primary',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{label}
</Typography>
{/* Child count chip */}
{hasChildren && (
<Typography
variant="caption"
sx={{
color: 'text.disabled',
fontSize: '0.7rem',
bgcolor: 'action.hover',
px: 0.6,
py: 0.1,
borderRadius: 1,
}}
>
{children.length}
</Typography>
)}
{/* Already assigned badge */}
{isAlreadyAssigned && (
<Chip
icon={<CheckCircleIcon sx={{ fontSize: '12px !important' }} />}
label="Zugewiesen"
size="small"
color="success"
variant="outlined"
sx={{ height: 20, fontSize: '0.65rem', '& .MuiChip-label': { px: 0.6 } }}
/>
)}
{/* Custom badge slot */}
{renderItemBadge?.(node)}
</Box>
}
sx={{ my: 0 }}
/>
{/* Action buttons (hover or selected) */}
<Box
className="category-actions"
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.25,
opacity: isSelected ? 1 : 0,
pointerEvents: isSelected ? 'auto' : 'none',
transition: 'opacity 0.15s ease',
ml: 'auto',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Quick reorder buttons */}
{allowReorder && !isRoot && (
<>
<Tooltip title="Nach oben verschieben">
<span>
<IconButton
disableRipple
size="small"
disabled={isFirstSibling}
onClick={() => onMoveSibling?.(node, 'up')}
sx={{ p: 0.3 }}
>
<ArrowUpwardIcon sx={{ fontSize: 16 }} />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Nach unten verschieben">
<span>
<IconButton
disableRipple
size="small"
disabled={isLastSibling}
onClick={() => onMoveSibling?.(node, 'down')}
sx={{ p: 0.3 }}
>
<ArrowDownwardIcon sx={{ fontSize: 16 }} />
</IconButton>
</span>
</Tooltip>
</>
)}
{/* Quick Add Subcategory button */}
{allowAdd && (
<Tooltip title="Unterkategorie hinzufügen">
<IconButton
disableRipple
size="small"
onClick={() => onOpenAdd?.(node)}
sx={{ p: 0.3 }}
>
<AddIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
)}
{/* More actions menu (not available on root category) */}
{!isRoot && (allowRename || allowDelete) && (
<IconButton disableRipple size="small" onClick={this.handleOpenMenu} sx={{ p: 0.3 }}>
<MoreVertIcon sx={{ fontSize: 16 }} />
</IconButton>
)}
{!isRoot && (
<Menu
anchorEl={menuAnchor}
open={Boolean(menuAnchor)}
onClose={this.handleCloseMenu}
onClick={(e) => e.stopPropagation()}
>
{allowRename && (
<MenuItem
dense
onClick={() => {
this.handleCloseMenu();
onOpenRename?.(node);
}}
>
<ListItemIcon sx={{ minWidth: 28 }}>
<EditIcon fontSize="small" />
</ListItemIcon>
Umbenennen
</MenuItem>
)}
{allowDelete && (
<MenuItem
dense
onClick={() => {
this.handleCloseMenu();
onOpenDelete?.(node);
}}
sx={{ color: 'error.main' }}
>
<ListItemIcon sx={{ minWidth: 28, color: 'error.main' }}>
<DeleteOutlinedIcon fontSize="small" />
</ListItemIcon>
Löschen
</MenuItem>
)}
</Menu>
)}
</Box>
</ListItemButton>
{/* Child categories - always expanded */}
{hasChildren && (
<List disablePadding sx={{ position: 'relative' }}>
{/* Subtle guide line on the left */}
<Box
sx={{
position: 'absolute',
left: 12 + depth * 22,
top: 0,
bottom: 8,
width: '1px',
bgcolor: 'divider',
opacity: 0.5,
}}
/>
{children.map((child, idx) => (
<CategoryTreeNode
key={child.id}
categories={categories}
categoryId={child.id}
rootCategoryId={rootCategoryId}
selectedId={selectedId}
onSelect={onSelect}
onDoubleClick={onDoubleClick}
searchMatches={searchMatches}
query={query}
depth={depth + 1}
alreadyAssignedIds={alreadyAssignedIds}
disabledIds={disabledIds}
excludeIds={excludeIds}
allowAdd={allowAdd}
allowDelete={allowDelete}
allowReorder={allowReorder}
allowRename={allowRename}
onOpenAdd={onOpenAdd}
onOpenRename={onOpenRename}
onOpenDelete={onOpenDelete}
onMoveSibling={onMoveSibling}
isFirstSibling={idx === 0}
isLastSibling={idx === children.length - 1}
renderItemBadge={renderItemBadge}
/>
))}
</List>
)}
</>
);
}
}
/**
* 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 (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0, ...sx }}>
{/* Search */}
{showSearch && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
p: 1.5,
borderBottom: 1,
borderColor: 'divider',
bgcolor: 'background.paper',
}}
>
<TextField
size="small"
placeholder="Kategorie suchen…"
value={searchQuery}
onChange={(e) => this.setState({ searchQuery: e.target.value })}
fullWidth
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" color="action" />
</InputAdornment>
),
endAdornment: searchQuery ? (
<InputAdornment position="end">
<IconButton size="small" onClick={() => this.setState({ searchQuery: '' })}>
<ClearIcon fontSize="small" />
</IconButton>
</InputAdornment>
) : null,
}}
sx={{ '& .MuiOutlinedInput-root': { borderRadius: 2 } }}
/>
</Box>
)}
{/* Tree Content - Always Expanded */}
<Box sx={{ flex: 1, minHeight: 0, overflowY: 'auto', p: 1 }}>
{categories && rootCategoryId != null ? (
<List dense disablePadding>
<CategoryTreeNode
categories={categories}
categoryId={rootCategoryId}
rootCategoryId={rootCategoryId}
selectedId={selectedId}
onSelect={onSelect}
onDoubleClick={onDoubleClick}
searchMatches={searchMatches}
query={searchQuery}
alreadyAssignedIds={alreadyAssignedIds}
disabledIds={disabledIds}
excludeIds={excludeIds}
allowAdd={allowAdd}
allowDelete={allowDelete}
allowReorder={allowReorder}
allowRename={allowRename}
onOpenAdd={this.handleOpenAdd}
onOpenRename={this.handleOpenRename}
onOpenDelete={this.handleOpenDelete}
onMoveSibling={this.handleMoveSibling}
renderItemBadge={renderItemBadge}
/>
</List>
) : (
<Box sx={{ p: 3, textAlign: 'center', color: 'text.secondary' }}>
Lade Kategorien…
</Box>
)}
</Box>
{/* Add Dialog */}
<AppDialog
open={addDialogOpen}
onClose={() => !isSubmitting && this.setState({ addDialogOpen: false })}
maxWidth="xs"
fullWidth
>
<DialogTitle>Neue Unterkategorie anlegen</DialogTitle>
<DialogContent>
<DialogContentText sx={{ mb: 2 }}>
Unterkategorie wird erstellt in:{' '}
<strong>
{addParentNode
? categoryDisplayName(addParentNode.name, addParentNode.id, rootCategoryId)
: 'Home'}
</strong>
</DialogContentText>
<TextField
autoFocus
label="Kategoriename"
value={addCategoryName}
onChange={(e) => this.setState({ addCategoryName: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter' && addCategoryName.trim() && !isSubmitting) {
e.preventDefault();
void this.handleConfirmAdd();
}
}}
fullWidth
size="small"
/>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button
onClick={() => this.setState({ addDialogOpen: false })}
color="inherit"
disabled={isSubmitting}
>
Abbrechen
</Button>
<Button
onClick={this.handleConfirmAdd}
variant="contained"
disabled={!addCategoryName.trim() || isSubmitting}
>
Anlegen
</Button>
</DialogActions>
</AppDialog>
{/* Rename Dialog */}
<AppDialog
open={renameDialogOpen}
onClose={() => !isSubmitting && this.setState({ renameDialogOpen: false })}
maxWidth="xs"
fullWidth
>
<DialogTitle>Kategorie umbenennen</DialogTitle>
<DialogContent>
<TextField
autoFocus
label="Neuer Name"
value={renameValue}
onChange={(e) => 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 }}
/>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button
onClick={() => this.setState({ renameDialogOpen: false })}
color="inherit"
disabled={isSubmitting}
>
Abbrechen
</Button>
{(() => {
const canSave =
Boolean(renameValue.trim()) &&
!isSubmitting &&
renameValue.trim() !== (renameTarget?.name ?? '').trim();
return (
<Button
onClick={this.handleConfirmRename}
variant="contained"
color={canSave ? 'success' : 'inherit'}
disabled={!canSave}
sx={
canSave
? { bgcolor: 'success.main', color: '#fff', '&:hover': { bgcolor: 'success.dark' } }
: undefined
}
>
Speichern
</Button>
);
})()}
</DialogActions>
</AppDialog>
{/* Delete Confirmation Dialog */}
<AppDialog
open={deleteDialogOpen}
onClose={() => !isSubmitting && this.setState({ deleteDialogOpen: false })}
maxWidth="xs"
fullWidth
>
<DialogTitle>Kategorie löschen?</DialogTitle>
<DialogContent>
<DialogContentText>
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.'}
</DialogContentText>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button
onClick={() => this.setState({ deleteDialogOpen: false })}
color="inherit"
disabled={isSubmitting}
>
Abbrechen
</Button>
<Button
onClick={this.handleConfirmDelete}
variant="contained"
color="error"
disabled={isSubmitting}
>
Löschen
</Button>
</DialogActions>
</AppDialog>
</Box>
);
}
}