u
This commit is contained in:
@@ -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)) };
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<ListItemButton
|
||||
selected={isSelected}
|
||||
onClick={() => onSelect(categoryId)}
|
||||
sx={{ pl: 1 + depth * 1.5 }}
|
||||
>
|
||||
<Box sx={{ minWidth: 24, display: 'flex', alignItems: 'center' }}>
|
||||
{hasChildren ? (
|
||||
isExpanded ? <FolderOpenIcon fontSize="small" /> : <FolderIcon fontSize="small" />
|
||||
) : null}
|
||||
</Box>
|
||||
<ListItemText primary={label} />
|
||||
{isSelected && <CheckIcon fontSize="small" color="primary" sx={{ ml: 1 }} />}
|
||||
{hasChildren && (
|
||||
isExpanded ? (
|
||||
<ExpandLess onClick={(e) => { e.stopPropagation(); onToggleExpand(categoryId, false); }} />
|
||||
) : (
|
||||
<ExpandMore onClick={(e) => { e.stopPropagation(); onToggleExpand(categoryId, true); }} />
|
||||
)
|
||||
)}
|
||||
</ListItemButton>
|
||||
{hasChildren && (
|
||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||
<List disablePadding>
|
||||
{children.map((child) => (
|
||||
<CategoryNode
|
||||
key={child.id}
|
||||
categories={categories}
|
||||
categoryId={child.id}
|
||||
rootCategoryId={rootCategoryId}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
expandedIds={expandedIds}
|
||||
onToggleExpand={onToggleExpand}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Kategorie zuweisen</DialogTitle>
|
||||
<DialogContent dividers sx={{ p: 0 }}>
|
||||
{categories && rootCategoryId != null ? (
|
||||
<List dense disablePadding>
|
||||
<CategoryNode
|
||||
categories={categories}
|
||||
categoryId={rootCategoryId}
|
||||
rootCategoryId={rootCategoryId}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
expandedIds={expandedIds}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
/>
|
||||
</List>
|
||||
) : (
|
||||
<Box sx={{ p: 2, textAlign: 'center', color: 'text.secondary' }}>
|
||||
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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
height: '75vh',
|
||||
minHeight: '75vh',
|
||||
maxHeight: 700,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: 2.5,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
},
|
||||
}}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
height: '75vh',
|
||||
minHeight: '75vh',
|
||||
maxHeight: 700,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: 2.5,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<DialogTitle
|
||||
sx={{
|
||||
p: 2,
|
||||
pb: 1.5,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FolderIcon color="primary" />
|
||||
<Typography variant="h6" component="span" sx={{ fontWeight: 600 }}>
|
||||
{dialogTitle}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton size="small" onClick={onClose} aria-label="Schließen">
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Abbrechen</Button>
|
||||
<Button onClick={handleConfirm} disabled={selectedId == null} variant="contained">
|
||||
Zuweisen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
{/* Breadcrumb Path Preview */}
|
||||
{breadcrumbs.length > 0 && (
|
||||
<Breadcrumbs
|
||||
separator={<NavigateNextIcon sx={{ fontSize: 14 }} />}
|
||||
sx={{ mt: 1, '& .MuiBreadcrumbs-li': { fontSize: '0.8rem' } }}
|
||||
>
|
||||
{breadcrumbs.map((bc, idx) => {
|
||||
const isLast = idx === breadcrumbs.length - 1;
|
||||
return (
|
||||
<Typography
|
||||
key={bc.id}
|
||||
variant="caption"
|
||||
color={isLast ? 'primary.main' : 'text.secondary'}
|
||||
fontWeight={isLast ? 600 : 400}
|
||||
>
|
||||
{categoryDisplayName(bc.name, bc.id, rootCategoryId)}
|
||||
</Typography>
|
||||
);
|
||||
})}
|
||||
</Breadcrumbs>
|
||||
)}
|
||||
</DialogTitle>
|
||||
|
||||
{/* Tree Content */}
|
||||
<DialogContent sx={{ p: 0, flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
<CategoryTree
|
||||
categories={categories}
|
||||
rootCategoryId={rootCategoryId}
|
||||
selectedId={selectedId}
|
||||
onSelect={this.handleSelect}
|
||||
onDoubleClick={this.handleDoubleClick}
|
||||
alreadyAssignedIds={currentCategoryIds}
|
||||
disabledIds={disabledIds}
|
||||
excludeIds={excludeIds}
|
||||
allowAdd={true}
|
||||
allowDelete={true}
|
||||
allowReorder={true}
|
||||
allowRename={true}
|
||||
showSearch={true}
|
||||
sx={{ height: '100%' }}
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
{/* Footer / Actions */}
|
||||
<DialogActions
|
||||
sx={{
|
||||
p: 2,
|
||||
borderTop: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'background.paper',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: isAlreadyAssigned ? 'space-between' : 'flex-end',
|
||||
}}
|
||||
>
|
||||
{isAlreadyAssigned && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
(bereits zugewiesen)
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button onClick={onClose} color="inherit">
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<CheckIcon />}
|
||||
onClick={this.handleConfirm}
|
||||
disabled={isSelectedDisabled}
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</Box>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<ArticleNameDialog
|
||||
open={this.state.open}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Component, PureComponent } from 'react';
|
||||
import { EditablePanelTile } from 'dashgui/panels';
|
||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
||||
import { getCategoryBreadcrumbs } from '../../../utils/categoryUtils';
|
||||
import { getCategoryBreadcrumbs, categoryDisplayName } from '../../../utils/categoryUtils';
|
||||
import { Box, Chip, IconButton } from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CategoryPickerDialog from '../CategoryPickerDialog';
|
||||
@@ -92,7 +92,7 @@ class CategoryPills extends Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { pickerOpen: false };
|
||||
this.state = { pickerOpen: false, targetPillId: null };
|
||||
}
|
||||
|
||||
handleRemoveCategory = (removeId) => {
|
||||
@@ -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 (
|
||||
<>
|
||||
<Box sx={pillsContainerSx}>
|
||||
@@ -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 ? (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={this.handleOpenPicker}
|
||||
onClick={this.handleOpenAddPicker}
|
||||
sx={{ ml: 0.5, width: 24, height: 24 }}
|
||||
>
|
||||
<AddIcon fontSize="small" />
|
||||
@@ -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}
|
||||
</>
|
||||
|
||||
796
src/components/category/CategoryTree.jsx
Normal file
796
src/components/category/CategoryTree.jsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<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() {
|
||||
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 (
|
||||
<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 */}
|
||||
<Dialog
|
||||
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>
|
||||
</Dialog>
|
||||
|
||||
{/* Rename Dialog */}
|
||||
<Dialog
|
||||
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>
|
||||
<Button
|
||||
onClick={this.handleConfirmRename}
|
||||
variant="contained"
|
||||
disabled={!renameValue.trim() || isSubmitting}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
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>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 }) {
|
||||
)}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
className="droppable-handle"
|
||||
aria-label="In Unterkategorie ablegen"
|
||||
aria-label="Kategorie verschieben"
|
||||
disableRipple
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPlacementClick?.();
|
||||
}}
|
||||
sx={{
|
||||
...handleSx,
|
||||
right: 0,
|
||||
cursor: 'grab',
|
||||
'&:active': { cursor: 'grabbing' },
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<AccountTreeIcon sx={{ fontSize: 36 }} />
|
||||
@@ -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 (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '33.33%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Upper half: click-through to open category */}
|
||||
<Box
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onOpen}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onOpen?.();
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
flex: 1,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: 'action.hover' },
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Lower half: reorder & delete */}
|
||||
<Box
|
||||
sx={{
|
||||
height: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 0.5,
|
||||
bgcolor: 'grey.400',
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
className="sortable-handle-btn"
|
||||
size="small"
|
||||
aria-label="Nach links verschieben"
|
||||
disabled={!editBand.canLeft}
|
||||
onClick={editBand.onMoveLeft}
|
||||
>
|
||||
<ChevronLeftIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
className="sortable-handle-btn"
|
||||
size="small"
|
||||
aria-label="Löschen"
|
||||
onClick={editBand.onDelete}
|
||||
>
|
||||
<DeleteOutlinedIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
className="sortable-handle-btn"
|
||||
size="small"
|
||||
aria-label="Nach rechts verschieben"
|
||||
disabled={!editBand.canRight}
|
||||
onClick={editBand.onMoveRight}
|
||||
>
|
||||
<ChevronRightIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}
|
||||
/>
|
||||
<Box
|
||||
onClick={this.handleMiddleBandClick}
|
||||
@@ -417,7 +340,30 @@ export class CategoryTile extends Component {
|
||||
fullHash={fullHash}
|
||||
/>
|
||||
</Box>
|
||||
<BottomRow onOpen={onOpen} editBand={editBand} />
|
||||
{/* Lower third: clickable in edit mode to open/navigate into category */}
|
||||
<Box
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Kategorie öffnen"
|
||||
onClick={onOpen || onClick}
|
||||
onKeyDown={(e) => {
|
||||
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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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) => (
|
||||
<ArticleTile
|
||||
key={item.id}
|
||||
label={item.name != null ? item.name.trim() : undefined}
|
||||
article={item}
|
||||
disabled={!articleSku(item)}
|
||||
onClick={() => this.handleArticleClick(item)}
|
||||
sx={index === 0 ? { gridColumnStart: 1 } : undefined}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -477,6 +474,7 @@ class ArtikelKategorienPage extends PureComponent {
|
||||
<NewArticleTile
|
||||
parentId={currentCategoryId}
|
||||
onOpenArticle={this.handleArticleClick}
|
||||
sx={{ gridColumnStart: 1 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
@@ -486,6 +484,21 @@ class ArtikelKategorienPage extends PureComponent {
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{moveCategory && (
|
||||
<CategoryPickerDialog
|
||||
open={Boolean(moveCategory)}
|
||||
onClose={this.handleCloseMoveCategory}
|
||||
categories={categories}
|
||||
rootCategoryId={rootCategoryId}
|
||||
title={`„${categoryDisplayName(moveCategory.name, moveCategory.id, rootCategoryId)}“ verschieben nach…`}
|
||||
confirmLabel="Hierher verschieben"
|
||||
mode="single"
|
||||
selectedId={moveCategory.parentId}
|
||||
excludeIds={getCategoryDescendantIds(categories, moveCategory.id)}
|
||||
onSelectCategory={this.handleConfirmMoveCategory}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user