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>
125 lines
4.5 KiB
TypeScript
125 lines
4.5 KiB
TypeScript
import type { Category } from './api';
|
||
|
||
export type ItemStatus = 'available' | 'reserved' | 'sold';
|
||
|
||
export interface ItemFilters {
|
||
categoryId: number | null;
|
||
tagIds: number[];
|
||
minPriceCents: number | null;
|
||
maxPriceCents: number | null;
|
||
// Only the admin Inventory tab sets this; the storefront leaves it null and
|
||
// shows every status, as it always has.
|
||
status: ItemStatus | null;
|
||
}
|
||
|
||
export const EMPTY_FILTERS: ItemFilters = {
|
||
categoryId: null,
|
||
tagIds: [],
|
||
minPriceCents: null,
|
||
maxPriceCents: null,
|
||
status: null
|
||
};
|
||
|
||
// Filters live in the URL so a filtered view can be linked, bookmarked, and
|
||
// walked back through with the browser's back button. The param names match
|
||
// what GET /api/items accepts, so the same object serializes for both.
|
||
export function filtersToSearchParams(filters: ItemFilters): URLSearchParams {
|
||
const params = new URLSearchParams();
|
||
if (filters.categoryId !== null) params.set('category', String(filters.categoryId));
|
||
if (filters.tagIds.length) params.set('tags', filters.tagIds.join(','));
|
||
if (filters.minPriceCents !== null) params.set('min_price', String(filters.minPriceCents));
|
||
if (filters.maxPriceCents !== null) params.set('max_price', String(filters.maxPriceCents));
|
||
if (filters.status !== null) params.set('status', filters.status);
|
||
return params;
|
||
}
|
||
|
||
function readInt(raw: string | null): number | null {
|
||
if (raw === null || raw.trim() === '') return null;
|
||
const parsed = Number(raw);
|
||
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
||
}
|
||
|
||
export function filtersFromSearchParams(params: URLSearchParams): ItemFilters {
|
||
const tags = (params.get('tags') || '')
|
||
.split(',')
|
||
.map((part) => readInt(part))
|
||
.filter((id): id is number => id !== null && id > 0);
|
||
|
||
const rawStatus = params.get('status');
|
||
const status = rawStatus === 'available' || rawStatus === 'reserved' || rawStatus === 'sold'
|
||
? rawStatus
|
||
: null;
|
||
|
||
return {
|
||
categoryId: readInt(params.get('category')),
|
||
tagIds: tags,
|
||
minPriceCents: readInt(params.get('min_price')),
|
||
maxPriceCents: readInt(params.get('max_price')),
|
||
status
|
||
};
|
||
}
|
||
|
||
// One count for the "Filters (N)" button. A price range counts once however
|
||
// many ends are set, since it reads as a single filter to the user.
|
||
export function activeFilterCount(filters: ItemFilters): number {
|
||
let count = 0;
|
||
if (filters.categoryId !== null) count++;
|
||
count += filters.tagIds.length;
|
||
if (filters.minPriceCents !== null || filters.maxPriceCents !== null) count++;
|
||
if (filters.status !== null) count++;
|
||
return count;
|
||
}
|
||
|
||
export function hasActiveFilters(filters: ItemFilters): boolean {
|
||
return activeFilterCount(filters) > 0;
|
||
}
|
||
|
||
export interface CategoryNode extends Category {
|
||
children: CategoryNode[];
|
||
}
|
||
|
||
// The API returns categories flat; the tree is rebuilt here so the drawer and
|
||
// the admin tab share one nesting implementation.
|
||
export function buildCategoryTree(categories: Category[]): CategoryNode[] {
|
||
const byId = new Map<number, CategoryNode>();
|
||
for (const category of categories) {
|
||
byId.set(category.id, { ...category, children: [] });
|
||
}
|
||
|
||
const roots: CategoryNode[] = [];
|
||
for (const node of byId.values()) {
|
||
const parent = node.parent_id === null ? undefined : byId.get(node.parent_id);
|
||
// A node whose parent is missing is treated as a root rather than dropped,
|
||
// so nothing can silently disappear from the tree.
|
||
if (parent) {
|
||
parent.children.push(node);
|
||
} else {
|
||
roots.push(node);
|
||
}
|
||
}
|
||
return roots;
|
||
}
|
||
|
||
// "Furniture / Tables / Coffee Tables" — used on chips and in the admin form so
|
||
// a leaf name like "Vintage" isn't ambiguous between branches.
|
||
export function categoryPath(categories: Category[], id: number): string {
|
||
const byId = new Map(categories.map((category) => [category.id, category]));
|
||
const parts: string[] = [];
|
||
let current = byId.get(id);
|
||
while (current) {
|
||
parts.unshift(current.name);
|
||
current = current.parent_id === null ? undefined : byId.get(current.parent_id);
|
||
// Guards against a cycle that somehow reached the client.
|
||
if (parts.length > 32) break;
|
||
}
|
||
return parts.join(' / ');
|
||
}
|
||
|
||
export function formatPriceRange(minCents: number | null, maxCents: number | null): string {
|
||
const dollars = (cents: number) => `$${(cents / 100).toFixed(0)}`;
|
||
if (minCents !== null && maxCents !== null) return `${dollars(minCents)}–${dollars(maxCents)}`;
|
||
if (minCents !== null) return `${dollars(minCents)}+`;
|
||
if (maxCents !== null) return `Up to ${dollars(maxCents)}`;
|
||
return '';
|
||
}
|