Files
GearBox/.planning/phases/37-admin-global-item-management/37-02-PLAN.md
Jean-Luc Makiola eabfca475c docs(37): write wave plan files for admin global item management
Plans 37-01 (server: services + admin-items routes) and 37-02 (client:
hooks, list page, edit page, sidebar) with full acceptance criteria and
read_first blocks per phase context, research, and UI-SPEC artifacts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 21:28:46 +02:00

1054 lines
37 KiB
Markdown
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.
---
phase: 37
plan: "02"
title: "Client — Admin Items List, Edit Page & Sidebar"
type: execute
wave: 2
depends_on:
- "37-01"
files_modified:
- src/client/hooks/useAdminGlobalItems.ts
- src/client/routes/admin.tsx
- src/client/routes/admin/items.tsx
- src/client/routes/admin/items.$itemId.tsx
autonomous: true
requirements:
- ADMN-02
- ADMN-03
- ADMN-04
---
# Plan 37-02: Client — Admin Items List, Edit Page & Sidebar
## Objective
Build the client side of the admin global item management feature: a `useAdminGlobalItems` hooks file, the `/admin/items` list page with infinite scroll, the `/admin/items/$itemId` edit page with all fields and impact-aware delete confirmation, and activate the Items sidebar link in the admin shell. All styling matches the UI-SPEC (Tailwind classes specified explicitly).
---
<tasks>
<task id="37-02-T1">
<title>Create useAdminGlobalItems hooks file</title>
<type>execute</type>
<read_first>
- `src/client/hooks/useGlobalItems.ts` — read as pattern reference: `useQuery`/`useMutation` pattern, `apiGet`/`apiDelete`/`apiPut` usage, `ApiError` import, queryClient invalidation
- `src/client/lib/api.ts` — confirm `apiGet`, `apiPut`, `apiDelete` signatures
</read_first>
<action>
Create `src/client/hooks/useAdminGlobalItems.ts` with the following content:
```typescript
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { ApiError, apiDelete, apiGet, apiPut } from "../lib/api";
// ── Types ──────────────────────────────────────────────────────────
export interface AdminGlobalItem {
id: number;
manufacturerId: number;
brand: string;
model: string;
category: string | null;
weightGrams: number | null;
priceCents: number | null;
imageUrl: string | null;
description: string | null;
sourceUrl: string | null;
imageCredit: string | null;
imageSourceUrl: string | null;
dominantColor: string | null;
cropZoom: number | null;
cropX: number | null;
cropY: number | null;
createdAt: string;
tags: string[];
ownerCount: number;
}
export interface AdminGlobalItemPage {
items: AdminGlobalItem[];
total: number;
hasMore: boolean;
nextOffset: number;
}
export interface AdminGlobalItemDetail extends Omit<AdminGlobalItem, "tags"> {
ownerCount: number;
}
export interface UpdateGlobalItemPayload {
manufacturerId?: number;
model?: string;
category?: string | null;
weightGrams?: number | null;
priceCents?: number | null;
imageUrl?: string | null;
description?: string | null;
sourceUrl?: string | null;
imageCredit?: string | null;
imageSourceUrl?: string | null;
tags?: string[];
}
// ── Hooks ──────────────────────────────────────────────────────────
export function useAdminGlobalItems(query?: string, tagNames?: string[]) {
const params = new URLSearchParams();
if (query) params.set("q", query);
if (tagNames && tagNames.length > 0) params.set("tags", tagNames.join(","));
params.set("limit", "50");
const qs = params.toString();
return useInfiniteQuery({
queryKey: ["admin-global-items", query ?? "", tagNames ?? []],
queryFn: ({ pageParam = 0 }) =>
apiGet<AdminGlobalItemPage>(
`/api/admin/items?offset=${pageParam}&${qs}`,
),
getNextPageParam: (lastPage) =>
lastPage.hasMore ? lastPage.nextOffset : undefined,
initialPageParam: 0,
});
}
export function useAdminGlobalItem(id: number | null) {
return useQuery({
queryKey: ["admin-global-item", id],
queryFn: () =>
apiGet<AdminGlobalItemDetail>(`/api/admin/items/${id}`),
enabled: id != null,
retry: (count, error) =>
error instanceof ApiError && error.status === 404 ? false : count < 3,
});
}
export function useUpdateAdminGlobalItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
data,
}: {
id: number;
data: UpdateGlobalItemPayload;
}) => apiPut<AdminGlobalItemDetail>(`/api/admin/items/${id}`, data),
onSuccess: (_result, { id }) => {
queryClient.invalidateQueries({ queryKey: ["admin-global-items"] });
queryClient.invalidateQueries({ queryKey: ["admin-global-item", id] });
},
});
}
export function useDeleteAdminGlobalItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiDelete<{ success: boolean }>(`/api/admin/items/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin-global-items"] });
},
});
}
```
</action>
<acceptance_criteria>
- File `src/client/hooks/useAdminGlobalItems.ts` exists
- File exports `useAdminGlobalItems` (uses `useInfiniteQuery` with `initialPageParam: 0`)
- File exports `useAdminGlobalItem` (uses `useQuery`, enabled only when id is not null)
- File exports `useUpdateAdminGlobalItem` (uses `useMutation` with `apiPut`)
- File exports `useDeleteAdminGlobalItem` (uses `useMutation` with `apiDelete`)
- `bun run build` exits 0 after this task
</acceptance_criteria>
</task>
<task id="37-02-T2">
<title>Activate Items sidebar link in admin.tsx</title>
<type>execute</type>
<read_first>
- `src/client/routes/admin.tsx` — read entire file; identify the disabled Items `<div>` block (lines ~32-40) that must be replaced with an active `<Link>`; understand existing imports (`createFileRoute`, `Outlet`, `useNavigate`, `useEffect`, `useAuth`, `LucideIcon`)
</read_first>
<action>
Edit `src/client/routes/admin.tsx`:
1. Add `Link` to the `@tanstack/react-router` import line:
```typescript
import { Link, createFileRoute, Outlet, useNavigate } from "@tanstack/react-router";
```
2. Replace the disabled Items `<div>` block:
```tsx
{/* Items — disabled (phase 37) */}
<div
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-300 cursor-not-allowed"
title="Coming in a future release"
>
<LucideIcon name="package" size={16} />
<span>Items</span>
<span className="ml-auto text-xs bg-gray-100 text-gray-400 px-1.5 py-0.5 rounded">
Soon
</span>
</div>
```
With:
```tsx
{/* Items — active (phase 37) */}
<Link
to="/admin/items"
activeProps={{ className: "bg-gray-100 text-gray-900 font-medium" }}
inactiveProps={{ className: "text-gray-600 hover:bg-gray-50" }}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors"
>
<LucideIcon name="package" size={16} />
<span>Items</span>
</Link>
```
</action>
<acceptance_criteria>
- `src/client/routes/admin.tsx` imports `Link` from `@tanstack/react-router`
- File contains `<Link` with `to="/admin/items"` (not the old `<div cursor-not-allowed>`)
- File contains `activeProps={{ className: "bg-gray-100 text-gray-900 font-medium" }}`
- File does NOT contain `cursor-not-allowed` on the Items entry
- File does NOT contain the "Soon" badge span for Items
- `bun run build` exits 0 after this task
</acceptance_criteria>
</task>
<task id="37-02-T3">
<title>Create admin items list route (/admin/items)</title>
<type>execute</type>
<read_first>
- `src/client/routes/admin/index.tsx` — read as reference for the file-based route pattern in the admin directory; confirm `createFileRoute` usage
- `src/client/hooks/useAdminGlobalItems.ts` — the hooks file created in T1 (AdminGlobalItem type, useAdminGlobalItems, hook return shape)
- `src/client/lib/iconData.ts` — confirm `LucideIcon` export
- `src/client/hooks/useFormatters.ts` — confirm `useFormatters()` hook and its `formatWeight`/`formatPrice` methods
- `src/client/routes/admin.tsx` — confirm the `<main>` wrapper uses `bg-gray-50 p-6`; the list page renders inside `<Outlet />`
</read_first>
<action>
Create `src/client/routes/admin/items.tsx` with the following content:
```tsx
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { useAdminGlobalItems } from "../../hooks/useAdminGlobalItems";
import { useFormatters } from "../../hooks/useFormatters";
import { useTags } from "../../hooks/useTags";
export const Route = createFileRoute("/admin/items")({
component: AdminItemsPage,
});
function AdminItemsPage() {
const navigate = useNavigate();
const { weight: formatWeight, price: formatPrice } = useFormatters();
const [searchQuery, setSearchQuery] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [debouncedQuery, setDebouncedQuery] = useState("");
const sentinelRef = useRef<HTMLDivElement>(null);
// Debounce search input
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(searchQuery), 300);
return () => clearTimeout(timer);
}, [searchQuery]);
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
} = useAdminGlobalItems(
debouncedQuery || undefined,
selectedTags.length > 0 ? selectedTags : undefined,
);
const { data: allTags } = useTags();
// Infinite scroll sentinel
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 0.1 },
);
observer.observe(el);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const allItems = data?.pages.flatMap((p) => p.items) ?? [];
const total = data?.pages[0]?.total ?? 0;
function toggleTag(name: string) {
setSelectedTags((prev) =>
prev.includes(name) ? prev.filter((t) => t !== name) : [...prev, name],
);
}
return (
<div>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-lg font-semibold text-gray-900">Catalog Items</h1>
{!isLoading && (
<p className="text-sm text-gray-400 mt-0.5">
{total.toLocaleString()} items
</p>
)}
</div>
<input
type="text"
placeholder="Search catalog..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-64 rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-300"
/>
</div>
{/* Tag filters */}
{allTags && allTags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{allTags.map((tag) => (
<button
key={tag.id}
onClick={() => toggleTag(tag.name)}
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors ${
selectedTags.includes(tag.name)
? "bg-blue-50 text-blue-600 border border-blue-200"
: "bg-gray-100 text-gray-600"
}`}
>
{tag.name}
</button>
))}
</div>
)}
{/* Error state */}
{isError && (
<div className="py-12 text-center text-sm text-red-500">
Failed to load catalog items. Please try again.
</div>
)}
{/* Table */}
{!isError && (
<div className="w-full overflow-hidden rounded-xl border border-gray-100 bg-white">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-100">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide">
Brand / Model
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide">
Category
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide">
Weight
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide">
Price
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide">
Tags
</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-400 uppercase tracking-wide">
Owners
</th>
</tr>
</thead>
<tbody>
{isLoading
? Array.from({ length: 6 }).map((_, i) => (
<tr key={i} className="border-b border-gray-50">
{Array.from({ length: 6 }).map((_, j) => (
<td key={j} className="px-4 py-3">
<div className="h-4 bg-gray-100 rounded animate-pulse" />
</td>
))}
</tr>
))
: allItems.map((item) => (
<tr
key={item.id}
className="border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"
onClick={() =>
navigate({ to: "/admin/items/$itemId", params: { itemId: String(item.id) } })
}
>
<td className="px-4 py-3">
<span className="font-medium text-gray-900">{item.brand}</span>
<span className="text-gray-500 ml-1">{item.model}</span>
</td>
<td className="px-4 py-3 text-gray-700">
{item.category ?? <span className="text-gray-300"></span>}
</td>
<td className="px-4 py-3 text-gray-700">
{item.weightGrams != null
? formatWeight(item.weightGrams)
: <span className="text-gray-300"></span>}
</td>
<td className="px-4 py-3 text-gray-700">
{item.priceCents != null
? formatPrice(item.priceCents)
: <span className="text-gray-300"></span>}
</td>
<td className="px-4 py-3">
{item.tags.length === 0 ? (
<span className="text-gray-300"></span>
) : item.tags.length <= 2 ? (
<div className="flex flex-wrap gap-1">
{item.tags.map((t) => (
<span
key={t}
className="text-xs bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full"
>
{t}
</span>
))}
</div>
) : (
<span className="text-xs bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full">
+{item.tags.length}
</span>
)}
</td>
<td className="px-4 py-3 text-right">
<span
className={
item.ownerCount === 0
? "text-gray-300"
: "text-gray-500"
}
>
{item.ownerCount}
</span>
</td>
</tr>
))}
</tbody>
</table>
{/* Empty state (after load, no items) */}
{!isLoading && allItems.length === 0 && !isError && (
<div className="py-12 text-center">
<p className="text-sm font-medium text-gray-900">No items found</p>
<p className="text-sm text-gray-400 mt-1">
Try a different search or clear your filters.
</p>
</div>
)}
{/* Infinite scroll sentinel */}
<div ref={sentinelRef} className="h-4" />
{/* Loading more */}
{isFetchingNextPage && (
<div className="py-4 text-center text-sm text-gray-400">Loading...</div>
)}
{/* All loaded message */}
{!isLoading && !hasNextPage && allItems.length > 0 && (
<div className="py-4 text-center text-sm text-gray-400">
All {total.toLocaleString()} items loaded
</div>
)}
</div>
)}
</div>
);
}
```
Note: `useTags` hook — check if it exists in `src/client/hooks/useTags.ts`. If not, replace `useTags()` with a direct `useQuery` call to `/api/tags`.
</action>
<acceptance_criteria>
- File `src/client/routes/admin/items.tsx` exists
- File exports route via `createFileRoute("/admin/items")(`
- File imports and calls `useAdminGlobalItems` with `useInfiniteQuery` (via the hook)
- File contains `sentinelRef` and `IntersectionObserver` for infinite scroll
- File contains the data table with columns: Brand/Model, Category, Weight, Price, Tags, Owners
- File contains skeleton loading rows (`animate-pulse`)
- File contains empty state with "No items found" text
- Row click calls `navigate` to `/admin/items/$itemId`
- `bun run build` exits 0 after this task (routeTree.gen.ts auto-updated by Vite)
</acceptance_criteria>
</task>
<task id="37-02-T4">
<title>Create admin item edit route (/admin/items/$itemId)</title>
<type>execute</type>
<read_first>
- `src/client/hooks/useAdminGlobalItems.ts` — hooks file from T1: `useAdminGlobalItem`, `useUpdateAdminGlobalItem`, `useDeleteAdminGlobalItem`, `UpdateGlobalItemPayload`, `AdminGlobalItemDetail` types
- `src/client/routes/admin/items.tsx` — the list route just created (T3) to understand navigation patterns
- `src/server/routes/manufacturers.ts``GET /api/manufacturers` returns `{ id, name, slug }[]`; client needs to fetch this list for the brand dropdown
- `src/client/lib/api.ts``apiGet` for fetching manufacturers inline
</read_first>
<action>
Create `src/client/routes/admin/items.$itemId.tsx` with the following content:
```tsx
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import {
useAdminGlobalItem,
useDeleteAdminGlobalItem,
useUpdateAdminGlobalItem,
} from "../../hooks/useAdminGlobalItems";
import { apiGet } from "../../lib/api";
export const Route = createFileRoute("/admin/items/$itemId")({
component: AdminItemEditPage,
});
interface Manufacturer {
id: number;
name: string;
slug: string;
}
// ── Tag chip input ─────────────────────────────────────────────────
function TagInput({
value,
onChange,
}: {
value: string[];
onChange: (tags: string[]) => void;
}) {
const [inputValue, setInputValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
function addTag(raw: string) {
const tag = raw.trim().toLowerCase().replace(/\s+/g, "-");
if (tag && !value.includes(tag)) {
onChange([...value, tag]);
}
setInputValue("");
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addTag(inputValue);
} else if (e.key === "Backspace" && inputValue === "" && value.length > 0) {
onChange(value.slice(0, -1));
}
}
function removeTag(tag: string) {
onChange(value.filter((t) => t !== tag));
}
return (
<div
className="flex flex-wrap gap-2 rounded-lg border border-gray-200 px-3 py-2 min-h-[40px] cursor-text"
onClick={() => inputRef.current?.focus()}
>
{value.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 bg-gray-100 text-gray-700 text-xs px-2 py-1 rounded-full"
>
{tag}
<button
type="button"
onClick={(e) => { e.stopPropagation(); removeTag(tag); }}
className="text-gray-400 hover:text-gray-600"
>
×
</button>
</span>
))}
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => { if (inputValue) addTag(inputValue); }}
placeholder={value.length === 0 ? "Add tags..." : ""}
className="outline-none bg-transparent text-sm flex-1 min-w-[100px]"
/>
</div>
);
}
// ── Main edit page ─────────────────────────────────────────────────
function AdminItemEditPage() {
const { itemId } = Route.useParams();
const id = Number(itemId);
const navigate = useNavigate();
const { data: item, isLoading, isError } = useAdminGlobalItem(isNaN(id) ? null : id);
const updateMutation = useUpdateAdminGlobalItem();
const deleteMutation = useDeleteAdminGlobalItem();
const [manufacturers, setManufacturers] = useState<Manufacturer[]>([]);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// Form state
const [form, setForm] = useState({
manufacturerId: 0,
model: "",
category: "",
weightGrams: "",
priceCents: "",
imageUrl: "",
description: "",
sourceUrl: "",
imageCredit: "",
imageSourceUrl: "",
tags: [] as string[],
});
// Populate form when item loads
useEffect(() => {
if (item) {
setForm({
manufacturerId: item.manufacturerId,
model: item.model,
category: item.category ?? "",
weightGrams: item.weightGrams != null ? String(item.weightGrams) : "",
priceCents: item.priceCents != null ? String(item.priceCents / 100) : "",
imageUrl: item.imageUrl ?? "",
description: item.description ?? "",
sourceUrl: item.sourceUrl ?? "",
imageCredit: item.imageCredit ?? "",
imageSourceUrl: item.imageSourceUrl ?? "",
tags: [],
});
}
}, [item]);
// Fetch manufacturers for dropdown
useEffect(() => {
apiGet<Manufacturer[]>("/api/manufacturers").then(setManufacturers).catch(() => {});
}, []);
function handleChange(
field: keyof typeof form,
value: string | number | string[],
) {
setForm((prev) => ({ ...prev, [field]: value }));
}
async function handleSave(e: React.FormEvent) {
e.preventDefault();
const weightGrams = form.weightGrams !== "" ? Number(form.weightGrams) : null;
const priceCents =
form.priceCents !== "" ? Math.round(Number(form.priceCents) * 100) : null;
await updateMutation.mutateAsync({
id,
data: {
manufacturerId: form.manufacturerId || undefined,
model: form.model || undefined,
category: form.category || null,
weightGrams: weightGrams,
priceCents: priceCents,
imageUrl: form.imageUrl || null,
description: form.description || null,
sourceUrl: form.sourceUrl || null,
imageCredit: form.imageCredit || null,
imageSourceUrl: form.imageSourceUrl || null,
tags: form.tags,
},
});
}
async function handleDelete() {
await deleteMutation.mutateAsync(id);
navigate({ to: "/admin/items" });
}
const inputClass =
"w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-300";
const labelClass = "block text-sm font-medium text-gray-700 mb-1";
const sectionClass = "border-t border-gray-100 pt-6 mt-6";
if (isLoading) {
return (
<div className="max-w-2xl mx-auto">
<div className="h-4 w-16 bg-gray-100 rounded animate-pulse mb-6" />
<div className="space-y-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-10 bg-gray-100 rounded-lg animate-pulse" />
))}
</div>
</div>
);
}
if (isError || !item) {
return (
<div className="max-w-2xl mx-auto text-center py-12">
<p className="text-sm text-red-500">Failed to load item. Please try again.</p>
</div>
);
}
const ownerText =
item.ownerCount === 0
? "Not in any collection"
: item.ownerCount === 1
? "1 user in collection"
: `${item.ownerCount} users in collection`;
return (
<div className="max-w-2xl mx-auto">
{/* Back link */}
<button
type="button"
onClick={() => navigate({ to: "/admin/items" })}
className="text-sm text-gray-400 hover:text-gray-600 transition-colors mb-6 block"
>
Items
</button>
{/* Page heading */}
<div className="mb-6">
<h1 className="text-lg font-semibold text-gray-900">
{item.brand} {item.model}
</h1>
<p className="text-sm text-gray-400 mt-0.5">{ownerText}</p>
</div>
<form onSubmit={handleSave}>
{/* Image section */}
<div>
{item.imageUrl && (
<img
src={item.imageUrl}
alt={`${item.brand} ${item.model}`}
className="w-full h-48 object-contain rounded-lg bg-gray-50 mb-3"
/>
)}
<label className={labelClass}>Image URL</label>
<input
type="url"
value={form.imageUrl}
onChange={(e) => handleChange("imageUrl", e.target.value)}
className={inputClass}
placeholder="https://..."
/>
</div>
{/* Brand + Model */}
<div className={sectionClass}>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Brand (Manufacturer)</label>
<select
value={form.manufacturerId}
onChange={(e) => handleChange("manufacturerId", Number(e.target.value))}
className={`${inputClass} appearance-none bg-white`}
>
<option value={0}>Select manufacturer...</option>
{manufacturers.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</div>
<div>
<label className={labelClass}>Model</label>
<input
type="text"
value={form.model}
onChange={(e) => handleChange("model", e.target.value)}
className={inputClass}
placeholder="e.g. Woodsmoke 700"
/>
</div>
</div>
<div className="mt-4">
<label className={labelClass}>Category</label>
<input
type="text"
value={form.category}
onChange={(e) => handleChange("category", e.target.value)}
className={inputClass}
placeholder="e.g. Bikepacking Bags"
/>
</div>
</div>
{/* Weight + Price */}
<div className={sectionClass}>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Weight (g)</label>
<input
type="number"
value={form.weightGrams}
onChange={(e) => handleChange("weightGrams", e.target.value)}
className={inputClass}
placeholder="e.g. 450"
min="0"
step="1"
/>
</div>
<div>
<label className={labelClass}>Price ()</label>
<input
type="number"
value={form.priceCents}
onChange={(e) => handleChange("priceCents", e.target.value)}
className={inputClass}
placeholder="e.g. 129.99"
min="0"
step="0.01"
/>
</div>
</div>
</div>
{/* Tags + Description + Source */}
<div className={sectionClass}>
<div className="mb-4">
<label className={labelClass}>Tags</label>
<TagInput
value={form.tags}
onChange={(tags) => handleChange("tags", tags)}
/>
</div>
<div className="mb-4">
<label className={labelClass}>Description</label>
<textarea
value={form.description}
onChange={(e) => handleChange("description", e.target.value)}
className={`${inputClass} min-h-[80px] resize-y`}
placeholder="Brief description of the item..."
/>
</div>
<div className="mb-4">
<label className={labelClass}>Source URL</label>
<input
type="url"
value={form.sourceUrl}
onChange={(e) => handleChange("sourceUrl", e.target.value)}
className={inputClass}
placeholder="https://manufacturer.com/product"
/>
</div>
<div className="mb-4">
<label className={labelClass}>Image Credit</label>
<input
type="text"
value={form.imageCredit}
onChange={(e) => handleChange("imageCredit", e.target.value)}
className={inputClass}
placeholder="e.g. © Manufacturer Name"
/>
</div>
<div>
<label className={labelClass}>Image Source URL</label>
<input
type="url"
value={form.imageSourceUrl}
onChange={(e) => handleChange("imageSourceUrl", e.target.value)}
className={inputClass}
placeholder="https://..."
/>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-between mt-8 pt-6 border-t border-gray-100">
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
disabled={deleteMutation.isPending}
className="px-4 py-2 rounded-lg border border-red-200 text-red-600 hover:bg-red-50 text-sm font-medium transition-colors disabled:opacity-50"
>
Delete Item
</button>
<button
type="submit"
disabled={updateMutation.isPending}
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition-colors disabled:opacity-50"
>
{updateMutation.isPending ? "Saving..." : "Save Changes"}
</button>
</div>
{/* Save error */}
{updateMutation.isError && (
<p className="text-sm text-red-500 mt-2 text-right">
Failed to save. Please try again.
</p>
)}
</form>
{/* Delete confirmation dialog */}
{showDeleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/30"
onClick={() => setShowDeleteConfirm(false)}
/>
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
<h2 className="text-lg font-semibold text-gray-900 mb-2">
Delete {item.brand} {item.model}?
</h2>
<p className="text-sm text-gray-600 mb-6">
{item.ownerCount === 0
? "This item is not in any collection. This cannot be undone."
: `${item.ownerCount} ${item.ownerCount === 1 ? "user has" : "users have"} this item in their collection. This cannot be undone.`}
</p>
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={() => setShowDeleteConfirm(false)}
disabled={deleteMutation.isPending}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg disabled:opacity-50"
>
{deleteMutation.isPending ? "Deleting..." : "Delete"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
```
</action>
<acceptance_criteria>
- File `src/client/routes/admin/items.$itemId.tsx` exists
- File exports route via `createFileRoute("/admin/items/$itemId")(`
- File imports `useAdminGlobalItem`, `useUpdateAdminGlobalItem`, `useDeleteAdminGlobalItem`
- File contains `TagInput` component with chip add/remove via keyboard (Enter/comma)
- File contains manufacturer `<select>` populated from `/api/manufacturers`
- File contains all form fields: brand, model, category, weightGrams, priceCents, imageUrl, description, sourceUrl, imageCredit, imageSourceUrl, tags
- File contains `showDeleteConfirm` state and the delete confirmation dialog
- Delete confirmation dialog shows `ownerCount` in the message body
- After successful delete, `navigate({ to: "/admin/items" })` is called
- File contains "← Items" back link
- Save button shows "Save Changes" (or "Saving..." when pending)
- Delete button shows "Delete Item" styled with `border-red-200 text-red-600`
- `bun run build` exits 0 after this task
</acceptance_criteria>
</task>
<task id="37-02-T5">
<title>Verify routeTree.gen.ts contains new admin routes</title>
<type>execute</type>
<read_first>
- `src/client/routeTree.gen.ts` — check if this file has already been regenerated by Vite/TanStack Router after T3 and T4; if not, trigger a build to regenerate
</read_first>
<action>
Run the build to ensure TanStack Router regenerates `routeTree.gen.ts` with the new routes:
```bash
bun run build
```
After the build completes, verify the file contains both new route entries:
```bash
grep -E "admin/items|adminItems" src/client/routeTree.gen.ts
```
The output should include entries for `/admin/items` and `/admin/items/$itemId`.
If `routeTree.gen.ts` does NOT contain these routes after build, inspect the route file names — TanStack Router file-based routing is strict about naming conventions. The files must be:
- `src/client/routes/admin/items.tsx``/admin/items`
- `src/client/routes/admin/items.$itemId.tsx``/admin/items/$itemId`
No manual edits to `routeTree.gen.ts` — it is auto-generated and must never be edited manually (per CLAUDE.md).
</action>
<acceptance_criteria>
- `bun run build` exits 0
- `grep -E "admin/items|adminItems" src/client/routeTree.gen.ts` returns at least 2 matches (one for items, one for items.$itemId)
- `grep "itemId" src/client/routeTree.gen.ts` returns at least 1 match confirming dynamic segment
</acceptance_criteria>
</task>
</tasks>
---
<verification>
## Wave 2 Verification
After all tasks in this plan complete:
1. **Build check:** `bun run build` exits 0
2. **Route tree:** `grep "admin/items" src/client/routeTree.gen.ts` shows both routes
3. **Sidebar link:** `grep 'to="/admin/items"' src/client/routes/admin.tsx` returns a match (Link component)
4. **Hook file:** `grep "useInfiniteQuery" src/client/hooks/useAdminGlobalItems.ts` returns a match
5. **Infinite scroll:** `grep "IntersectionObserver" src/client/routes/admin/items.tsx` returns a match
6. **Delete dialog:** `grep "showDeleteConfirm" src/client/routes/admin/items.\$itemId.tsx` returns a match
**Manual verification (dev server required):**
- Navigate to `/admin/items` as admin → table renders with catalog items
- Scroll to bottom → next page loads automatically
- Click a row → navigates to `/admin/items/{id}` edit page
- Edit page shows all fields populated with item data
- Click "Delete Item" → confirmation dialog appears with correct ownerCount
- Cancel dismisses dialog without deleting
- Confirm delete → item removed, navigate back to list
</verification>
<success_criteria>
- [ ] `useAdminGlobalItems.ts` hooks file with infinite query, detail query, update/delete mutations
- [ ] Admin sidebar "Items" entry replaced with active `<Link to="/admin/items">` (no "Soon" badge)
- [ ] `/admin/items` list page: data table, infinite scroll, search, tag filters, skeleton loading, empty state
- [ ] `/admin/items/$itemId` edit page: all fields, manufacturer dropdown, tag chip input, save/delete actions
- [ ] Delete confirmation dialog shows brand + model + ownerCount message
- [ ] After successful delete: navigate to `/admin/items`
- [ ] `routeTree.gen.ts` contains `/admin/items` and `/admin/items/$itemId` routes
- [ ] `bun run build` exits 0
- [ ] Requirements ADMN-02, ADMN-03, ADMN-04 fully implemented on the client
</success_criteria>
<must_haves>
- Admin can browse all global catalog items with search and tag filtering — ADMN-02
- Admin can edit a global catalog item via the edit page form — ADMN-03
- Admin can delete a global catalog item with impact-aware confirmation showing ownerCount — ADMN-04
- Sidebar Items link is active and navigable (no longer disabled/coming-soon)
- Infinite scroll loads next pages automatically as admin scrolls
</must_haves>