--- phase: 03-dashboard-intake-ui plan: "03" type: execute wave: 2 depends_on: ["03-01", "03-02"] files_modified: - web/src/lib/api.ts - web/src/hooks/useInventory.ts - web/src/components/layout/AppShell.tsx - web/src/components/layout/TopBar.tsx - web/src/components/inventory/ItemCard.tsx - web/src/components/inventory/ItemRow.tsx - web/src/components/inventory/FilterBar.tsx - web/src/components/inventory/StatusBadge.tsx - web/src/pages/DashboardPage.tsx - web/src/pages/ItemDetailPage.tsx - web/src/router.tsx autonomous: true requirements: [UI-01, UI-02, UI-04, UI-05, PWA-02] must_haves: truths: - "Visiting / shows the inventory dashboard with a grid of item cards on desktop" - "Each card shows: photo (or placeholder icon), HW ID, item name, catalog_status badge, and key spec line from ai_notes" - "Grid/list toggle switches the layout without a page reload" - "Filter dropdowns for category, catalog_status, and location narrow the displayed items client-side" - "Clicking a card navigates to /item/:id and shows the item detail page" - "Item detail shows all custom fields, photo URLs as images, ai_notes, test_data (raw JSON)" - "Quick actions on dashboard cards: 'View in NetBox' opens new tab to NetBox device URL" - "Item detail page is readable on a 390px-wide mobile screen (PWA-02)" artifacts: - path: "web/src/lib/api.ts" provides: "typed fetch wrappers for GET /api/inventory and GET /api/inventory/:id" exports: ["InventoryItem", "fetchInventory", "fetchInventoryItem"] - path: "web/src/hooks/useInventory.ts" provides: "TanStack Query hooks wrapping api.ts" exports: ["useInventory", "useInventoryItem"] - path: "web/src/components/inventory/ItemCard.tsx" provides: "Grid card component (photo, HW ID, name, status badge, quick actions)" - path: "web/src/components/inventory/FilterBar.tsx" provides: "Client-side filter controls (catalog_status, search by name)" - path: "web/src/pages/DashboardPage.tsx" provides: "/ route — inventory grid/list with filters" - path: "web/src/pages/ItemDetailPage.tsx" provides: "/item/$id route — full detail view, mobile responsive" key_links: - from: "web/src/pages/DashboardPage.tsx" to: "web/src/hooks/useInventory.ts" via: "useInventory() hook → TanStack Query → GET /api/inventory" - from: "web/src/pages/ItemDetailPage.tsx" to: "web/src/hooks/useInventory.ts" via: "useInventoryItem(id) hook → TanStack Query → GET /api/inventory/:id" - from: "web/src/router.tsx" to: "web/src/pages/DashboardPage.tsx" via: "indexRoute component replaced with lazy(() => import('./pages/DashboardPage'))" --- Build the inventory dashboard and item detail views — the primary UI for browsing the homelab inventory. Purpose: Users need to see, filter, and navigate their cataloged hardware. This is the core browsing experience wired to the real backend. Output: Dashboard page with grid/list toggle and filters; item detail page with all custom fields; mobile-responsive layout following ClickHouse design system. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/phases/03-dashboard-intake-ui/03-CONTEXT.md @.planning/phases/03-dashboard-intake-ui/03-01-SUMMARY.md @.planning/phases/03-dashboard-intake-ui/03-02-SUMMARY.md ```typescript // TypeScript equivalent of internal/api/handlers.InventoryItemResponse interface InventoryItem { id: number name: string asset_tag: string hw_id: string catalog_status: string // draft | indexed | needs_research | researched | complete product_url?: string firmware_version?: string test_date?: string test_data?: string // raw JSON string ai_notes?: string photo_urls: string[] } ``` ```typescript import { useUIStore } from '@/store/ui' const { viewMode, setViewMode } = useUIStore() // viewMode: 'grid' | 'list' ``` // /item/$id route param access: // import { useParams } from '@tanstack/react-router' // const { id } = useParams({ from: '/item/$id' }) // bg-canvas (#000000), bg-near-black (#141414), text-volt (#faff69) // bg-forest (#166534), border-charcoal/80 (rgba(65,65,65,0.8)) // rounded-card (8px), rounded-sharp (4px) // font-display font-black (Inter 900) // label-upper (uppercase tracked label) // import { Button } from '@/components/ui/button' // import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/components/ui/card' // import { Badge } from '@/components/ui/badge' Task 1: API client, TanStack Query hooks, AppShell layout, and ItemCard/ItemRow components web/src/lib/api.ts, web/src/hooks/useInventory.ts, web/src/components/layout/AppShell.tsx, web/src/components/layout/TopBar.tsx, web/src/components/inventory/ItemCard.tsx, web/src/components/inventory/ItemRow.tsx, web/src/components/inventory/StatusBadge.tsx Read first: `web/src/store/ui.ts`, `web/src/components/ui/card.tsx`, `web/src/components/ui/badge.tsx`, `web/src/components/ui/button.tsx`. **1. Create web/src/lib/api.ts** — typed fetch wrappers: ```typescript export interface InventoryItem { id: number name: string asset_tag: string hw_id: string catalog_status: string product_url?: string firmware_version?: string test_date?: string test_data?: string ai_notes?: string photo_urls: string[] } const BASE = '/api' async function fetchJSON(url: string): Promise { const res = await fetch(url) if (!res.ok) { const body = await res.json().catch(() => ({ error: res.statusText })) throw new Error(body.error ?? `HTTP ${res.status}`) } return res.json() as Promise } export const fetchInventory = (): Promise => fetchJSON(`${BASE}/inventory`) export const fetchInventoryItem = (id: number): Promise => fetchJSON(`${BASE}/inventory/${id}`) ``` **2. Create web/src/hooks/useInventory.ts:** ```typescript 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, }) } ``` **3. Create web/src/components/inventory/StatusBadge.tsx** — maps catalog_status to Badge variant: ```typescript 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} } ``` **4. Create web/src/components/inventory/ItemCard.tsx** — grid card (ClickHouse style): The card shows: photo or placeholder icon, HW ID (volt text, font-code), item name (white, font-semibold), StatusBadge, ai_notes preview (silver, truncated to 2 lines), quick action buttons (View in NetBox). ```typescript 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}

)}
) } ``` **5. Create web/src/components/inventory/ItemRow.tsx** — list-mode row: Horizontal layout: status indicator bar (left, 4px, volt for indexed/complete, yellow for needs_research, gray for draft), HW ID, name, status badge, ai_notes preview, quick action. Uses `` or a flex div. ```typescript 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 */} ) } ``` **6. Create web/src/components/layout/TopBar.tsx** — app-wide navigation bar: Left: "HWLab" in Inter Black volt text. Right: intake button (forest green), scan QR button (outline). ```typescript import { Plus, QrCode } from 'lucide-react' import { Link } from '@tanstack/react-router' import { Button } from '@/components/ui/button' export function TopBar() { return (
HWLab
) } ``` **7. Create web/src/components/layout/AppShell.tsx** — wraps TopBar + main content area: ```typescript import { TopBar } from './TopBar' export function AppShell({ children }: { children: React.ReactNode }) { return (
{children}
) } ``` cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -10 `npm run build` succeeds. `web/src/lib/api.ts` exports `InventoryItem`, `fetchInventory`, `fetchInventoryItem`. `web/src/hooks/useInventory.ts` exports `useInventory`, `useInventoryItem`. All five component files exist with no TypeScript errors. Task 2: DashboardPage, FilterBar, ItemDetailPage, and router wiring web/src/components/inventory/FilterBar.tsx, web/src/pages/DashboardPage.tsx, web/src/pages/ItemDetailPage.tsx, web/src/router.tsx Read first: `web/src/router.tsx` (current stub routes), `web/src/store/ui.ts` (viewMode), `web/src/hooks/useInventory.ts`. **1. Create web/src/components/inventory/FilterBar.tsx** — client-side filter controls: Filter state is local component state (not Zustand — ephemeral UI). Filters: text search (name/hw_id), catalog_status select. ```typescript 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 */}
) } ``` **2. Create web/src/pages/DashboardPage.tsx** — the main inventory view: ```typescript 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) => ( ))}
)}
) } ``` **3. Create web/src/pages/ItemDetailPage.tsx** — full item detail, mobile-responsive: Layout: TopBar via AppShell, back button, two-column on desktop (photos left, fields right), single column on mobile. Shows all custom fields, photos, raw test_data in a code block. ```typescript import { useParams, Link } from '@tanstack/react-router' import { ArrowLeft, ExternalLink, Package } 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' import { Loader2, AlertCircle } from 'lucide-react' 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 }
                      })()}
                    
)}
) } ``` **4. Update web/src/router.tsx** — replace stub components with real pages: Read current `web/src/router.tsx`, then update `indexRoute` and `itemRoute` to use the real page components. Use lazy imports to keep bundle splitting: ```typescript import { lazy, Suspense } from 'react' // ... existing imports ... const DashboardPage = lazy(() => import('./pages/DashboardPage').then(m => ({ default: m.DashboardPage }))) const ItemDetailPage = lazy(() => import('./pages/ItemDetailPage').then(m => ({ default: m.ItemDetailPage }))) const Spinner = () => (
) const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: () => ( }> ), }) const itemRoute = createRoute({ getParentRoute: () => rootRoute, path: '/item/$id', component: () => ( }> ), }) ``` Keep intakeRoute and scanRoute as stubs (Plan 04 and 05 replace them). Run `npm run build` to confirm TypeScript compiles with no errors. cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -15 `npm run build` exits 0 with no TypeScript errors. `web/src/pages/DashboardPage.tsx` renders FilterBar (search + status dropdown + view toggle), grid/list conditional rendering, loading/error/empty states. `web/src/pages/ItemDetailPage.tsx` shows two-column layout with photo grid and field rows. `web/src/router.tsx` lazy-loads DashboardPage and ItemDetailPage. Dashboard and Item detail views wired to real backend inventory API. - `/` — inventory grid (5-col on xl, responsive) with search, status filter, view toggle - `/item/:id` — full detail with photos, custom fields, ai_notes, test_data, NetBox link - ClickHouse design: black canvas, volt accents, charcoal card borders, Inter 900 display - Grid/list toggle persists in Zustand (survives navigation within session) - StatusBadge color-coded for all 5 catalog statuses 1. Start Go backend: `cd /home/mikkel/homelabby && go run ./cmd/hwlab/...` 2. Start Vite dev server: `cd web && npm run dev` 3. Open http://localhost:5173 Check: - [ ] Background is pure black (#000000) - [ ] "HWLab" in the TopBar is volt (#faff69) Inter Black - [ ] "Add Item" button is forest green, "Scan" is ghost/outlined - [ ] With no NetBox items: empty state shows "0" in volt color, descriptive text - [ ] With NetBox items (if live NetBox available): cards render with HW ID, name, status badge - [ ] Grid/list toggle switches layout - [ ] Search filters items client-side as you type - [ ] Status dropdown filters correctly - [ ] Clicking a card navigates to /item/:id - [ ] Item detail shows two columns on desktop, single column on mobile (resize to 390px) - [ ] "NetBox" link on detail page opens new tab If NetBox is unavailable: verify the error state renders (red border, alert icon, error message) rather than crashing. Type "approved" or describe visual/functional issues to fix ## Trust Boundaries | Boundary | Description | |----------|-------------| | React → GET /api/inventory | Frontend fetches inventory; all data comes from backend, not directly from NetBox | | External links → NetBox | "View in NetBox" links open user-provided URLs — these use device IDs only | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-03-08 | Info Disclosure | photo_urls displayed as <img> | accept | URLs come from NetBox custom fields (trusted data store); homelab-internal only; no external URL injection path | | T-03-09 | Tampering | test_data rendered in <pre> | mitigate | React escapes HTML in JSX; test_data is inside a <pre> tag as text content, not dangerouslySetInnerHTML — no XSS risk | | T-03-10 | Info Disclosure | ai_notes rendered as text | accept | whitespace-pre-wrap rendering does not execute scripts; React's JSX rendering is safe | 1. `cd web && npm run build` — exits 0, no TypeScript errors 2. `go build ./...` — Go still compiles 3. Visual verification via checkpoint task 4. `grep "DashboardPage" web/src/router.tsx` — lazy import present 5. `grep "useInventory" web/src/pages/DashboardPage.tsx` — TanStack Query hook wired - Dashboard renders grid/list of inventory items fetched from GET /api/inventory - Filtering by name/hw_id and catalog_status works client-side without page reload - Grid/list toggle works and persists in Zustand - Item detail shows all custom fields, photos, and test data - Mobile responsive: single column at 390px width (PWA-02) - Quick action "View in NetBox" opens correct URL in new tab (UI-05) - ClickHouse design system: black canvas, volt text, charcoal borders, Inter 900 display - Human verification checkpoint passed After completion, create `.planning/phases/03-dashboard-intake-ui/03-03-SUMMARY.md` following the summary template.