feat(ui): storefront filters and admin category/tag management (#23)
SonarQube Analysis / sonarqube (pull_request) Successful in 2m49s
Tests / backend-unit (pull_request) Successful in 37s
Tests / backend-integration (pull_request) Failing after 3h2m23s
Tests / frontend-e2e (pull_request) Failing after 3m54s

Storefront gains a Filters drawer holding the category tree, colour-coded
tag pills, and a price range, with applied filters shown as removable
chips. Filter state lives in the URL query string, so a filtered view is
shareable and the back button works. Item cards now show their category
and tags.

Admin gains Categories and Tags tabs, and the item form gains a category
TreeSelect plus a tags Select that creates new tags on the fly.

The admin category tree tracks expansion in state rather than using
defaultExpandAll: that prop is evaluated once at mount, so a branch added
afterwards rendered collapsed and its children were unreachable. Creating
or moving a node now expands its parent. Caught by the new admin e2e spec.

The chip row is marked as a named group so its "Clear all" stays
distinguishable from the drawer's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 09:34:37 -05:00
co-authored by Claude Opus 5
parent 9222e97deb
commit d28fb5634a
13 changed files with 1374 additions and 22 deletions
+84 -4
View File
@@ -1,17 +1,39 @@
import { useEffect, useState } from 'react';
import {
Layout, Table, Button, Form, Input, InputNumber, Upload, Modal,
Space, Tag, Typography, Switch, message, Image as AntImage, theme, Tabs
Space, Tag, Typography, Switch, message, Image as AntImage, theme, Tabs,
TreeSelect, Select
} from 'antd';
import { UploadOutlined, DeleteOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface';
import MDEditor from '@uiw/react-md-editor';
import '@uiw/react-md-editor/markdown-editor.css';
import '@uiw/react-markdown-preview/markdown.css';
import { Item, fetchAdminItems, saveItem, deleteItem, deleteItemImage, markSold, markAvailable } from '../api';
import {
Item, Category, Tag as TagRecord,
fetchAdminItems, saveItem, deleteItem, deleteItemImage, markSold, markAvailable,
fetchAdminCategories, fetchAdminTags
} from '../api';
import { buildCategoryTree, CategoryNode } from '../filters';
import { useThemeMode } from '../theme/ThemeContext';
import Customers from './Customers';
import Settings from './Settings';
import Categories from './Categories';
import Tags from './Tags';
interface CategoryTreeOption {
value: number;
title: string;
children?: CategoryTreeOption[];
}
function toCategoryTreeData(nodes: CategoryNode[]): CategoryTreeOption[] {
return nodes.map(node => ({
value: node.id,
title: node.name,
children: node.children.length ? toCategoryTreeData(node.children) : undefined
}));
}
const { Header, Content } = Layout;
const { Title } = Typography;
@@ -23,24 +45,41 @@ function Inventory() {
const [form] = Form.useForm();
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [description, setDescription] = useState<string>('');
const [categories, setCategories] = useState<Category[]>([]);
const [tags, setTags] = useState<TagRecord[]>([]);
const { mode } = useThemeMode();
const load = () => fetchAdminItems().then(setItems);
useEffect(() => { load(); }, []);
// The item form needs the current category tree and tag list; both change
// from the sibling tabs, so they're refetched whenever the modal opens.
const loadOptions = () => Promise.all([
fetchAdminCategories().then(setCategories),
fetchAdminTags().then(setTags)
]);
useEffect(() => { load(); loadOptions(); }, []);
function openNew() {
setEditingItem(null);
form.resetFields();
setFileList([]);
setDescription('');
loadOptions();
setModalOpen(true);
}
function openEdit(item: Item) {
setEditingItem(item);
form.setFieldsValue({ name: item.name, price: item.price_cents / 100 });
form.setFieldsValue({
name: item.name,
price: item.price_cents / 100,
category_id: item.category_id ?? undefined,
tags: item.tags.map(tag => tag.name)
});
setFileList([]);
setDescription(item.description || '');
loadOptions();
setModalOpen(true);
}
@@ -50,11 +89,16 @@ function Inventory() {
fd.append('name', values.name);
fd.append('description', description);
fd.append('price', String(values.price));
// An empty string clears the category server-side; undefined would be sent
// as the literal text "undefined".
fd.append('category_id', values.category_id == null ? '' : String(values.category_id));
fd.append('tags', JSON.stringify(values.tags ?? []));
fileList.forEach(f => { if (f.originFileObj) fd.append('images', f.originFileObj as File); });
await saveItem(editingItem?.id ?? null, fd);
message.success(editingItem ? 'Item updated' : 'Item added');
setModalOpen(false);
load();
loadOptions();
}
async function handleDelete(id: number) {
@@ -87,6 +131,19 @@ function Inventory() {
) : null
},
{ title: 'Name', dataIndex: 'name' },
{
title: 'Category',
dataIndex: 'category_name',
render: (name: string | null) => name || <span style={{ opacity: 0.45 }}>Uncategorized</span>
},
{
title: 'Tags',
dataIndex: 'tags',
render: (itemTags: Item['tags']) =>
itemTags.length
? itemTags.map(tag => <Tag key={tag.id} color={tag.color}>{tag.name}</Tag>)
: null
},
{ title: 'Price', dataIndex: 'price_cents', render: (v: number) => `$${(v / 100).toFixed(2)}` },
{
title: 'Status',
@@ -129,6 +186,27 @@ function Inventory() {
<Form.Item name="price" label="Price (USD)" rules={[{ required: true }]}>
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="category_id" label="Category">
<TreeSelect
allowClear
placeholder="Uncategorized"
treeDefaultExpandAll
treeData={toCategoryTreeData(buildCategoryTree(categories))}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item
name="tags"
label="Tags"
extra="Pick existing tags or type a new one and press Enter to create it."
>
<Select
mode="tags"
placeholder="Add tags"
style={{ width: '100%' }}
options={tags.map(tag => ({ value: tag.name, label: tag.name }))}
/>
</Form.Item>
{editingItem && editingItem.images.length > 0 && (
<Form.Item label="Existing Images (front / back / etc.)">
<Space wrap>
@@ -172,6 +250,8 @@ export default function Admin() {
defaultActiveKey="inventory"
items={[
{ key: 'inventory', label: 'Inventory', children: <Inventory /> },
{ key: 'categories', label: 'Categories', children: <Categories /> },
{ key: 'tags', label: 'Tags', children: <Tags /> },
{ key: 'customers', label: 'Customers', children: <Customers /> },
{ key: 'settings', label: 'Settings', children: <Settings /> }
]}
+251
View File
@@ -0,0 +1,251 @@
import { useEffect, useRef, useState } from 'react';
import type { Key } from 'react';
import Tree from 'antd/lib/tree';
import Button from 'antd/lib/button';
import Input from 'antd/lib/input';
import Modal from 'antd/lib/modal';
import Select from 'antd/lib/select';
import Space from 'antd/lib/space';
import Typography from 'antd/lib/typography';
import Empty from 'antd/lib/empty';
import Spin from 'antd/lib/spin';
import message from 'antd/lib/message';
import type { DataNode, TreeProps } from 'antd/es/tree';
import {
Category,
fetchAdminCategories,
createCategory,
updateCategory,
deleteCategory
} from '../api';
import { buildCategoryTree, categoryPath, CategoryNode } from '../filters';
const { Title, Text } = Typography;
// Counts the whole branch, so the delete confirmation can say what a parent
// takes down with it rather than only naming itself.
function branchTotals(node: CategoryNode): { categories: number; items: number } {
return node.children.reduce(
(acc, child) => {
const nested = branchTotals(child);
return { categories: acc.categories + nested.categories, items: acc.items + nested.items };
},
{ categories: 1, items: node.item_count }
);
}
function findNode(nodes: CategoryNode[], id: number): CategoryNode | null {
for (const node of nodes) {
if (node.id === id) return node;
const nested = findNode(node.children, id);
if (nested) return nested;
}
return null;
}
export default function Categories() {
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Category | null>(null);
const [name, setName] = useState('');
const [parentId, setParentId] = useState<number | null>(null);
// The tree stays mounted across reloads, so `defaultExpandAll` would only
// ever apply to the categories present on first render — a branch added
// afterwards would render collapsed and its children be unreachable.
const [expandedKeys, setExpandedKeys] = useState<Key[]>([]);
const expansionInitialized = useRef(false);
const tree = buildCategoryTree(categories);
function expand(id: number) {
setExpandedKeys((keys) => (keys.includes(id) ? keys : [...keys, id]));
}
function load() {
setLoading(true);
return fetchAdminCategories()
.then((list) => {
setCategories(list);
// Start fully expanded, then leave expansion to the user — reloading
// must not silently re-open branches they collapsed.
if (!expansionInitialized.current) {
setExpandedKeys(list.map((category) => category.id));
expansionInitialized.current = true;
}
})
.finally(() => setLoading(false));
}
useEffect(() => { load(); }, []);
function openNew(parent: number | null) {
setEditing(null);
setName('');
setParentId(parent);
setModalOpen(true);
}
function openEdit(category: Category) {
setEditing(category);
setName(category.name);
setParentId(category.parent_id);
setModalOpen(true);
}
async function handleSave() {
const trimmed = name.trim();
if (!trimmed) {
message.error('Name is required');
return;
}
try {
if (editing) {
await updateCategory(editing.id, { name: trimmed, parent_id: parentId });
message.success('Category updated');
} else {
await createCategory(trimmed, parentId);
message.success('Category added');
}
// Reveal where the category just landed instead of filing it out of sight.
if (parentId !== null) expand(parentId);
setModalOpen(false);
load();
} catch (err) {
message.error((err as Error).message);
}
}
function handleDelete(category: Category) {
const node = findNode(tree, category.id);
const totals = node ? branchTotals(node) : { categories: 1, items: category.item_count };
const subcategories = totals.categories - 1;
Modal.confirm({
title: `Delete "${category.name}"?`,
content: (
<span>
This deletes {subcategories === 0 ? 'no subcategories' : `${subcategories} subcategor${subcategories === 1 ? 'y' : 'ies'}`}
{' '}and uncategorizes {totals.items} item{totals.items === 1 ? '' : 's'}. The items themselves are kept.
</span>
),
okText: 'Delete',
okButtonProps: { danger: true },
onOk: async () => {
const result = await deleteCategory(category.id);
message.success(
`Deleted ${result.deleted_categories} categor${result.deleted_categories === 1 ? 'y' : 'ies'}, ` +
`uncategorized ${result.uncategorized_items} item${result.uncategorized_items === 1 ? '' : 's'}`
);
load();
}
});
}
// Dragging a node onto another reparents it. The server rejects a move that
// would put a node beneath its own descendant, so a refused drop just
// reloads the unchanged tree.
const handleDrop: TreeProps['onDrop'] = async (info) => {
const dragId = Number(info.dragNode.key);
const dropId = Number(info.node.key);
const dropToGap = !info.dropToGap ? dropId : findNode(tree, dropId)?.parent_id ?? null;
try {
await updateCategory(dragId, { parent_id: dropToGap });
if (dropToGap !== null) expand(dropToGap);
message.success('Category moved');
} catch (err) {
message.error((err as Error).message);
}
load();
};
function toTreeData(nodes: CategoryNode[]): DataNode[] {
return nodes.map((node) => ({
key: node.id,
title: (
<span className="admin-category-node">
<span>{node.name}</span>
<Text type="secondary" style={{ fontSize: 12 }}>
{node.item_count} item{node.item_count === 1 ? '' : 's'}
</Text>
<Space size={4} onClick={(event) => event.stopPropagation()}>
<Button size="small" type="link" onClick={() => openNew(node.id)}>Add child</Button>
<Button size="small" type="link" onClick={() => openEdit(node)}>Rename</Button>
<Button size="small" type="link" danger onClick={() => handleDelete(node)}>Delete</Button>
</Space>
</span>
),
children: node.children.length ? toTreeData(node.children) : undefined
}));
}
// A category may not be moved beneath itself or its own descendants, so those
// options are withheld from the parent picker too.
const forbidden = new Set<number>();
if (editing) {
const collect = (node: CategoryNode) => {
forbidden.add(node.id);
node.children.forEach(collect);
};
const node = findNode(tree, editing.id);
if (node) collect(node);
}
const parentOptions = [
{ value: null as number | null, label: 'No parent (top level)' },
...categories
.filter((category) => !forbidden.has(category.id))
.map((category) => ({ value: category.id as number | null, label: categoryPath(categories, category.id) }))
];
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
<Title level={4} style={{ margin: 0 }}>Categories</Title>
<Button type="primary" onClick={() => openNew(null)}>Add Category</Button>
</div>
{loading && !categories.length ? <Spin /> : null}
{!loading && !categories.length ? (
<Empty description="No categories yet. Add one to start organizing items." />
) : (
<Tree
treeData={toTreeData(tree)}
draggable
blockNode
expandedKeys={expandedKeys}
onExpand={setExpandedKeys}
selectable={false}
onDrop={handleDrop}
/>
)}
<Modal
title={editing ? 'Edit Category' : 'Add Category'}
open={modalOpen}
onOk={handleSave}
onCancel={() => setModalOpen(false)}
destroyOnHidden
>
<Space direction="vertical" style={{ width: '100%' }}>
<label htmlFor="category-name">Name</label>
<Input
id="category-name"
value={name}
onChange={(event) => setName(event.target.value)}
onPressEnter={handleSave}
/>
<label htmlFor="category-parent">Parent</label>
<Select
id="category-parent"
style={{ width: '100%' }}
value={parentId}
onChange={setParentId}
options={parentOptions}
/>
</Space>
</Modal>
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
import { useEffect, useState } from 'react';
import Table from 'antd/lib/table';
import Button from 'antd/lib/button';
import Input from 'antd/lib/input';
import Modal from 'antd/lib/modal';
import Select from 'antd/lib/select';
import Space from 'antd/lib/space';
import Tag from 'antd/lib/tag';
import Typography from 'antd/lib/typography';
import message from 'antd/lib/message';
import type { ColumnsType } from 'antd/es/table';
import { Tag as TagRecord, fetchAdminTags, createTag, updateTag, deleteTag } from '../api';
const { Title } = Typography;
// Mirrors TAG_COLORS in the backend's utils.ts — the server rejects anything
// outside this set, so the two lists have to stay aligned.
const TAG_COLORS = [
'magenta', 'red', 'volcano', 'orange', 'gold', 'lime',
'green', 'cyan', 'blue', 'geekblue', 'purple'
];
export default function Tags() {
const [tags, setTags] = useState<TagRecord[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<TagRecord | null>(null);
const [name, setName] = useState('');
const [color, setColor] = useState<string>(TAG_COLORS[0]);
function load() {
setLoading(true);
return fetchAdminTags()
.then(setTags)
.finally(() => setLoading(false));
}
useEffect(() => { load(); }, []);
function openNew() {
setEditing(null);
setName('');
setColor(TAG_COLORS[0]);
setModalOpen(true);
}
function openEdit(tag: TagRecord) {
setEditing(tag);
setName(tag.name);
setColor(tag.color);
setModalOpen(true);
}
async function handleSave() {
const trimmed = name.trim();
if (!trimmed) {
message.error('Name is required');
return;
}
try {
if (editing) {
await updateTag(editing.id, { name: trimmed, color });
message.success('Tag updated');
} else {
// New tags take the colour the server derives from the name; it can be
// overridden straight afterwards by editing.
await createTag(trimmed);
message.success('Tag added');
}
setModalOpen(false);
load();
} catch (err) {
message.error((err as Error).message);
}
}
function handleDelete(tag: TagRecord) {
Modal.confirm({
title: `Delete "${tag.name}"?`,
content: `This removes the tag from ${tag.item_count} item${tag.item_count === 1 ? '' : 's'}. The items themselves are kept.`,
okText: 'Delete',
okButtonProps: { danger: true },
onOk: async () => {
await deleteTag(tag.id);
message.success('Tag deleted');
load();
}
});
}
const columns: ColumnsType<TagRecord> = [
{
title: 'Tag',
dataIndex: 'name',
render: (_: string, tag) => <Tag color={tag.color}>{tag.name}</Tag>
},
{ title: 'Colour', dataIndex: 'color' },
{ title: 'Items', dataIndex: 'item_count' },
{
title: 'Actions',
render: (_: unknown, tag) => (
<Space>
<Button size="small" onClick={() => openEdit(tag)}>Edit</Button>
<Button size="small" danger onClick={() => handleDelete(tag)}>Delete</Button>
</Space>
)
}
];
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
<Title level={4} style={{ margin: 0 }}>Tags</Title>
<Button type="primary" onClick={openNew}>Add Tag</Button>
</div>
<Table rowKey="id" dataSource={tags} columns={columns} loading={loading} scroll={{ x: true }} />
<Modal
title={editing ? 'Edit Tag' : 'Add Tag'}
open={modalOpen}
onOk={handleSave}
onCancel={() => setModalOpen(false)}
destroyOnHidden
>
<Space direction="vertical" style={{ width: '100%' }}>
<label htmlFor="tag-name">Name</label>
<Input
id="tag-name"
value={name}
onChange={(event) => setName(event.target.value)}
onPressEnter={handleSave}
/>
{editing && (
<>
<label htmlFor="tag-color">Colour</label>
<Select
id="tag-color"
style={{ width: '100%' }}
value={color}
onChange={setColor}
options={TAG_COLORS.map((option) => ({
value: option,
label: <Tag color={option}>{option}</Tag>
}))}
/>
</>
)}
</Space>
</Modal>
</div>
);
}