Files
redefined-designs/frontend/src/admin/CategoryTreeSelect.tsx
T
bermudalambandClaude Opus 5 f537314259
SonarQube Analysis / sonarqube (pull_request) Successful in 4m23s
Tests / backend-unit (pull_request) Successful in 1m6s
Tests / backend-integration (pull_request) Failing after 4m55s
Tests / frontend-e2e (pull_request) Failing after 12m41s
fix(admin): theme, American English, and inventory/reservation tooling (#27)
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>
2026-08-17 15:49:05 -05:00

108 lines
3.9 KiB
TypeScript

import { useMemo, useState } from 'react';
import TreeSelect from 'antd/es/tree-select';
import Input from 'antd/es/input';
import Button from 'antd/es/button';
import Divider from 'antd/es/divider';
import message from 'antd/es/message';
import { Category, createCategory, fetchAdminCategories } from '../api';
import { buildCategoryTree, CategoryNode } from '../filters';
interface CategoryTreeOption {
value: number;
title: string;
children?: CategoryTreeOption[];
}
function toTreeData(nodes: CategoryNode[]): CategoryTreeOption[] {
return nodes.map((node) => ({
value: node.id,
title: node.name,
children: node.children.length ? toTreeData(node.children) : undefined
}));
}
interface Props {
// Supplied by antd's Form.Item. `id` has to be forwarded or the field loses
// its association with the rendered <label>, which breaks both screen readers
// and any lookup by label.
value?: number;
onChange?: (value: number | undefined) => void;
id?: string;
categories: Category[];
onCategoriesChanged: (categories: Category[]) => void;
}
// Pulled out of the item form so that typing a new category name re-renders
// only this control. Left inline, every keystroke re-rendered the whole
// Inventory component and rebuilt the tree data, which visibly jittered the
// open popup and moved the buttons under the pointer.
export default function CategoryTreeSelect({ value, onChange, id, categories, onCategoriesChanged }: Props) {
const [newCategoryName, setNewCategoryName] = useState('');
const [creating, setCreating] = useState(false);
const treeData = useMemo(() => toTreeData(buildCategoryTree(categories)), [categories]);
// Tags can be invented from the item form, so categories should be too —
// otherwise adding an item in a new category means abandoning a half-filled
// form. New categories land at the top level; nesting is done in the
// Categories tab, keeping this control to a single decision.
async function handleCreate() {
const name = newCategoryName.trim();
if (!name) return;
setCreating(true);
try {
const created = await createCategory(name, null);
onCategoriesChanged(await fetchAdminCategories());
onChange?.(created.id);
setNewCategoryName('');
message.success(`Category "${created.name}" added`);
} catch (err) {
message.error(`Couldn't create category — ${(err as Error).message}`);
} finally {
setCreating(false);
}
}
return (
<TreeSelect
allowClear
showSearch
placeholder="Uncategorized"
// Deliberately not treeDefaultExpandAll: expanding a large tree on open
// makes the popup reflow while it measures, and buries the create field
// below every row. Search is the better affordance past a screenful.
treeNodeFilterProp="title"
listHeight={256}
treeData={treeData}
id={id}
value={value}
onChange={onChange}
style={{ width: '100%' }}
popupRender={(menu) => (
<>
{/* Above the tree, not below it. Under a long list the control is
both invisible without scrolling and positioned by the list's
measured height, so it shifts as the virtualized rows settle. */}
<div style={{ display: 'flex', gap: 8, padding: '8px 8px 0' }}>
<Input
placeholder="New category name"
value={newCategoryName}
onChange={(event) => setNewCategoryName(event.target.value)}
// Without this the tree steals the keystrokes for its own
// type-ahead and arrow-key navigation.
onKeyDown={(event) => event.stopPropagation()}
onPressEnter={handleCreate}
/>
<Button type="primary" loading={creating} onClick={handleCreate}>
Create category
</Button>
</div>
<Divider style={{ margin: '8px 0' }} />
{menu}
</>
)}
/>
);
}