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>
1054 lines
37 KiB
Markdown
1054 lines
37 KiB
Markdown
---
|
||
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>
|