From 78be835388581128cef0abd229fb04b0aee3d0dd Mon Sep 17 00:00:00 2001 From: seb Date: Mon, 3 Aug 2026 08:57:32 +0200 Subject: [PATCH] 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. --- src/api/index.js | 13 ++ src/components/article/ArticleView.jsx | 9 +- .../article/CategoryPickerDialog.jsx | 158 +++++++++++++++ .../article/boxes/ArticleViewName.jsx | 186 +++++++++++++----- 4 files changed, 311 insertions(+), 55 deletions(-) create mode 100644 src/components/article/CategoryPickerDialog.jsx diff --git a/src/api/index.js b/src/api/index.js index ad53921..12a1f54 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -261,6 +261,19 @@ export const article = { 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 }, + }); + }, }; /** diff --git a/src/components/article/ArticleView.jsx b/src/components/article/ArticleView.jsx index 5f0a37e..60fb65e 100644 --- a/src/components/article/ArticleView.jsx +++ b/src/components/article/ArticleView.jsx @@ -50,6 +50,13 @@ export default function ArticleView({ const handleArticleUpdated = (next) => { onArticleUpdated?.(next); syncCategoryListPhoto(next); + dispatch( + patchArticleInLists({ + sku: next.sku, + name: next.name, + categoryIds: next.categoryIds, + }), + ); }; const handleArticleDeleted = () => { @@ -71,7 +78,7 @@ export default function ArticleView({ article={articleData} categories={categories} rootCategoryId={rootCategoryId} - onArticleUpdated={onArticleUpdated} + onArticleUpdated={handleArticleUpdated} /> 0; + const isSelected = selectedId != null && String(selectedId) === String(categoryId); + const isExpanded = expandedIds.has(categoryId); + const label = categoryDisplayName(node.name, node.id, rootCategoryId); + + return ( + <> + onSelect(categoryId)} + sx={{ pl: 1 + depth * 1.5 }} + > + + {hasChildren ? ( + isExpanded ? : + ) : null} + + + {isSelected && } + {hasChildren && ( + isExpanded ? ( + { e.stopPropagation(); onToggleExpand(categoryId, false); }} /> + ) : ( + { e.stopPropagation(); onToggleExpand(categoryId, true); }} /> + ) + )} + + {hasChildren && ( + + + {children.map((child) => ( + + ))} + + + )} + + ); +} + +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 ( + + Kategorie zuweisen + + {categories && rootCategoryId != null ? ( + + + + ) : ( + + Lade Kategorien… + + )} + + + + + + + ); +} diff --git a/src/components/article/boxes/ArticleViewName.jsx b/src/components/article/boxes/ArticleViewName.jsx index 56feefa..0a25557 100644 --- a/src/components/article/boxes/ArticleViewName.jsx +++ b/src/components/article/boxes/ArticleViewName.jsx @@ -1,10 +1,13 @@ +import { useState } from 'react'; import { EditablePanelTile } from '../../panels'; import SingleLineTextEdit from '../../SingleLineTextEdit'; import { article } from '../../../api'; import { requireObject } from '../../../utils/apiShape'; import { PANEL_TILE_KEYS } from '../../../theme'; 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 = { fontWeight: 700, @@ -42,12 +45,18 @@ const pillsContainerSx = { mt: 'auto', }; -function nameDraftDirty(draft, saved) { - return draft.name.trim() !== String(saved.name ?? '').trim(); +function draftDirty(draft, saved) { + 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) { - return draft.name.trim().length > 0; +function draftValid(draft) { + return draft.name.trim().length > 0 && (draft.categoryIds?.length ?? 0) > 0; } const headerTileSx = { @@ -61,19 +70,73 @@ function buildCategoryLabel(categories, categoryId, rootCategoryId) { 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 ( + <> + + {categoryLabels.map(({ id, label }) => ( + 1 ? () => handleRemoveCategory(id) : undefined} + /> + ))} + {editMode && ( + setPickerOpen(true)} + sx={{ ml: 0.5, width: 24, height: 24 }} + > + + + )} + + setPickerOpen(false)} + categories={categories} + rootCategoryId={rootCategoryId} + currentCategoryIds={categoryIds} + onSelect={onCategoryIdsChange} + /> + + ); +} + export default function ArticleViewName({ article: articleData, categories, rootCategoryId, onArticleUpdated }) { const handleSave = async (draft) => { const name = draft.name.trim(); - const res = await article.update(articleData.sku, { name }); - requireObject(res, 'PATCH /api/article/:sku'); - requireObject(res.article, 'article'); - onArticleUpdated?.(res.article); + const categoryIds = draft.categoryIds ?? []; + + const [nameRes] = await Promise.all([ + 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 categoryLabels = categoryIds - .map((id) => ({ id, label: buildCategoryLabel(categories, id, rootCategoryId) })) - .filter((c) => c.label); + const savedCategoryIds = articleData.categoryIds ?? []; return ( ( - - - {}} - sx={articleNameFieldHiddenSx} + isDirty={draftDirty} + canSave={draftValid} + renderDisplay={({ name, categoryIds }) => { + const labels = (categoryIds ?? []) + .map((id) => ({ id, label: buildCategoryLabel(categories, id, rootCategoryId) })) + .filter((c) => c.label); + return ( + + + {}} + sx={articleNameFieldHiddenSx} + /> + + {labels.length > 0 && ( + + )} + + ); + }} + renderEdit={(draft, onChange, inputRef) => { + const labels = (draft.categoryIds ?? []) + .map((id) => ({ id, label: buildCategoryLabel(categories, id, rootCategoryId) })) + .filter((c) => c.label); + return ( + + + onChange({ ...draft, name })} + sx={articleNameFieldSx} + /> + + onChange({ ...draft, categoryIds })} /> - {categoryLabels.length > 0 && ( - - {categoryLabels.map(({ id, label }) => ( - - ))} - - )} - - )} - renderEdit={(draft, onChange, inputRef) => ( - - - onChange({ name })} - sx={articleNameFieldSx} - /> - - {categoryLabels.length > 0 && ( - - {categoryLabels.map(({ id, label }) => ( - - ))} - - )} - - )} + ); + }} /> ); }