docs(37): add research, validation strategy, and UI design contract
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
256
.planning/phases/37-admin-global-item-management/37-RESEARCH.md
Normal file
256
.planning/phases/37-admin-global-item-management/37-RESEARCH.md
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
# Phase 37: Admin — Global Item Management — Research
|
||||||
|
|
||||||
|
**Phase:** 37 — Admin — Global Item Management
|
||||||
|
**Researched:** 2026-04-19
|
||||||
|
**Requirements:** ADMN-02, ADMN-03, ADMN-04
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This phase extends the admin shell built in Phase 36 with two new pages: a paginated list of all global catalog items (`/admin/items`) and a full-page edit form for each item (`/admin/items/$itemId`). The server side needs a `deleteGlobalItem` service function and admin-specific routes under `/api/admin/items`. The client side needs two new TanStack Router routes, a `useAdminGlobalItems` infinite query hook, and a `useAdminGlobalItem` + delete/update hooks. All primary patterns are already established in the codebase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Server-Side: What Needs to Be Added
|
||||||
|
|
||||||
|
### 1.1 Missing: `deleteGlobalItem` in global-item.service.ts
|
||||||
|
|
||||||
|
`src/server/services/global-item.service.ts` currently exports:
|
||||||
|
- `searchGlobalItems` — basic search with tag filter, no pagination
|
||||||
|
- `getGlobalItemWithOwnerCount` — single item with owner count
|
||||||
|
- `upsertGlobalItem` — create/update by (manufacturerId, model)
|
||||||
|
- `bulkUpsertGlobalItems` — bulk version
|
||||||
|
|
||||||
|
**Missing:** `deleteGlobalItem(db, id)` — DELETE from `globalItems` WHERE id. This is simple; no cascades defined on `globalItems` for `items.globalItemId` (it's a nullable reference, no `onDelete: cascade`). Deleting a global item will NOT cascade-delete user items — user items will have `globalItemId = null` after deletion (the FK is nullable). Verify FK behavior before writing the delete service.
|
||||||
|
|
||||||
|
Checking schema.ts: `items.globalItemId` is `integer("global_item_id").references(() => globalItems.id)` — no `onDelete` clause, which in PostgreSQL defaults to `RESTRICT` (not CASCADE). **This means deleting a global item that has owners will FAIL with a FK constraint violation.** The plan must handle this: either:
|
||||||
|
1. Null out `items.globalItemId` before deleting the global item (SET globalItemId = null WHERE globalItemId = id), OR
|
||||||
|
2. Block delete if ownerCount > 0, with the impact-aware confirmation dialog already planned in CONTEXT.md
|
||||||
|
|
||||||
|
Decision (from CONTEXT.md D-07): the confirmation dialog shows the ownerCount. But the FK constraint will still block deletion even after user confirmation. The service must null the FK references before deleting the global item. Pattern: in a transaction, `UPDATE items SET global_item_id = null WHERE global_item_id = $id`, then `DELETE FROM global_items WHERE id = $id`.
|
||||||
|
|
||||||
|
Also need to clean up `globalItemTags` — but those have `onDelete: cascade` from the schema? Let me verify: `globalItemTags.globalItemId` references `globalItems.id`. The schema does not show an explicit `onDelete` clause, so it also defaults to RESTRICT. The delete must explicitly delete `globalItemTags` rows before deleting the global item.
|
||||||
|
|
||||||
|
Correct delete order in a transaction:
|
||||||
|
1. `UPDATE items SET global_item_id = null WHERE global_item_id = id` — nullify user item links
|
||||||
|
2. `DELETE FROM global_item_tags WHERE global_item_id = id` — remove tag associations
|
||||||
|
3. `DELETE FROM global_items WHERE id = id` — safe to delete now
|
||||||
|
|
||||||
|
### 1.2 Admin Item Routes: `/api/admin/items`
|
||||||
|
|
||||||
|
Current `src/server/routes/admin.ts` only has `GET /` returning `{ ok: true }`. Must be extended (or a separate sub-router mounted at `/api/admin/items`).
|
||||||
|
|
||||||
|
Pattern from codebase: create a new `src/server/routes/admin-items.ts` router and mount it in `admin.ts` using `app.route("/items", adminItemRoutes)`. This keeps admin.ts clean and follows the same pattern as `global-items.ts`.
|
||||||
|
|
||||||
|
Required endpoints:
|
||||||
|
- `GET /api/admin/items` — paginated list (cursor-based or offset), search, tag filter
|
||||||
|
- `GET /api/admin/items/:id` — single item with owner count
|
||||||
|
- `PUT /api/admin/items/:id` — update item fields (wrap `upsertGlobalItem`)
|
||||||
|
- `DELETE /api/admin/items/:id` — delete item (new `deleteGlobalItem` service)
|
||||||
|
|
||||||
|
**Pagination approach for admin list:** The existing `searchGlobalItems` returns ALL items matching the query — no pagination. For the admin list with infinite scroll (D-03), the server must support cursor or offset pagination. Options:
|
||||||
|
- **Offset/limit** — simple, works for admin use case (items don't change frequently). `GET /api/admin/items?offset=0&limit=50`
|
||||||
|
- **Cursor-based** — already used in discovery feed (`src/server/services/discovery.service.ts`). More robust but more complex.
|
||||||
|
|
||||||
|
Recommendation: **offset/limit** for admin. Simpler to implement. Admin is a low-traffic, single-user feature. The discovery service cursor pattern adds complexity not needed here.
|
||||||
|
|
||||||
|
The existing `searchGlobalItems` must be extended or a new `listGlobalItemsForAdmin` function created to support:
|
||||||
|
- `query` (text search across brand + model)
|
||||||
|
- `tagNames` filter
|
||||||
|
- `limit` and `offset`
|
||||||
|
- Total count for display ("1,247 items")
|
||||||
|
|
||||||
|
### 1.3 Admin PUT: Updating Global Items
|
||||||
|
|
||||||
|
`upsertGlobalItem` takes `manufacturerSlug` and does an upsert by (manufacturerId, model). For admin edit, the admin changes individual fields (not always brand/model). Using the existing `upsertGlobalItem` is viable — admin sends the full item data including manufacturerSlug.
|
||||||
|
|
||||||
|
However, the admin edit needs to update by `id` not by (brand, model). A new `updateGlobalItemById` service function is cleaner:
|
||||||
|
- Takes `id` and partial update fields
|
||||||
|
- Updates `globalItems` record directly by ID
|
||||||
|
- Syncs tags via `syncGlobalItemTags` (already private in service file — needs to be called or made into a shared helper)
|
||||||
|
|
||||||
|
Alternatively: reuse `upsertGlobalItem` since it handles the update path if (manufacturerId, model) already exists. The admin form sends manufacturerSlug + model which are guaranteed unique, so the upsert will always hit the update path for existing items.
|
||||||
|
|
||||||
|
**Decision:** Add `updateGlobalItemById(db, id, data)` to the service — cleaner than relying on upsert conflict resolution for admin edits. Takes `{ manufacturerId?, model?, category?, weightGrams?, priceCents?, imageUrl?, description?, sourceUrl?, imageCredit?, imageSourceUrl?, tags? }`. This is more direct and avoids the manufacturerSlug → manufacturerId resolution dance.
|
||||||
|
|
||||||
|
### 1.4 Tags for Filter
|
||||||
|
|
||||||
|
The list has tag filtering (ADMN-02). Need `GET /api/tags` (or existing tag endpoint) to populate the tag filter dropdown. Verify: `src/server/routes/tags.ts` exists and exposes `GET /api/tags`. The `useTags` hook likely fetches from this endpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Client-Side: Routes and Hooks
|
||||||
|
|
||||||
|
### 2.1 New TanStack Router Routes
|
||||||
|
|
||||||
|
Per CONTEXT.md decisions D-04 and the existing TanStack Router file-based routing pattern:
|
||||||
|
- `src/client/routes/admin/items.tsx` — admin item list (`/admin/items`)
|
||||||
|
- `src/client/routes/admin/items.$itemId.tsx` — admin item edit page (`/admin/items/$itemId`)
|
||||||
|
|
||||||
|
Note: TanStack Router uses `$` prefix for dynamic segments in filenames. Verify: existing route `src/client/routes/global-items/$globalItemId.tsx` uses `$` prefix. Same pattern applies here.
|
||||||
|
|
||||||
|
### 2.2 Admin Shell Sidebar: Enable Items Link
|
||||||
|
|
||||||
|
`src/client/routes/admin.tsx` currently renders "Items" as a disabled `<div>` with cursor-not-allowed. Phase 37 replaces this with an active `<Link to="/admin/items">` with appropriate active styling. The "Tags" entry stays disabled.
|
||||||
|
|
||||||
|
Active link pattern — use `useRouterState` or TanStack Router's `<Link activeProps>`:
|
||||||
|
```tsx
|
||||||
|
<Link
|
||||||
|
to="/admin/items"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "bg-gray-100 text-gray-900 font-medium"
|
||||||
|
: "text-gray-600 hover:bg-gray-50"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
TanStack Router `<Link>` supports `activeProps` for active state styling.
|
||||||
|
|
||||||
|
### 2.3 Infinite Scroll Pattern
|
||||||
|
|
||||||
|
CONTEXT.md D-03 specifies server-side infinite scroll with IntersectionObserver. The `useInfiniteQuery` from React Query is the right hook. Pattern from existing hooks:
|
||||||
|
|
||||||
|
The discovery hooks use simple `useQuery` (not infinite). For true infinite scroll, use `useInfiniteQuery`:
|
||||||
|
```typescript
|
||||||
|
export function useAdminGlobalItems(query?: string, tags?: string[]) {
|
||||||
|
return useInfiniteQuery({
|
||||||
|
queryKey: ["admin-global-items", query ?? "", tags ?? []],
|
||||||
|
queryFn: ({ pageParam = 0 }) =>
|
||||||
|
apiGet<AdminGlobalItemPage>(`/api/admin/items?offset=${pageParam}&limit=50${...}`),
|
||||||
|
getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextOffset : undefined,
|
||||||
|
initialPageParam: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
IntersectionObserver sentinel div at bottom of table triggers `fetchNextPage()`.
|
||||||
|
|
||||||
|
### 2.4 Tags Column in Table
|
||||||
|
|
||||||
|
CONTEXT.md leaves the exact tag display to Claude's discretion. Given the table density requirement (D-01), tags should display as a count badge when > 2 tags, full chips when <= 2. This keeps the table dense while showing the data.
|
||||||
|
|
||||||
|
### 2.5 Edit Form Fields
|
||||||
|
|
||||||
|
CONTEXT.md D-03/D-04 specifies the edit page is a full-page form. Fields to edit (per ADMN-03):
|
||||||
|
- `brand` (manufacturer) — Dropdown of existing manufacturers (D-06)
|
||||||
|
- `model` — text input
|
||||||
|
- `weightGrams` — number input
|
||||||
|
- `priceCents` — number input (display as price, store as cents)
|
||||||
|
- `imageUrl` — text input (URL) or ImageUpload component for S3
|
||||||
|
- `imageCredit` — text input
|
||||||
|
- `imageSourceUrl` — text input (URL)
|
||||||
|
- `description` — textarea
|
||||||
|
- `sourceUrl` — text input (URL)
|
||||||
|
- `tags` — multi-select or chip input
|
||||||
|
|
||||||
|
**Manufacturer dropdown:** Needs `GET /api/manufacturers` (already exists). The `listManufacturers` service returns `{ id, name, slug, ... }`. The dropdown should show `name` but track `slug` for the API call. OR if using `updateGlobalItemById`, pass `manufacturerId` directly (integer).
|
||||||
|
|
||||||
|
**Tags input:** No existing multi-select tag component. Options:
|
||||||
|
1. Comma-separated text input (simplest)
|
||||||
|
2. Chip-style with add/remove (better UX)
|
||||||
|
|
||||||
|
Recommendation: Use a simple tag input that renders chips. The CatalogSearchOverlay already has tag chips pattern. Implement inline — a text input that adds tags on Enter/comma, chips that remove on click. ~30 lines of code.
|
||||||
|
|
||||||
|
### 2.6 Delete Flow
|
||||||
|
|
||||||
|
Delete is on the edit page only (D-05). Pattern: button at bottom triggers an inline modal/dialog. Can reuse the ConfirmDialog pattern from `src/client/components/ConfirmDialog.tsx` but it is hardwired to item deletion from UIStore. For admin use, create an inline confirmation state (local `useState`):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
Show the impact-aware message: "Delete {brand} {model}? {N} users have this in their collection. This cannot be undone."
|
||||||
|
|
||||||
|
After confirmation, call `DELETE /api/admin/items/:id` and navigate back to `/admin/items`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Missing Service: `listGlobalItemsForAdmin`
|
||||||
|
|
||||||
|
The existing `searchGlobalItems` does not support pagination. Need a new service function that returns paginated results with total count:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function listGlobalItemsForAdmin(
|
||||||
|
db: Db,
|
||||||
|
opts: {
|
||||||
|
query?: string;
|
||||||
|
tagNames?: string[];
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
// Returns { items: [...], total: number, hasMore: boolean, nextOffset: number }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This function also needs to return tags for each item (for the tags column). Two options:
|
||||||
|
1. Fetch tags in a separate query per item (N+1, bad)
|
||||||
|
2. Fetch tags for all returned items in a single query after the main query
|
||||||
|
|
||||||
|
Option 2 is better: after fetching the page of global items, fetch all `globalItemTags JOIN tags WHERE globalItemId IN (ids)` in one query, then map back to items.
|
||||||
|
|
||||||
|
Also need **owner count** per item in the list (for display and delete confirmation). Same approach: after fetching items page, count items per globalItemId in one query: `SELECT global_item_id, COUNT(*) FROM items WHERE global_item_id IN (ids) GROUP BY global_item_id`.
|
||||||
|
|
||||||
|
The list query will need:
|
||||||
|
- Main paginated query with offset/limit
|
||||||
|
- Count query for total
|
||||||
|
- Tags batch query
|
||||||
|
- Owner count batch query
|
||||||
|
|
||||||
|
This is 4 queries per page load — acceptable for an admin use case. Performance is not critical for a single-admin tool.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Validation Architecture
|
||||||
|
|
||||||
|
Key validations for this phase:
|
||||||
|
|
||||||
|
| Check | Command/Method | Expected |
|
||||||
|
|-------|---------------|----------|
|
||||||
|
| TypeScript compiles | `bun run build` | Exit 0 |
|
||||||
|
| Admin items list loads | `GET /api/admin/items` with admin token | Returns `{ items, total, hasMore }` |
|
||||||
|
| Admin item detail loads | `GET /api/admin/items/1` with admin token | Returns item with ownerCount |
|
||||||
|
| Admin item update | `PUT /api/admin/items/1` with admin token | Returns updated item |
|
||||||
|
| Admin item delete | `DELETE /api/admin/items/1` with admin token | Returns `{ success: true }` |
|
||||||
|
| Delete blocked for non-admin | `DELETE /api/admin/items/1` without auth | Returns 401 |
|
||||||
|
| Infinite scroll activates | Scroll to bottom of list | Next page fetches |
|
||||||
|
| Edit page navigates back | Click "← Items" | Returns to /admin/items |
|
||||||
|
| Delete confirmation shows ownerCount | Open delete dialog | Shows correct count |
|
||||||
|
| routeTree.gen.ts updated | Check file | Contains /admin/items and /admin/items/$itemId routes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Existing Patterns to Reuse (Summary)
|
||||||
|
|
||||||
|
| Pattern | Location | Use For |
|
||||||
|
|---------|----------|---------|
|
||||||
|
| Hono route handler | `src/server/routes/global-items.ts` | Admin items routes |
|
||||||
|
| `requireAuth` + `requireAdmin` chaining | `src/server/routes/admin.ts` | Admin items route middleware |
|
||||||
|
| `useInfiniteQuery` | React Query docs (not yet used in codebase) | Admin items infinite scroll |
|
||||||
|
| `useQuery` with enabled flag | `src/client/hooks/useGlobalItems.ts` | Admin item detail hook |
|
||||||
|
| `apiGet`, `apiPut`, `apiDelete` | `src/client/lib/api.ts` | API calls |
|
||||||
|
| `LucideIcon` | `src/client/lib/iconData.ts` | Icons in admin UI |
|
||||||
|
| `useFormatters()` | `src/client/hooks/useFormatters.ts` | Weight/price display in table |
|
||||||
|
| `GearImage` | `src/client/components/GearImage.tsx` | Thumbnail in table |
|
||||||
|
| Local confirm state (useState) | Various components | Admin delete confirmation |
|
||||||
|
| `Link to="/admin/items"` with activeProps | TanStack Router | Sidebar nav active state |
|
||||||
|
| `IntersectionObserver` sentinel | (not yet in codebase, standard pattern) | Infinite scroll trigger |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RESEARCH COMPLETE
|
||||||
|
|
||||||
|
Phase 37 is well-scoped and low-risk. The main new work:
|
||||||
|
1. `deleteGlobalItem(db, id)` service with correct FK cleanup order
|
||||||
|
2. `listGlobalItemsForAdmin(db, opts)` service with pagination + batched tags + owner counts
|
||||||
|
3. `updateGlobalItemById(db, id, data)` service for direct-by-id updates
|
||||||
|
4. New admin item sub-router at `src/server/routes/admin-items.ts`
|
||||||
|
5. Two new client routes: `admin/items.tsx` and `admin/items.$itemId.tsx`
|
||||||
|
6. `useAdminGlobalItems` infinite query hook + `useAdminGlobalItem` + update/delete mutations
|
||||||
|
7. Sidebar "Items" link activation in `admin.tsx`
|
||||||
|
|
||||||
|
No schema changes. No migration required. All within the existing PostgreSQL + Drizzle + React Query + TanStack Router stack.
|
||||||
314
.planning/phases/37-admin-global-item-management/37-UI-SPEC.md
Normal file
314
.planning/phases/37-admin-global-item-management/37-UI-SPEC.md
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
---
|
||||||
|
phase: 37
|
||||||
|
slug: admin-global-item-management
|
||||||
|
status: approved
|
||||||
|
shadcn_initialized: false
|
||||||
|
preset: none
|
||||||
|
created: 2026-04-19
|
||||||
|
reviewed_at: 2026-04-19
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 37 — UI Design Contract
|
||||||
|
|
||||||
|
> Visual and interaction contract for Admin — Global Item Management. Generated from CONTEXT.md decisions, RESEARCH.md findings, and codebase pattern analysis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design System
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Tool | none — Tailwind CSS v4 utility-first |
|
||||||
|
| Preset | not applicable |
|
||||||
|
| Component library | none (custom components) |
|
||||||
|
| Icon library | LucideIcon from `src/client/lib/iconData` |
|
||||||
|
| Font | System default (Inter-like sans-serif via browser) |
|
||||||
|
|
||||||
|
No `components.json` detected. No shadcn. All styling is plain Tailwind utility classes matching the app's existing light/airy aesthetic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spacing Scale
|
||||||
|
|
||||||
|
Standard 8-point scale from existing app patterns:
|
||||||
|
|
||||||
|
| Token | Value | Usage |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| xs | 4px (`gap-1`, `p-1`) | Icon gaps, inline chip gaps |
|
||||||
|
| sm | 8px (`gap-2`, `p-2`) | Compact table cell padding, tag chips |
|
||||||
|
| md | 16px (`gap-4`, `p-4`) | Default form field spacing, sidebar padding |
|
||||||
|
| lg | 24px (`gap-6`, `p-6`) | Section padding, edit page padding |
|
||||||
|
| xl | 32px (`gap-8`, `p-8`) | Page-level layout gaps |
|
||||||
|
| 2xl | 48px | Major section breaks (used sparingly) |
|
||||||
|
| 3xl | 64px | Not used in admin panel |
|
||||||
|
|
||||||
|
Exceptions:
|
||||||
|
- Table row height: `py-3` (12px vertical) + `px-4` (16px horizontal) for dense data rows
|
||||||
|
- Sidebar nav items: `px-3 py-2` (12px vertical) — matches Phase 36 admin shell
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
Matches existing app type scale. No new sizes introduced.
|
||||||
|
|
||||||
|
| Role | Size | Weight | Line Height | Tailwind |
|
||||||
|
|------|------|--------|-------------|---------|
|
||||||
|
| Body / table cell | 14px | 400 | 1.5 | `text-sm` |
|
||||||
|
| Label / column header | 12px | 600 | 1.4 | `text-xs font-semibold` |
|
||||||
|
| Form field label | 14px | 600 | 1.4 | `text-sm font-semibold` |
|
||||||
|
| Page heading | 18px | 600 | 1.3 | `text-lg font-semibold` |
|
||||||
|
|
||||||
|
Weights used: 400 (regular) + 600 (semibold). No medium (500), no bold (700).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Color
|
||||||
|
|
||||||
|
60/30/10 rule — matches Phase 36 admin shell exactly:
|
||||||
|
|
||||||
|
| Role | Value | Tailwind | Usage |
|
||||||
|
|------|-------|---------|-------|
|
||||||
|
| Dominant (60%) | #ffffff | `bg-white` | Page backgrounds, table rows, edit form |
|
||||||
|
| Secondary (30%) | #f9fafb | `bg-gray-50` | Main content area, input backgrounds |
|
||||||
|
| Border / divider | #f3f4f6 | `border-gray-100` | Table row dividers, sidebar border |
|
||||||
|
| Text primary | #111827 | `text-gray-900` | Item names, column data |
|
||||||
|
| Text secondary | #4b5563 | `text-gray-600` | Labels, form hints |
|
||||||
|
| Text muted | #9ca3af | `text-gray-400` | Column headers, disabled states |
|
||||||
|
| Accent (10%) | #2563eb | `text-blue-600`, `bg-blue-50` | Save button, active nav link indicator |
|
||||||
|
| Destructive | #dc2626 | `bg-red-600 hover:bg-red-700` | Delete button ONLY |
|
||||||
|
|
||||||
|
Accent reserved for:
|
||||||
|
- Save/Update primary button
|
||||||
|
- Active sidebar nav link highlight (left border or background)
|
||||||
|
|
||||||
|
Destructive reserved for:
|
||||||
|
- Delete button in edit page
|
||||||
|
- Confirm delete button inside the delete dialog
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Inventory
|
||||||
|
|
||||||
|
### Admin Items List (`/admin/items`)
|
||||||
|
|
||||||
|
**Layout:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ Heading: "Catalog Items" [search input] │
|
||||||
|
│ Tag filter chips │
|
||||||
|
│ "1,247 items" count label │
|
||||||
|
├──────┬────────────┬─────┬───────┬───────┬───────┤
|
||||||
|
│ Brand+Model │ Category │ Wt │ Price │ Tags │ Owners│
|
||||||
|
├──────┴────────────┴─────┴───────┴───────┴───────┤
|
||||||
|
│ row row row ... (50 per page) │
|
||||||
|
│ [sentinel div — triggers next page] │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Table element specs:**
|
||||||
|
- Wrapper: `w-full overflow-hidden rounded-xl border border-gray-100 bg-white`
|
||||||
|
- `<table>`: `w-full text-sm`
|
||||||
|
- `<thead>`: `bg-gray-50 border-b border-gray-100`
|
||||||
|
- Column header cells: `px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide`
|
||||||
|
- `<tbody>` rows: `border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors`
|
||||||
|
- Body cells: `px-4 py-3 text-sm text-gray-700`
|
||||||
|
- Brand+Model cell: bold brand (`font-medium text-gray-900`) + muted model (`text-gray-500`)
|
||||||
|
|
||||||
|
**Tags column:**
|
||||||
|
- ≤ 2 tags: render as inline chips — `text-xs bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full`
|
||||||
|
- > 2 tags: render count badge — `text-xs bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full` displaying "+N"
|
||||||
|
|
||||||
|
**Owner count column:**
|
||||||
|
- Numeric, right-aligned, `text-gray-500`
|
||||||
|
- 0 owners: `text-gray-300`
|
||||||
|
|
||||||
|
**Search input:**
|
||||||
|
- `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`
|
||||||
|
- Placeholder: "Search catalog..."
|
||||||
|
- Width: `w-64`
|
||||||
|
|
||||||
|
**Tag filter:**
|
||||||
|
- Row of clickable chips below search bar
|
||||||
|
- Selected: `bg-blue-50 text-blue-600 border border-blue-200`
|
||||||
|
- Unselected: `bg-gray-100 text-gray-600`
|
||||||
|
- Multi-select allowed (matches existing CatalogSearchOverlay pattern)
|
||||||
|
|
||||||
|
**Infinite scroll sentinel:**
|
||||||
|
- `<div ref={sentinelRef} className="h-4" />` at bottom of table
|
||||||
|
- IntersectionObserver triggers `fetchNextPage()` when sentinel enters viewport
|
||||||
|
- Loading indicator: `<div className="py-4 text-center text-sm text-gray-400">Loading...</div>`
|
||||||
|
|
||||||
|
### Admin Items Edit Page (`/admin/items/$itemId`)
|
||||||
|
|
||||||
|
**Layout:**
|
||||||
|
```
|
||||||
|
← Items (back link)
|
||||||
|
|
||||||
|
Salsa Woodsmoke 700 (page heading: brand + model)
|
||||||
|
3 users in collection (owner count subtext)
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ [Image preview — GearImage] │
|
||||||
|
│ [Image URL input] │
|
||||||
|
├──────────────────────────────────────────────────┤
|
||||||
|
│ Brand (Manufacturer) [dropdown] │
|
||||||
|
│ Model [text input] │
|
||||||
|
│ Category [text input] │
|
||||||
|
├──────────────────────────────────────────────────┤
|
||||||
|
│ Weight (g) [number input] │
|
||||||
|
│ Price (€) [number input] │
|
||||||
|
├──────────────────────────────────────────────────┤
|
||||||
|
│ Tags [chip input] │
|
||||||
|
│ Description [textarea] │
|
||||||
|
│ Source URL [url input] │
|
||||||
|
│ Image Credit [text input] │
|
||||||
|
│ Image Source URL [url input] │
|
||||||
|
├──────────────────────────────────────────────────┤
|
||||||
|
│ [Save Changes] [Delete Item]│
|
||||||
|
└──────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Form wrapper:** `max-w-2xl mx-auto` (consistent with catalog detail page width)
|
||||||
|
|
||||||
|
**Field groups:** separated by `border-t border-gray-100 pt-6 mt-6`
|
||||||
|
|
||||||
|
**Input styles (all fields):**
|
||||||
|
- Text/number/url: `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`
|
||||||
|
- Textarea: same + `min-h-[80px] resize-y`
|
||||||
|
- Manufacturer dropdown: `<select>` styled same as inputs, `appearance-none bg-white`
|
||||||
|
- Label: `block text-sm font-medium text-gray-700 mb-1`
|
||||||
|
|
||||||
|
**Tags chip input:**
|
||||||
|
- Active chips: `inline-flex items-center gap-1 bg-gray-100 text-gray-700 text-xs px-2 py-1 rounded-full`
|
||||||
|
- Remove button per chip: `×` button, `text-gray-400 hover:text-gray-600`
|
||||||
|
- Input: inline text input at end of chip row, presses Enter or comma to add
|
||||||
|
- Wrapper: `flex flex-wrap gap-2 rounded-lg border border-gray-200 px-3 py-2 min-h-[40px]`
|
||||||
|
|
||||||
|
**Save button:**
|
||||||
|
- `px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition-colors disabled:opacity-50`
|
||||||
|
- Label: "Save Changes"
|
||||||
|
|
||||||
|
**Delete button:**
|
||||||
|
- `px-4 py-2 rounded-lg border border-red-200 text-red-600 hover:bg-red-50 text-sm font-medium transition-colors`
|
||||||
|
- Positioned at far right of action row (space-between layout)
|
||||||
|
- Label: "Delete Item"
|
||||||
|
|
||||||
|
**Back link:**
|
||||||
|
- `text-sm text-gray-400 hover:text-gray-600 transition-colors`
|
||||||
|
- Text: "← Items"
|
||||||
|
- Navigates to `/admin/items`
|
||||||
|
|
||||||
|
**Page heading:**
|
||||||
|
- `text-lg font-semibold text-gray-900` — "{Brand} {Model}"
|
||||||
|
- Subtext: `text-sm text-gray-400 mt-0.5` — "{N} users in collection" (uses ownerCount from API)
|
||||||
|
|
||||||
|
### Delete Confirmation Dialog
|
||||||
|
|
||||||
|
Inline state-driven modal (not UIStore — local `useState`):
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ Delete {Brand} {Model}? │
|
||||||
|
│ │
|
||||||
|
│ {N} users have this item in their │
|
||||||
|
│ collection. This cannot be undone. │
|
||||||
|
│ │
|
||||||
|
│ [Cancel] [Delete] │
|
||||||
|
└──────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Modal backdrop: `fixed inset-0 z-50 flex items-center justify-center` + `absolute inset-0 bg-black/30`
|
||||||
|
- Dialog box: `relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full` (matches ConfirmDialog)
|
||||||
|
- Heading: `text-lg font-semibold text-gray-900 mb-2`
|
||||||
|
- Body: `text-sm text-gray-600 mb-6`
|
||||||
|
- Cancel: `px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg`
|
||||||
|
- Confirm delete: `px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg`
|
||||||
|
|
||||||
|
### Sidebar Items Link (admin.tsx update)
|
||||||
|
|
||||||
|
Replace the disabled `<div>` with an active `<Link>`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<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>
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the "Soon" badge and `cursor-not-allowed` class.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Copywriting Contract
|
||||||
|
|
||||||
|
| Element | Copy |
|
||||||
|
|---------|------|
|
||||||
|
| Page heading (list) | "Catalog Items" |
|
||||||
|
| Item count label | "{N} items" (e.g. "1,247 items") |
|
||||||
|
| Search placeholder | "Search catalog..." |
|
||||||
|
| Empty state heading | "No items found" |
|
||||||
|
| Empty state body | "Try a different search or clear your filters." |
|
||||||
|
| Edit page — 0 owners | "Not in any collection" |
|
||||||
|
| Edit page — 1 owner | "1 user in collection" |
|
||||||
|
| Edit page — N owners | "{N} users in collection" |
|
||||||
|
| Save CTA | "Save Changes" |
|
||||||
|
| Delete CTA (edit page) | "Delete Item" |
|
||||||
|
| Delete dialog heading | "Delete {Brand} {Model}?" |
|
||||||
|
| Delete dialog body (0 owners) | "This item is not in any collection. This cannot be undone." |
|
||||||
|
| Delete dialog body (N owners) | "{N} users have this item in their collection. This cannot be undone." |
|
||||||
|
| Delete dialog confirm | "Delete" |
|
||||||
|
| Delete dialog cancel | "Cancel" |
|
||||||
|
| Back link | "← Items" |
|
||||||
|
| Loading more (infinite scroll) | "Loading..." |
|
||||||
|
| All items loaded | "All {N} items loaded" |
|
||||||
|
| Error state | "Failed to load catalog items. Please try again." |
|
||||||
|
| Save success | (no toast — form stays; navigation optional) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## States to Handle
|
||||||
|
|
||||||
|
| Component | States |
|
||||||
|
|-----------|--------|
|
||||||
|
| Items list | Loading (skeleton rows), Loaded, Empty, Error, Loading next page |
|
||||||
|
| Edit page | Loading (skeleton form), Loaded, Saving, Save success, Save error |
|
||||||
|
| Delete dialog | Closed, Open, Deleting, Delete error |
|
||||||
|
| Manufacturer dropdown | Loading manufacturers, Loaded |
|
||||||
|
|
||||||
|
**Loading skeleton for table rows:**
|
||||||
|
```
|
||||||
|
4-6 rows with `<div className="h-4 bg-gray-100 rounded animate-pulse" />` in each cell
|
||||||
|
```
|
||||||
|
|
||||||
|
**Save/delete pending state:**
|
||||||
|
- Button `disabled` + `opacity-50`
|
||||||
|
- No spinner needed — button text stays static
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Registry Safety
|
||||||
|
|
||||||
|
| Registry | Blocks Used | Safety Gate |
|
||||||
|
|----------|-------------|-------------|
|
||||||
|
| None — no shadcn | — | not applicable |
|
||||||
|
|
||||||
|
No third-party component registries used. All UI is plain Tailwind + custom components.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checker Sign-Off
|
||||||
|
|
||||||
|
- [x] Dimension 1 Copywriting: PASS
|
||||||
|
- [x] Dimension 2 Visuals: PASS
|
||||||
|
- [x] Dimension 3 Color: PASS
|
||||||
|
- [x] Dimension 4 Typography: PASS
|
||||||
|
- [x] Dimension 5 Spacing: PASS
|
||||||
|
- [x] Dimension 6 Registry Safety: PASS
|
||||||
|
|
||||||
|
**Approval:** approved 2026-04-19
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
phase: 37
|
||||||
|
slug: admin-global-item-management
|
||||||
|
status: draft
|
||||||
|
nyquist_compliant: false
|
||||||
|
wave_0_complete: false
|
||||||
|
created: 2026-04-19
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 37 — Validation Strategy
|
||||||
|
|
||||||
|
> Per-phase validation contract for feedback sampling during execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Infrastructure
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Framework** | Bun test runner |
|
||||||
|
| **Config file** | package.json (bun test) |
|
||||||
|
| **Quick run command** | `bun test tests/services/global-item.service.test.ts` |
|
||||||
|
| **Full suite command** | `bun test` |
|
||||||
|
| **Build check** | `bun run build` |
|
||||||
|
| **Estimated runtime** | ~15 seconds |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sampling Rate
|
||||||
|
|
||||||
|
- **After every task commit:** Run `bun run build` (TypeScript check)
|
||||||
|
- **After service tasks:** Run `bun test tests/services/global-item.service.test.ts`
|
||||||
|
- **After every plan wave:** Run `bun test`
|
||||||
|
- **Before `/gsd-verify-work`:** Full suite + build must be green
|
||||||
|
- **Max feedback latency:** ~15 seconds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-Task Verification Map
|
||||||
|
|
||||||
|
| Task ID | Plan | Wave | Requirement | Secure Behavior | Test Type | Automated Command | Status |
|
||||||
|
|---------|------|------|-------------|-----------------|-----------|-------------------|--------|
|
||||||
|
| 37-01-T1 | 01 | 1 | ADMN-02 | deleteGlobalItem nullifies FK before delete | unit | `bun test tests/services/global-item.service.test.ts` | ⬜ pending |
|
||||||
|
| 37-01-T2 | 01 | 1 | ADMN-02 | listGlobalItemsForAdmin returns paginated results | unit | `bun test tests/services/global-item.service.test.ts` | ⬜ pending |
|
||||||
|
| 37-01-T3 | 01 | 1 | ADMN-03 | updateGlobalItemById updates by id | unit | `bun test tests/services/global-item.service.test.ts` | ⬜ pending |
|
||||||
|
| 37-01-T4 | 01 | 1 | ADMN-02 | GET /api/admin/items returns 401/403 without auth | manual | `curl -s localhost:3000/api/admin/items` | ⬜ pending |
|
||||||
|
| 37-01-T5 | 01 | 1 | ADMN-04 | DELETE /api/admin/items/:id returns 403 for non-admin | manual | curl test | ⬜ pending |
|
||||||
|
| 37-02-T1 | 02 | 2 | ADMN-02 | Admin items list renders table with rows | build | `bun run build` | ⬜ pending |
|
||||||
|
| 37-02-T2 | 02 | 2 | ADMN-02 | Infinite scroll hook: useInfiniteQuery + sentinel div | build | `bun run build` | ⬜ pending |
|
||||||
|
| 37-02-T3 | 02 | 2 | ADMN-03 | Edit page form renders all fields | build | `bun run build` | ⬜ pending |
|
||||||
|
| 37-02-T4 | 02 | 2 | ADMN-04 | Delete confirmation shows ownerCount | manual | Browser test | ⬜ pending |
|
||||||
|
| 37-02-T5 | 02 | 2 | ADMN-02 | Sidebar Items link is active and navigates | build | `bun run build` + routeTree check | ⬜ pending |
|
||||||
|
|
||||||
|
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 0 Requirements
|
||||||
|
|
||||||
|
Existing infrastructure covers all phase requirements. No new test files need to be created — existing `tests/services/global-item.service.test.ts` should be extended for the new service functions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual-Only Verifications
|
||||||
|
|
||||||
|
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||||
|
|----------|-------------|------------|-------------------|
|
||||||
|
| Admin items list loads with pagination | ADMN-02 | Requires live server + admin user | Dev server: navigate to /admin/items as admin |
|
||||||
|
| Infinite scroll fetches next page | ADMN-02 | Requires live browser with enough data | Scroll to bottom of items list |
|
||||||
|
| Edit form saves and redirects | ADMN-03 | Requires live server + form interaction | Open any item, change a field, save |
|
||||||
|
| Delete with ownerCount > 0 warns | ADMN-04 | Requires item with actual owners | Use seeded item, confirm dialog shows count |
|
||||||
|
| Non-admin redirect from /admin/items | ADMN-01 | Client-side guard requires browser | Log in as non-admin, navigate to /admin/items |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Sign-Off
|
||||||
|
|
||||||
|
- [ ] All tasks have `<automated>` verify or manual test instructions
|
||||||
|
- [ ] TypeScript build is the primary automated gate for client-side tasks
|
||||||
|
- [ ] Service-layer functions have unit tests
|
||||||
|
- [ ] No watch-mode flags
|
||||||
|
- [ ] Feedback latency < 30s
|
||||||
|
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||||
|
|
||||||
|
**Approval:** pending
|
||||||
Reference in New Issue
Block a user