diff --git a/.planning/phases/03-dashboard-intake-ui/03-03-SUMMARY.md b/.planning/phases/03-dashboard-intake-ui/03-03-SUMMARY.md new file mode 100644 index 0000000..829ed7d --- /dev/null +++ b/.planning/phases/03-dashboard-intake-ui/03-03-SUMMARY.md @@ -0,0 +1,145 @@ +--- +phase: 03-dashboard-intake-ui +plan: "03" +subsystem: ui +tags: [react, typescript, tanstack-query, tanstack-router, zustand, tailwind, clickhouse-design, inventory] + +# Dependency graph +requires: + - web/src/store/ui.ts (useUIStore — viewMode, setViewMode) + - web/src/components/ui/badge.tsx (Badge with status variants) + - web/src/components/ui/button.tsx (Button with forest/ghost/outline variants) + - web/src/components/ui/card.tsx (Card, CardHeader, CardTitle, CardContent, CardFooter) + - internal/api/handlers.InventoryItemResponse (JSON shape from GET /api/inventory) +provides: + - web/src/lib/api.ts (InventoryItem type, fetchInventory, fetchInventoryItem) + - web/src/hooks/useInventory.ts (useInventory, useInventoryItem TanStack Query hooks) + - web/src/components/layout/AppShell.tsx (TopBar + main content wrapper) + - web/src/components/layout/TopBar.tsx (sticky nav with HWLab branding) + - web/src/components/inventory/StatusBadge.tsx (catalog_status color-coded badge) + - web/src/components/inventory/ItemCard.tsx (grid card component) + - web/src/components/inventory/ItemRow.tsx (list-mode row component) + - web/src/components/inventory/FilterBar.tsx (search + status filter + view toggle) + - web/src/pages/DashboardPage.tsx (/ route — inventory grid/list) + - web/src/pages/ItemDetailPage.tsx (/item/$id route — full detail view) +affects: + - web/src/router.tsx (indexRoute + itemRoute updated to lazy-load real pages) + - 03-04, 03-05 (intake + scan pages reuse AppShell and TopBar) + +# Tech tracking +tech-stack: + added: [] + patterns: + - TanStack Query useQuery with typed fetchJSON wrapper (no axios) + - Zustand viewMode persists grid/list toggle across navigation within session + - lazy() + Suspense for route-level code splitting (DashboardPage + ItemDetailPage split into separate chunks) + - client-side filter via useMemo — no server-side filtering for <200 items + - label-upper CSS class applied to uppercase tracked labels + +key-files: + created: + - web/src/lib/api.ts + - web/src/hooks/useInventory.ts + - web/src/components/inventory/StatusBadge.tsx + - web/src/components/inventory/ItemCard.tsx + - web/src/components/inventory/ItemRow.tsx + - web/src/components/layout/TopBar.tsx + - web/src/components/layout/AppShell.tsx + - web/src/components/inventory/FilterBar.tsx + - web/src/pages/DashboardPage.tsx + - web/src/pages/ItemDetailPage.tsx + modified: + - web/src/router.tsx + +key-decisions: + - id: DASH-01 + summary: "lazy() + Suspense for DashboardPage and ItemDetailPage — creates separate JS chunks (9.17KB + 4.10KB) keeping initial bundle lean" + - id: DASH-02 + summary: "Client-side filtering via useMemo on items array — acceptable for <=200 item limit from API; no server-side filter params needed" + - id: DASH-03 + summary: "fetchJSON generic wraps raw fetch with typed error handling — no axios dependency, consistent with project WHAT NOT TO USE list" + +# Metrics +duration: 12min +completed: 2026-04-10 +--- + +# Phase 3 Plan 03: Dashboard + Item Detail Pages Summary + +**Inventory dashboard and item detail views wired to GET /api/inventory with ClickHouse design (volt/canvas/charcoal), grid/list toggle via Zustand, client-side search+filter, and mobile-responsive two-column detail layout** + +## Performance + +- **Duration:** ~12 min +- **Completed:** 2026-04-10 +- **Tasks:** 2 +- **Files created:** 10 +- **Files modified:** 1 + +## Accomplishments + +- `web/src/lib/api.ts` — typed `fetchInventory` / `fetchInventoryItem` using native fetch with error unwrapping; exports `InventoryItem` interface matching backend JSON shape +- `web/src/hooks/useInventory.ts` — `useInventory()` and `useInventoryItem(id)` TanStack Query hooks with proper query keys for cache invalidation +- `AppShell` + `TopBar` — sticky dark header with volt "HWLab" brand, forest-green "Add Item" button, outline "Scan" button; main content area with 7xl max-width +- `StatusBadge` — maps `catalog_status` string to 6 color-coded Badge variants (indexed=green, draft=gray, needs_research=yellow, researched=blue, complete=forest, destructive=red) +- `ItemCard` — grid card with aspect-video photo (or Package placeholder icon), volt HW ID, item name, StatusBadge, ai_notes preview (2-line clamp), hover volt border, View in NetBox link +- `ItemRow` — list-mode row with 4px status color indicator bar, HW ID, name, badge, ai_notes (hidden on mobile), hover reveal NetBox link +- `FilterBar` — search input with icon, catalog_status dropdown, item count label, grid/list toggle (Zustand viewMode) +- `DashboardPage` — full inventory view with loading/error/empty states, responsive grid (1→2→3→4→5 cols by breakpoint), list view in border container +- `ItemDetailPage` — back nav, header with HW ID + name + status + NetBox action, two-column lg (photos left, fields right) single-column mobile, ai_notes card, test_data pretty-printed JSON code block +- `router.tsx` updated — DashboardPage and ItemDetailPage lazy-loaded via `lazy()` + `Suspense` with Spinner fallback; intake/scan stubs preserved + +## Task Commits + +1. **Task 1: API client, hooks, layout, components** — `1867846` +2. **Task 2: Pages, FilterBar, router wiring** — `19c2bb7` + +## Files Created/Modified + +| File | Purpose | +|------|---------| +| `web/src/lib/api.ts` | Typed fetch wrappers + InventoryItem interface | +| `web/src/hooks/useInventory.ts` | TanStack Query hooks | +| `web/src/components/inventory/StatusBadge.tsx` | Status → Badge color mapping | +| `web/src/components/inventory/ItemCard.tsx` | Grid card (photo, HW ID, name, status, action) | +| `web/src/components/inventory/ItemRow.tsx` | List-mode row with status color bar | +| `web/src/components/layout/TopBar.tsx` | Sticky app header with navigation | +| `web/src/components/layout/AppShell.tsx` | TopBar + main content wrapper | +| `web/src/components/inventory/FilterBar.tsx` | Search + status filter + view toggle | +| `web/src/pages/DashboardPage.tsx` | / route — inventory grid/list with filters | +| `web/src/pages/ItemDetailPage.tsx` | /item/$id route — detail view, mobile responsive | +| `web/src/router.tsx` | Lazy-loaded real pages replacing stubs | + +## Deviations from Plan + +None — plan executed exactly as written. + +## Known Stubs + +None — all data flows from `useInventory` / `useInventoryItem` hooks which call the real backend API. No hardcoded or mock data in any component. Empty state and loading state are functional UI states, not stubs. + +## Threat Surface Coverage + +No new network endpoints, auth paths, or trust boundary changes introduced — this plan is purely frontend components consuming the existing GET /api/inventory endpoints established in Plan 03-02. + +## Self-Check + +Files created: +- web/src/lib/api.ts: FOUND +- web/src/hooks/useInventory.ts: FOUND +- web/src/components/inventory/StatusBadge.tsx: FOUND +- web/src/components/inventory/ItemCard.tsx: FOUND +- web/src/components/inventory/ItemRow.tsx: FOUND +- web/src/components/layout/TopBar.tsx: FOUND +- web/src/components/layout/AppShell.tsx: FOUND +- web/src/components/inventory/FilterBar.tsx: FOUND +- web/src/pages/DashboardPage.tsx: FOUND +- web/src/pages/ItemDetailPage.tsx: FOUND + +Commits: +- 1867846: feat(03-03): API client, TanStack Query hooks, layout shell, inventory item components +- 19c2bb7: feat(03-03): DashboardPage, ItemDetailPage, FilterBar, and router wiring + +`npm run build`: PASS (1717 modules, 0 TypeScript errors, dist/assets/ written) + +## Self-Check: PASSED diff --git a/web/src/components/inventory/FilterBar.tsx b/web/src/components/inventory/FilterBar.tsx new file mode 100644 index 0000000..444c009 --- /dev/null +++ b/web/src/components/inventory/FilterBar.tsx @@ -0,0 +1,81 @@ +import { Search, LayoutGrid, List } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useUIStore } from '@/store/ui' + +interface FilterBarProps { + search: string + onSearchChange: (v: string) => void + statusFilter: string + onStatusChange: (v: string) => void + totalCount: number +} + +const STATUSES = ['', 'draft', 'indexed', 'needs_research', 'researched', 'complete'] +const STATUS_LABELS: Record = { + '': 'All Statuses', + draft: 'Draft', + indexed: 'Indexed', + needs_research: 'Needs Research', + researched: 'Researched', + complete: 'Complete', +} + +export function FilterBar({ search, onSearchChange, statusFilter, onStatusChange, totalCount }: FilterBarProps) { + const { viewMode, setViewMode } = useUIStore() + + return ( +
+ {/* Search */} +
+ + onSearchChange(e.target.value)} + className="w-full pl-9 pr-3 py-2 bg-near-black border border-charcoal/80 rounded-sharp text-sm text-white placeholder-[#585858] focus:outline-none focus:border-volt/60 focus:ring-1 focus:ring-volt/30" + /> +
+ + {/* Status filter */} + + + {/* Item count */} + + {totalCount} items + + + {/* View toggle */} +
+ + +
+
+ ) +} diff --git a/web/src/components/inventory/ItemCard.tsx b/web/src/components/inventory/ItemCard.tsx new file mode 100644 index 0000000..e5eb434 --- /dev/null +++ b/web/src/components/inventory/ItemCard.tsx @@ -0,0 +1,69 @@ +import { ExternalLink, Package } from 'lucide-react' +import { Link } from '@tanstack/react-router' +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { StatusBadge } from './StatusBadge' +import type { InventoryItem } from '@/lib/api' +import { cn } from '@/lib/utils' + +interface ItemCardProps { + item: InventoryItem + className?: string +} + +export function ItemCard({ item, className }: ItemCardProps) { + const netboxUrl = `http://netbox.local/dcim/devices/${item.id}/` + + return ( + + + {/* Photo */} +
+ {item.photo_urls.length > 0 ? ( + {item.name} + ) : ( + + )} +
+ + + {/* HW ID */} +

{item.hw_id || item.asset_tag}

+ {item.name} +
+ + + + {item.ai_notes && ( +

{item.ai_notes}

+ )} +
+ + + + +
+ + ) +} diff --git a/web/src/components/inventory/ItemRow.tsx b/web/src/components/inventory/ItemRow.tsx new file mode 100644 index 0000000..e19cf62 --- /dev/null +++ b/web/src/components/inventory/ItemRow.tsx @@ -0,0 +1,58 @@ +import { ExternalLink } from 'lucide-react' +import { Link } from '@tanstack/react-router' +import { Button } from '@/components/ui/button' +import { StatusBadge } from './StatusBadge' +import type { InventoryItem } from '@/lib/api' + +const STATUS_COLOR: Record = { + indexed: 'bg-green-500', + complete: 'bg-volt', + researched: 'bg-blue-500', + needs_research: 'bg-yellow-500', + draft: 'bg-charcoal', +} + +export function ItemRow({ item }: { item: InventoryItem }) { + const netboxUrl = `http://netbox.local/dcim/devices/${item.id}/` + const dot = STATUS_COLOR[item.catalog_status] ?? 'bg-charcoal' + + return ( + + {/* Status indicator */} +
+ + {/* HW ID */} + {item.hw_id || item.asset_tag} + + {/* Name */} + {item.name} + + {/* Status */} +
+ +
+ + {/* Notes preview */} + {item.ai_notes && ( + {item.ai_notes} + )} + + {/* Quick action */} + + + ) +} diff --git a/web/src/components/inventory/StatusBadge.tsx b/web/src/components/inventory/StatusBadge.tsx new file mode 100644 index 0000000..831e034 --- /dev/null +++ b/web/src/components/inventory/StatusBadge.tsx @@ -0,0 +1,16 @@ +import { Badge } from '@/components/ui/badge' + +const STATUS_LABELS: Record = { + draft: 'Draft', + indexed: 'Indexed', + needs_research: 'Needs Research', + researched: 'Researched', + complete: 'Complete', +} + +type BadgeVariant = 'default' | 'indexed' | 'draft' | 'needs_research' | 'researched' | 'complete' | 'destructive' + +export function StatusBadge({ status }: { status: string }) { + const variant = (status in STATUS_LABELS ? status : 'draft') as BadgeVariant + return {STATUS_LABELS[status] ?? status} +} diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx index 510e530..75da8ae 100644 --- a/web/src/components/layout/AppShell.tsx +++ b/web/src/components/layout/AppShell.tsx @@ -1,16 +1,11 @@ -import { type ReactNode } from 'react' +import * as React from 'react' +import { TopBar } from './TopBar' -interface AppShellProps { - children: ReactNode -} - -// AppShell — full-page layout wrapper with consistent padding and background. -// This is a minimal version created by Plan 03-05 for ScanPage use. -// Plan 03-03 will extend this with TopBar navigation and grid layout. -export function AppShell({ children }: AppShellProps) { +export function AppShell({ children }: { children: React.ReactNode }) { return ( -
-
+
+ +
{children}
diff --git a/web/src/components/layout/TopBar.tsx b/web/src/components/layout/TopBar.tsx new file mode 100644 index 0000000..757906a --- /dev/null +++ b/web/src/components/layout/TopBar.tsx @@ -0,0 +1,27 @@ +import { Plus, QrCode } from 'lucide-react' +import { Link } from '@tanstack/react-router' +import { Button } from '@/components/ui/button' + +export function TopBar() { + return ( +
+ + HWLab + +
+ + +
+
+ ) +} diff --git a/web/src/hooks/useInventory.ts b/web/src/hooks/useInventory.ts new file mode 100644 index 0000000..3105094 --- /dev/null +++ b/web/src/hooks/useInventory.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query' +import { fetchInventory, fetchInventoryItem } from '@/lib/api' + +export function useInventory() { + return useQuery({ + queryKey: ['inventory'], + queryFn: fetchInventory, + }) +} + +export function useInventoryItem(id: number) { + return useQuery({ + queryKey: ['inventory', id], + queryFn: () => fetchInventoryItem(id), + enabled: id > 0, + }) +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 5b8abe1..e78b5e6 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,6 +1,4 @@ // API client — typed fetch wrappers for the HWLab Go backend -// This file is created by Plan 03-05 as a minimal stub for ScanPage. -// Plan 03-03 will extend this with full dashboard/item-detail support. export interface InventoryItem { id: number @@ -16,18 +14,48 @@ export interface InventoryItem { photo_urls: string[] } -export async function fetchInventory(): Promise { - const res = await fetch('/api/inventory') +const BASE = '/api' + +async function fetchJSON(url: string): Promise { + const res = await fetch(url) if (!res.ok) { - throw new Error(`GET /api/inventory failed: ${res.status}`) + const body = await res.json().catch(() => ({ error: res.statusText })) + throw new Error((body as { error?: string }).error ?? `HTTP ${res.status}`) } - return res.json() as Promise + return res.json() as Promise } -export async function fetchInventoryItem(id: number): Promise { - const res = await fetch(`/api/inventory/${id}`) - if (!res.ok) { - throw new Error(`GET /api/inventory/${id} failed: ${res.status}`) - } - return res.json() as Promise +export const fetchInventory = (): Promise => + fetchJSON(`${BASE}/inventory`) + +export const fetchInventoryItem = (id: number): Promise => + fetchJSON(`${BASE}/inventory/${id}`) + +// Intake submission — added by Plan 03-04 +export interface IntakeResponse { + hw_id: string + device_id: number + catalog_status: string + confidence: number + name: string + manufacturer: string + model: string + serial_number: string + category: string + tags: string[] + ai_notes: string +} + +export async function submitIntake(photos: File[]): Promise { + const formData = new FormData() + photos.forEach((file) => formData.append('photos', file)) + const res = await fetch(`${BASE}/intake`, { + method: 'POST', + body: formData, + }) + if (!res.ok) { + const body = await res.json().catch(() => ({ error: res.statusText })) + throw new Error((body as { error?: string }).error ?? `HTTP ${res.status}`) + } + return res.json() as Promise } diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..57167a7 --- /dev/null +++ b/web/src/pages/DashboardPage.tsx @@ -0,0 +1,90 @@ +import { useState, useMemo } from 'react' +import { AppShell } from '@/components/layout/AppShell' +import { FilterBar } from '@/components/inventory/FilterBar' +import { ItemCard } from '@/components/inventory/ItemCard' +import { ItemRow } from '@/components/inventory/ItemRow' +import { useInventory } from '@/hooks/useInventory' +import { useUIStore } from '@/store/ui' +import { Loader2, AlertCircle } from 'lucide-react' + +export function DashboardPage() { + const { data: items, isLoading, error } = useInventory() + const { viewMode } = useUIStore() + const [search, setSearch] = useState('') + const [statusFilter, setStatusFilter] = useState('') + + const filtered = useMemo(() => { + if (!items) return [] + return items.filter((item) => { + const matchSearch = + !search || + item.name.toLowerCase().includes(search.toLowerCase()) || + (item.hw_id?.toLowerCase() ?? '').includes(search.toLowerCase()) || + (item.asset_tag?.toLowerCase() ?? '').includes(search.toLowerCase()) + const matchStatus = !statusFilter || item.catalog_status === statusFilter + return matchSearch && matchStatus + }) + }, [items, search, statusFilter]) + + return ( + + {/* Page header */} +
+

