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 /> }
]}