829 lines
27 KiB
JavaScript
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>
|
|
);
|
|
}
|
|
}
|