feat(ui): storefront filters and admin category/tag management (#23)
SonarQube Analysis / sonarqube (pull_request) Successful in 2m49s
Tests / backend-unit (pull_request) Successful in 37s
Tests / backend-integration (pull_request) Failing after 3h2m23s
Tests / frontend-e2e (pull_request) Failing after 3m54s

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:
2026-08-17 09:34:37 -05:00
co-authored by Claude Opus 5
parent 9222e97deb
commit d28fb5634a
13 changed files with 1374 additions and 22 deletions
+96 -2
View File
@@ -1,3 +1,12 @@
import type { ItemFilters } from './filters';
import { filtersToSearchParams } from './filters';
export interface ItemTag {
id: number;
name: string;
color: string;
}
export interface Item {
id: number;
name: string;
@@ -5,6 +14,30 @@ export interface Item {
price_cents: number;
images: { id: number; image_path: string; sort_order: number }[];
status: 'available' | 'reserved' | 'sold';
category_id: number | null;
category_name: string | null;
tags: ItemTag[];
}
export interface Category {
id: number;
name: string;
parent_id: number | null;
sort_order: number;
item_count: number;
}
export interface Tag {
id: number;
name: string;
color: string;
item_count: number;
}
export interface FilterOptions {
categories: Category[];
tags: Tag[];
priceRange: { min_cents: number; max_cents: number };
}
export interface SiteConfig {
@@ -18,8 +51,14 @@ export async function fetchConfig(): Promise<SiteConfig> {
return res.json();
}
export async function fetchItems(): Promise<Item[]> {
const res = await fetch('/api/items');
export async function fetchItems(filters?: ItemFilters): Promise<Item[]> {
const query = filters ? filtersToSearchParams(filters).toString() : '';
const res = await fetch(query ? `/api/items?${query}` : '/api/items');
return res.json();
}
export async function fetchFilterOptions(): Promise<FilterOptions> {
const res = await fetch('/api/filters');
return res.json();
}
@@ -51,3 +90,58 @@ export async function markAvailable(id: number): Promise<Item> {
const res = await fetch(`/api/admin/items/${id}/mark-available`, { method: 'POST' });
return res.json();
}
// The admin endpoints return a JSON error body on 4xx; surfacing its message
// lets the UI say "that name is already used here" instead of a generic
// failure.
async function sendJson<T>(url: string, method: string, body?: unknown): Promise<T> {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body)
});
if (!res.ok) {
const detail = await res.json().catch(() => ({ error: 'request failed' }));
throw new Error(detail.error || 'request failed');
}
return res.status === 204 ? (undefined as T) : res.json();
}
export async function fetchAdminCategories(): Promise<Category[]> {
const res = await fetch('/api/admin/categories');
return res.json();
}
export function createCategory(name: string, parentId: number | null): Promise<Category> {
return sendJson('/api/admin/categories', 'POST', { name, parent_id: parentId });
}
export function updateCategory(
id: number,
changes: { name?: string; parent_id?: number | null }
): Promise<Category> {
return sendJson(`/api/admin/categories/${id}`, 'PUT', changes);
}
export function deleteCategory(
id: number
): Promise<{ deleted_categories: number; uncategorized_items: number }> {
return sendJson(`/api/admin/categories/${id}`, 'DELETE');
}
export async function fetchAdminTags(): Promise<Tag[]> {
const res = await fetch('/api/admin/tags');
return res.json();
}
export function createTag(name: string): Promise<Tag> {
return sendJson('/api/admin/tags', 'POST', { name });
}
export function updateTag(id: number, changes: { name?: string; color?: string }): Promise<Tag> {
return sendJson(`/api/admin/tags/${id}`, 'PUT', changes);
}
export function deleteTag(id: number): Promise<void> {
return sendJson(`/api/admin/tags/${id}`, 'DELETE');
}