feat(ui): storefront filters and admin category/tag management (#23)
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:
@@ -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 /> }
|
||||
]}
|
||||
|
||||
Reference in New Issue
Block a user