Add category picker to article view with draft-based editing

- Add setCategories API method for PUT /api/article/:sku/categories
- Create CategoryPickerDialog component for selecting categories
- Add CategoryPills component with add/remove functionality
- Category changes are now draft-based (like name editing)
- Save button commits both name and category changes together
- Delete icons hidden when only one category remains
- Plus button only visible in edit mode

Implements category assignment flow similar to name editing pattern.
This commit is contained in:
seb
2026-08-03 08:57:32 +02:00
parent 26d7c4a021
commit 78be835388
4 changed files with 311 additions and 55 deletions

View File

@@ -261,6 +261,19 @@ export const article = {
body: { eans }, body: { eans },
}); });
}, },
/**
* PUT /api/article/:sku/categories
* Body: { categoryIds: number[] } — replace-all. Empty array clears all.
* Categories must belong to the article's company; ids must be unique.
* → 200 { article } (same shape as GET by SKU)
*/
setCategories(sku, categoryIds) {
return http(`/api/article/${encodeURIComponent(sku)}/categories`, {
method: 'PUT',
body: { categoryIds },
});
},
}; };
/** /**

View File

@@ -50,6 +50,13 @@ export default function ArticleView({
const handleArticleUpdated = (next) => { const handleArticleUpdated = (next) => {
onArticleUpdated?.(next); onArticleUpdated?.(next);
syncCategoryListPhoto(next); syncCategoryListPhoto(next);
dispatch(
patchArticleInLists({
sku: next.sku,
name: next.name,
categoryIds: next.categoryIds,
}),
);
}; };
const handleArticleDeleted = () => { const handleArticleDeleted = () => {
@@ -71,7 +78,7 @@ export default function ArticleView({
article={articleData} article={articleData}
categories={categories} categories={categories}
rootCategoryId={rootCategoryId} rootCategoryId={rootCategoryId}
onArticleUpdated={onArticleUpdated} onArticleUpdated={handleArticleUpdated}
/> />
<ArticlePhotoTile <ArticlePhotoTile

View File

@@ -0,0 +1,158 @@
import { useState } 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 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 '../../store/categorySlice';
function CategoryNode({
categories,
categoryId,
rootCategoryId,
selectedId,
onSelect,
expandedIds,
onToggleExpand,
depth = 0,
}) {
const node = categories[String(categoryId)];
if (!node) return null;
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);
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>
)}
</>
);
}
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;
}
}
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),
);
const handleToggleExpand = (id, expand) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (expand) next.add(id);
else next.delete(id);
return next;
});
};
const handleConfirm = () => {
if (selectedId == null) return;
const categoryIds = currentCategoryIds.filter((id) => String(id) !== String(selectedId));
categoryIds.push(selectedId);
onSelect?.(categoryIds);
onClose();
};
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…
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Abbrechen</Button>
<Button onClick={handleConfirm} disabled={selectedId == null} variant="contained">
Zuweisen
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -1,10 +1,13 @@
import { useState } from 'react';
import { EditablePanelTile } from '../../panels'; import { EditablePanelTile } from '../../panels';
import SingleLineTextEdit from '../../SingleLineTextEdit'; import SingleLineTextEdit from '../../SingleLineTextEdit';
import { article } from '../../../api'; import { article } from '../../../api';
import { requireObject } from '../../../utils/apiShape'; import { requireObject } from '../../../utils/apiShape';
import { PANEL_TILE_KEYS } from '../../../theme'; import { PANEL_TILE_KEYS } from '../../../theme';
import { getCategoryBreadcrumbs } from '../../../store/categorySlice'; import { getCategoryBreadcrumbs } from '../../../store/categorySlice';
import { Box, Chip } from '@mui/material'; import { Box, Chip, IconButton } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import CategoryPickerDialog from '../CategoryPickerDialog';
const articleNameTextSx = { const articleNameTextSx = {
fontWeight: 700, fontWeight: 700,
@@ -42,12 +45,18 @@ const pillsContainerSx = {
mt: 'auto', mt: 'auto',
}; };
function nameDraftDirty(draft, saved) { function draftDirty(draft, saved) {
return draft.name.trim() !== String(saved.name ?? '').trim(); const nameDiff = draft.name.trim() !== String(saved.name ?? '').trim();
const a = draft.categoryIds ?? [];
const b = saved.categoryIds ?? [];
const catDiff =
a.length !== b.length ||
[...a].map(String).sort().join(',') !== [...b].map(String).sort().join(',');
return nameDiff || catDiff;
} }
function nameDraftValid(draft) { function draftValid(draft) {
return draft.name.trim().length > 0; return draft.name.trim().length > 0 && (draft.categoryIds?.length ?? 0) > 0;
} }
const headerTileSx = { const headerTileSx = {
@@ -61,19 +70,73 @@ function buildCategoryLabel(categories, categoryId, rootCategoryId) {
return crumbs.map((c) => c.name).join(' | '); return crumbs.map((c) => c.name).join(' | ');
} }
function CategoryPills({
categoryLabels,
categoryIds,
categories,
rootCategoryId,
sku,
editMode,
onCategoryIdsChange,
}) {
const [pickerOpen, setPickerOpen] = useState(false);
const handleRemoveCategory = (removeId) => {
const remaining = categoryIds.filter((id) => String(id) !== String(removeId));
onCategoryIdsChange?.(remaining);
};
return (
<>
<Box sx={pillsContainerSx}>
{categoryLabels.map(({ id, label }) => (
<Chip
key={id}
label={label}
size="small"
variant="outlined"
onDelete={editMode && categoryIds.length > 1 ? () => handleRemoveCategory(id) : undefined}
/>
))}
{editMode && (
<IconButton
size="small"
onClick={() => setPickerOpen(true)}
sx={{ ml: 0.5, width: 24, height: 24 }}
>
<AddIcon fontSize="small" />
</IconButton>
)}
</Box>
<CategoryPickerDialog
key={sku}
open={pickerOpen}
onClose={() => setPickerOpen(false)}
categories={categories}
rootCategoryId={rootCategoryId}
currentCategoryIds={categoryIds}
onSelect={onCategoryIdsChange}
/>
</>
);
}
export default function ArticleViewName({ article: articleData, categories, rootCategoryId, onArticleUpdated }) { export default function ArticleViewName({ article: articleData, categories, rootCategoryId, onArticleUpdated }) {
const handleSave = async (draft) => { const handleSave = async (draft) => {
const name = draft.name.trim(); const name = draft.name.trim();
const res = await article.update(articleData.sku, { name }); const categoryIds = draft.categoryIds ?? [];
requireObject(res, 'PATCH /api/article/:sku');
requireObject(res.article, 'article'); const [nameRes] = await Promise.all([
onArticleUpdated?.(res.article); article.update(articleData.sku, { name }),
article.setCategories(articleData.sku, categoryIds),
]);
requireObject(nameRes, 'PATCH /api/article/:sku');
requireObject(nameRes.article, 'article');
onArticleUpdated?.(nameRes.article);
}; };
const categoryIds = articleData.categoryIds ?? []; const savedCategoryIds = articleData.categoryIds ?? [];
const categoryLabels = categoryIds
.map((id) => ({ id, label: buildCategoryLabel(categories, id, rootCategoryId) }))
.filter((c) => c.label);
return ( return (
<EditablePanelTile <EditablePanelTile
@@ -81,50 +144,65 @@ export default function ArticleViewName({ article: articleData, categories, root
tileKey={PANEL_TILE_KEYS.name} tileKey={PANEL_TILE_KEYS.name}
minHeight={0} minHeight={0}
sx={headerTileSx} sx={headerTileSx}
value={{ name: articleData.name ?? '' }} value={{ name: articleData.name ?? '', categoryIds: [...savedCategoryIds] }}
onSave={handleSave} onSave={handleSave}
isDirty={nameDraftDirty} isDirty={draftDirty}
canSave={nameDraftValid} canSave={draftValid}
renderDisplay={({ name }) => ( renderDisplay={({ name, categoryIds }) => {
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, alignSelf: 'stretch' }}> const labels = (categoryIds ?? [])
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center' }}> .map((id) => ({ id, label: buildCategoryLabel(categories, id, rootCategoryId) }))
<SingleLineTextEdit .filter((c) => c.label);
label="Name" return (
value={name} <Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, alignSelf: 'stretch' }}>
readOnly <Box sx={{ flex: 1, display: 'flex', alignItems: 'center' }}>
onChange={() => {}} <SingleLineTextEdit
sx={articleNameFieldHiddenSx} label="Name"
value={name}
readOnly
onChange={() => {}}
sx={articleNameFieldHiddenSx}
/>
</Box>
{labels.length > 0 && (
<CategoryPills
categoryLabels={labels}
categoryIds={categoryIds}
categories={categories}
rootCategoryId={rootCategoryId}
sku={articleData.sku}
editMode={false}
/>
)}
</Box>
);
}}
renderEdit={(draft, onChange, inputRef) => {
const labels = (draft.categoryIds ?? [])
.map((id) => ({ id, label: buildCategoryLabel(categories, id, rootCategoryId) }))
.filter((c) => c.label);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, alignSelf: 'stretch' }}>
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center' }}>
<SingleLineTextEdit
inputRef={inputRef}
label="Name"
value={draft.name}
onChange={(name) => onChange({ ...draft, name })}
sx={articleNameFieldSx}
/>
</Box>
<CategoryPills
categoryLabels={labels}
categoryIds={draft.categoryIds}
categories={categories}
rootCategoryId={rootCategoryId}
sku={articleData.sku}
editMode={true}
onCategoryIdsChange={(categoryIds) => onChange({ ...draft, categoryIds })}
/> />
</Box> </Box>
{categoryLabels.length > 0 && ( );
<Box sx={pillsContainerSx}> }}
{categoryLabels.map(({ id, label }) => (
<Chip key={id} label={label} size="small" variant="outlined" />
))}
</Box>
)}
</Box>
)}
renderEdit={(draft, onChange, inputRef) => (
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, alignSelf: 'stretch' }}>
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center' }}>
<SingleLineTextEdit
inputRef={inputRef}
label="Name"
value={draft.name}
onChange={(name) => onChange({ name })}
sx={articleNameFieldSx}
/>
</Box>
{categoryLabels.length > 0 && (
<Box sx={pillsContainerSx}>
{categoryLabels.map(({ id, label }) => (
<Chip key={id} label={label} size="small" variant="outlined" />
))}
</Box>
)}
</Box>
)}
/> />
); );
} }