Files
redefined-designs/frontend/src/filters.ts
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

125 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 '';
}