Seven reported items, of which the first four had two root causes. The active tab was invisible in dark mode because colorPrimary was hardcoded to #1a1a1a in both themes. The accent now inverts with the theme, and colorTextLightSolid inverts with it, or a near-white accent would get antd's default white label and disappear. The Category tab, Tag tab, and item-form category selector ignored the theme entirely. antd declares main: lib/index.js and module: es/index.js, so importing from 'antd' resolves to the ES build while 'antd/lib/...' loads the CommonJS one — two copies, two React contexts, and no ConfigProvider for anything deep-imported. Switching those files to antd/es/* keeps the deep-import convention and shares the instance. This was introduced by my own use of the lib path; es is correct under Vite. Two storefront components had the same latent bug. "Colour" is now "Color". The Customers tab shows how many items each customer is holding, as a link opening the item list with a Release button. Release mirrors the customer's own cart removal — drop the cart row, return the item to available, guarded on 'reserved' so it can never resurrect a sold item — and deliberately sends no email about an action the customer did not take. The count is a subquery rather than another join, which would have multiplied rows and inflated order_count and total_spent_cents. The Inventory tab filters by category, tags, price, and status, reusing the storefront's parser and query builder so the two cannot drift. Reserved is one option in a Status filter rather than a standalone toggle. Also fixes two defects the screenshots exposed: the reserved-count link bubbled to the row handler and opened the customer drawer behind the dialog, and .admin-category-node had no CSS at all, so the tree node name, item count, and actions ran together as one string. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
252 lines
8.4 KiB
TypeScript
252 lines
8.4 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import type { Key } from 'react';
|
|
import Tree from 'antd/es/tree';
|
|
import Button from 'antd/es/button';
|
|
import Input from 'antd/es/input';
|
|
import Modal from 'antd/es/modal';
|
|
import Select from 'antd/es/select';
|
|
import Space from 'antd/es/space';
|
|
import Typography from 'antd/es/typography';
|
|
import Empty from 'antd/es/empty';
|
|
import Spin from 'antd/es/spin';
|
|
import message from 'antd/es/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>
|
|
);
|
|
}
|