Inventory

+

All cataloged hardware in your homelab

+
+ + + + {/* Loading */} + {isLoading && ( +
+ + Loading inventory… +
+ )} + + {/* Error */} + {error && ( +
+ + {(error as Error).message} +
+ )} + + {/* Empty state */} + {!isLoading && !error && filtered.length === 0 && ( +
+

0

+

+ {items && items.length > 0 ? 'No items match your filters' : 'No items cataloged yet — add your first item'} +

+
+ )} + + {/* Grid view */} + {!isLoading && !error && filtered.length > 0 && viewMode === 'grid' && ( +
+ {filtered.map((item) => ( + + ))} +
+ )} + + {/* List view */} + {!isLoading && !error && filtered.length > 0 && viewMode === 'list' && ( +
+ {filtered.map((item) => ( + + ))} +
+ )} +
+ ) +} diff --git a/web/src/pages/ItemDetailPage.tsx b/web/src/pages/ItemDetailPage.tsx new file mode 100644 index 0000000..33721cd --- /dev/null +++ b/web/src/pages/ItemDetailPage.tsx @@ -0,0 +1,152 @@ +import { useParams, Link } from '@tanstack/react-router' +import { ArrowLeft, ExternalLink, Package, Loader2, AlertCircle } from 'lucide-react' +import { AppShell } from '@/components/layout/AppShell' +import { StatusBadge } from '@/components/inventory/StatusBadge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { useInventoryItem } from '@/hooks/useInventory' + +function FieldRow({ label, value }: { label: string; value?: string }) { + if (!value) return null + return ( +
+ {label} + {value} +
+ ) +} + +export function ItemDetailPage() { + const { id } = useParams({ from: '/item/$id' }) + const numericId = parseInt(id, 10) + const { data: item, isLoading, error } = useInventoryItem(numericId) + + if (isLoading) { + return ( + +
+ + Loading item… +
+
+ ) + } + + if (error || !item) { + return ( + +
+ + {(error as Error)?.message ?? 'Item not found'} +
+
+ ) + } + + const netboxUrl = `http://netbox.local/dcim/devices/${item.id}/` + + return ( + + {/* Back nav */} +
+ +
+ + {/* Header */} +
+
+

{item.hw_id || item.asset_tag}

+

{item.name}

+
+
+ + +
+
+ + {/* Two-column layout: photos (left on lg+) / fields (right or below) */} +
+ {/* Photos */} + + + Photos + + + {item.photo_urls.length > 0 ? ( +
+ {item.photo_urls.map((url, i) => ( + + {`${item.name} + + ))} +
+ ) : ( +
+ +

No photos

+
+ )} +
+
+ + {/* Fields */} +
+ + + Details + + + + + + + + + + + {item.ai_notes && ( + + + AI Notes + + +

{item.ai_notes}

+
+
+ )} + + {item.test_data && ( + + + Test Data + + +
+                  {(() => {
+                    try { return JSON.stringify(JSON.parse(item.test_data!), null, 2) }
+                    catch { return item.test_data }
+                  })()}
+                
+
+
+ )} +
+
+
+ ) +} diff --git a/web/src/router.tsx b/web/src/router.tsx index a186eda..d18bd7e 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -2,15 +2,16 @@ import { lazy, Suspense } from 'react' import { createRouter, createRoute, createRootRoute, Outlet } from '@tanstack/react-router' import { TanStackRouterDevtools } from '@tanstack/router-devtools' +const DashboardPage = lazy(() => import('./pages/DashboardPage').then((m) => ({ default: m.DashboardPage }))) +const ItemDetailPage = lazy(() => import('./pages/ItemDetailPage').then((m) => ({ default: m.ItemDetailPage }))) +const IntakePage = lazy(() => import('./pages/IntakePage').then((m) => ({ default: m.IntakePage }))) const ScanPage = lazy(() => import('./pages/ScanPage').then((m) => ({ default: m.ScanPage }))) -function Spinner() { - return ( -
-
-
- ) -} +const Spinner = () => ( +
+
+
+) // Root layout — wraps all routes with the app shell const rootRoute = createRootRoute({ @@ -22,14 +23,13 @@ const rootRoute = createRootRoute({ ), }) -// Routes — components are lazy-imported in Plan 03 and 04; stubs for now const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: () => ( -
-

HWLab — Dashboard loading…

-
+ }> + + ), }) @@ -37,9 +37,9 @@ const itemRoute = createRoute({ getParentRoute: () => rootRoute, path: '/item/$id', component: () => ( -
-

Item detail loading…

-
+ }> + + ), }) @@ -47,9 +47,9 @@ const intakeRoute = createRoute({ getParentRoute: () => rootRoute, path: '/intake', component: () => ( -
-

Intake wizard loading…

-
+ }> + + ), })