u
This commit is contained in:
@@ -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}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user