884 lines
34 KiB
Markdown
884 lines
34 KiB
Markdown
---
|
|
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'))"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.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
|
|
|
|
<interfaces>
|
|
<!-- From Plan 02: InventoryItemResponse — the JSON shape from GET /api/inventory -->
|
|
```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[]
|
|
}
|
|
```
|
|
|
|
<!-- From Plan 01: Zustand store for UI state -->
|
|
```typescript
|
|
import { useUIStore } from '@/store/ui'
|
|
const { viewMode, setViewMode } = useUIStore()
|
|
// viewMode: 'grid' | 'list'
|
|
```
|
|
|
|
<!-- From Plan 01: TanStack Router — route params -->
|
|
// /item/$id route param access:
|
|
// import { useParams } from '@tanstack/react-router'
|
|
// const { id } = useParams({ from: '/item/$id' })
|
|
|
|
<!-- From Plan 01: ClickHouse design tokens available as Tailwind classes -->
|
|
// 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)
|
|
|
|
<!-- From Plan 01: shadcn/ui components -->
|
|
// import { Button } from '@/components/ui/button'
|
|
// import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/components/ui/card'
|
|
// import { Badge } from '@/components/ui/badge'
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: API client, TanStack Query hooks, AppShell layout, and ItemCard/ItemRow components</name>
|
|
<files>
|
|
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
|
|
</files>
|
|
<action>
|
|
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<T>(url: string): Promise<T> {
|
|
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<T>
|
|
}
|
|
|
|
export const fetchInventory = (): Promise<InventoryItem[]> =>
|
|
fetchJSON<InventoryItem[]>(`${BASE}/inventory`)
|
|
|
|
export const fetchInventoryItem = (id: number): Promise<InventoryItem> =>
|
|
fetchJSON<InventoryItem>(`${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<string, string> = {
|
|
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 <Badge variant={variant}>{STATUS_LABELS[status] ?? status}</Badge>
|
|
}
|
|
```
|
|
|
|
**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 (
|
|
<Link to="/item/$id" params={{ id: String(item.id) }} className="block">
|
|
<Card
|
|
className={cn(
|
|
'hover:border-volt/60 transition-colors cursor-pointer h-full flex flex-col',
|
|
className,
|
|
)}
|
|
>
|
|
{/* Photo */}
|
|
<div className="aspect-video bg-near-black rounded-t-card overflow-hidden flex items-center justify-center border-b border-charcoal/80">
|
|
{item.photo_urls.length > 0 ? (
|
|
<img
|
|
src={item.photo_urls[0]}
|
|
alt={item.name}
|
|
className="w-full h-full object-cover"
|
|
loading="lazy"
|
|
/>
|
|
) : (
|
|
<Package className="w-12 h-12 text-charcoal" />
|
|
)}
|
|
</div>
|
|
|
|
<CardHeader className="pb-2">
|
|
{/* HW ID */}
|
|
<p className="font-code text-volt text-xs label-upper">{item.hw_id || item.asset_tag}</p>
|
|
<CardTitle className="text-white text-sm leading-tight line-clamp-2">{item.name}</CardTitle>
|
|
</CardHeader>
|
|
|
|
<CardContent className="pb-2 flex-1">
|
|
<StatusBadge status={item.catalog_status} />
|
|
{item.ai_notes && (
|
|
<p className="mt-2 text-xs text-[#a0a0a0] line-clamp-2">{item.ai_notes}</p>
|
|
)}
|
|
</CardContent>
|
|
|
|
<CardFooter className="pt-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-xs text-[#a0a0a0] hover:text-volt p-0 h-auto"
|
|
asChild
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<a href={netboxUrl} target="_blank" rel="noopener noreferrer">
|
|
<ExternalLink className="w-3 h-3 mr-1" />
|
|
NetBox
|
|
</a>
|
|
</Button>
|
|
</CardFooter>
|
|
</Card>
|
|
</Link>
|
|
)
|
|
}
|
|
```
|
|
|
|
**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 `<tr>` 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<string, string> = {
|
|
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 (
|
|
<Link
|
|
to="/item/$id"
|
|
params={{ id: String(item.id) }}
|
|
className="flex items-center gap-3 px-4 py-3 border-b border-charcoal/40 hover:bg-near-black/60 transition-colors group"
|
|
>
|
|
{/* Status indicator */}
|
|
<div className={`w-1 h-8 rounded-full flex-shrink-0 ${dot}`} />
|
|
|
|
{/* HW ID */}
|
|
<span className="font-code text-volt text-xs w-24 flex-shrink-0">{item.hw_id || item.asset_tag}</span>
|
|
|
|
{/* Name */}
|
|
<span className="text-white text-sm font-medium flex-1 truncate">{item.name}</span>
|
|
|
|
{/* Status */}
|
|
<div className="flex-shrink-0">
|
|
<StatusBadge status={item.catalog_status} />
|
|
</div>
|
|
|
|
{/* Notes preview */}
|
|
{item.ai_notes && (
|
|
<span className="text-xs text-[#a0a0a0] truncate max-w-xs hidden lg:block">{item.ai_notes}</span>
|
|
)}
|
|
|
|
{/* Quick action */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-xs text-[#a0a0a0] hover:text-volt p-1 h-auto opacity-0 group-hover:opacity-100 transition-opacity"
|
|
asChild
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<a href={netboxUrl} target="_blank" rel="noopener noreferrer">
|
|
<ExternalLink className="w-3.5 h-3.5" />
|
|
</a>
|
|
</Button>
|
|
</Link>
|
|
)
|
|
}
|
|
```
|
|
|
|
**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 (
|
|
<header className="sticky top-0 z-50 flex items-center justify-between px-4 py-3 border-b border-charcoal/80 bg-canvas/95 backdrop-blur-sm">
|
|
<Link to="/" className="font-display font-black text-xl text-volt tracking-tight">
|
|
HWLab
|
|
</Link>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="sm" asChild>
|
|
<Link to="/scan">
|
|
<QrCode className="w-4 h-4 mr-1.5" />
|
|
<span className="hidden sm:inline">Scan</span>
|
|
</Link>
|
|
</Button>
|
|
<Button variant="forest" size="sm" asChild>
|
|
<Link to="/intake">
|
|
<Plus className="w-4 h-4 mr-1.5" />
|
|
<span className="hidden sm:inline">Add Item</span>
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
</header>
|
|
)
|
|
}
|
|
```
|
|
|
|
**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 (
|
|
<div className="min-h-screen bg-canvas flex flex-col">
|
|
<TopBar />
|
|
<main className="flex-1 px-4 py-6 max-w-7xl mx-auto w-full">
|
|
{children}
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -10</automated>
|
|
</verify>
|
|
<done>
|
|
`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.
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: DashboardPage, FilterBar, ItemDetailPage, and router wiring</name>
|
|
<files>
|
|
web/src/components/inventory/FilterBar.tsx,
|
|
web/src/pages/DashboardPage.tsx,
|
|
web/src/pages/ItemDetailPage.tsx,
|
|
web/src/router.tsx
|
|
</files>
|
|
<action>
|
|
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<string, string> = {
|
|
'': '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 (
|
|
<div className="flex flex-wrap items-center gap-3 mb-6">
|
|
{/* Search */}
|
|
<div className="relative flex-1 min-w-48">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#a0a0a0]" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search items…"
|
|
value={search}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
|
|
{/* Status filter */}
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(e) => onStatusChange(e.target.value)}
|
|
className="px-3 py-2 bg-near-black border border-charcoal/80 rounded-sharp text-sm text-white focus:outline-none focus:border-volt/60"
|
|
>
|
|
{STATUSES.map((s) => (
|
|
<option key={s} value={s} className="bg-near-black">
|
|
{STATUS_LABELS[s]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Item count */}
|
|
<span className="text-xs text-[#a0a0a0] label-upper mr-auto">
|
|
{totalCount} items
|
|
</span>
|
|
|
|
{/* View toggle */}
|
|
<div className="flex rounded-sharp border border-charcoal/80 overflow-hidden">
|
|
<Button
|
|
variant={viewMode === 'grid' ? 'default' : 'secondary'}
|
|
size="icon"
|
|
className="rounded-none h-9 w-9"
|
|
onClick={() => setViewMode('grid')}
|
|
title="Grid view"
|
|
>
|
|
<LayoutGrid className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant={viewMode === 'list' ? 'default' : 'secondary'}
|
|
size="icon"
|
|
className="rounded-none h-9 w-9 border-l border-charcoal/80"
|
|
onClick={() => setViewMode('list')}
|
|
title="List view"
|
|
>
|
|
<List className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
**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 (
|
|
<AppShell>
|
|
{/* Page header */}
|
|
<div className="mb-6">
|
|
<h1 className="font-display font-black text-3xl text-white mb-1">Inventory</h1>
|
|
<p className="text-sm text-[#a0a0a0]">All cataloged hardware in your homelab</p>
|
|
</div>
|
|
|
|
<FilterBar
|
|
search={search}
|
|
onSearchChange={setSearch}
|
|
statusFilter={statusFilter}
|
|
onStatusChange={setStatusFilter}
|
|
totalCount={filtered.length}
|
|
/>
|
|
|
|
{/* Loading */}
|
|
{isLoading && (
|
|
<div className="flex items-center justify-center py-24 text-[#a0a0a0]">
|
|
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
|
Loading inventory…
|
|
</div>
|
|
)}
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="flex items-center gap-3 p-4 border border-red-500/40 rounded-card bg-red-500/10 text-red-400">
|
|
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
|
<span className="text-sm">{(error as Error).message}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Empty state */}
|
|
{!isLoading && !error && filtered.length === 0 && (
|
|
<div className="flex flex-col items-center justify-center py-24 text-center">
|
|
<p className="font-display font-black text-4xl text-volt mb-2">0</p>
|
|
<p className="text-[#a0a0a0] text-sm">
|
|
{items && items.length > 0 ? 'No items match your filters' : 'No items cataloged yet — add your first item'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Grid view */}
|
|
{!isLoading && !error && filtered.length > 0 && viewMode === 'grid' && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
|
{filtered.map((item) => (
|
|
<ItemCard key={item.id} item={item} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* List view */}
|
|
{!isLoading && !error && filtered.length > 0 && viewMode === 'list' && (
|
|
<div className="border border-charcoal/80 rounded-card overflow-hidden">
|
|
{filtered.map((item) => (
|
|
<ItemRow key={item.id} item={item} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</AppShell>
|
|
)
|
|
}
|
|
```
|
|
|
|
**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 (
|
|
<div className="flex gap-3 py-2 border-b border-charcoal/40 last:border-0">
|
|
<span className="label-upper text-[#a0a0a0] w-36 flex-shrink-0">{label}</span>
|
|
<span className="text-white text-sm flex-1 break-all">{value}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function ItemDetailPage() {
|
|
const { id } = useParams({ from: '/item/$id' })
|
|
const numericId = parseInt(id, 10)
|
|
const { data: item, isLoading, error } = useInventoryItem(numericId)
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<AppShell>
|
|
<div className="flex items-center justify-center py-24 text-[#a0a0a0]">
|
|
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
|
Loading item…
|
|
</div>
|
|
</AppShell>
|
|
)
|
|
}
|
|
|
|
if (error || !item) {
|
|
return (
|
|
<AppShell>
|
|
<div className="flex items-center gap-3 p-4 border border-red-500/40 rounded-card bg-red-500/10 text-red-400">
|
|
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
|
<span className="text-sm">{(error as Error)?.message ?? 'Item not found'}</span>
|
|
</div>
|
|
</AppShell>
|
|
)
|
|
}
|
|
|
|
const netboxUrl = `http://netbox.local/dcim/devices/${item.id}/`
|
|
|
|
return (
|
|
<AppShell>
|
|
{/* Back nav */}
|
|
<div className="mb-4">
|
|
<Button variant="ghost" size="sm" asChild>
|
|
<Link to="/">
|
|
<ArrowLeft className="w-4 h-4 mr-1.5" />
|
|
Inventory
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Header */}
|
|
<div className="flex flex-wrap items-start gap-4 mb-6">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-code text-volt text-sm label-upper mb-1">{item.hw_id || item.asset_tag}</p>
|
|
<h1 className="font-display font-black text-2xl text-white leading-tight">{item.name}</h1>
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
<StatusBadge status={item.catalog_status} />
|
|
<Button variant="outline" size="sm" asChild>
|
|
<a href={netboxUrl} target="_blank" rel="noopener noreferrer">
|
|
<ExternalLink className="w-3.5 h-3.5 mr-1.5" />
|
|
NetBox
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Two-column layout: photos (left on lg+) / fields (right or below) */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Photos */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-sm label-upper text-[#a0a0a0]">Photos</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{item.photo_urls.length > 0 ? (
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{item.photo_urls.map((url, i) => (
|
|
<a key={i} href={url} target="_blank" rel="noopener noreferrer">
|
|
<img
|
|
src={url}
|
|
alt={`${item.name} photo ${i + 1}`}
|
|
className="w-full rounded-sharp object-cover aspect-square hover:opacity-80 transition-opacity"
|
|
loading="lazy"
|
|
/>
|
|
</a>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center py-12 text-[#585858]">
|
|
<Package className="w-12 h-12 mb-2" />
|
|
<p className="text-sm">No photos</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Fields */}
|
|
<div className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-sm label-upper text-[#a0a0a0]">Details</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<FieldRow label="HW ID" value={item.hw_id || item.asset_tag} />
|
|
<FieldRow label="Status" value={item.catalog_status} />
|
|
<FieldRow label="Firmware" value={item.firmware_version} />
|
|
<FieldRow label="Test Date" value={item.test_date} />
|
|
<FieldRow label="Product URL" value={item.product_url} />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{item.ai_notes && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-sm label-upper text-[#a0a0a0]">AI Notes</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-sm text-white whitespace-pre-wrap">{item.ai_notes}</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{item.test_data && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-sm label-upper text-[#a0a0a0]">Test Data</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<pre className="font-code text-xs text-[#a0a0a0] bg-near-black p-3 rounded-sharp overflow-x-auto whitespace-pre-wrap break-words">
|
|
{(() => {
|
|
try { return JSON.stringify(JSON.parse(item.test_data!), null, 2) }
|
|
catch { return item.test_data }
|
|
})()}
|
|
</pre>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</AppShell>
|
|
)
|
|
}
|
|
```
|
|
|
|
**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 = () => (
|
|
<div className="min-h-screen bg-canvas flex items-center justify-center">
|
|
<div className="w-6 h-6 border-2 border-volt border-t-transparent rounded-full animate-spin" />
|
|
</div>
|
|
)
|
|
|
|
const indexRoute = createRoute({
|
|
getParentRoute: () => rootRoute,
|
|
path: '/',
|
|
component: () => (
|
|
<Suspense fallback={<Spinner />}>
|
|
<DashboardPage />
|
|
</Suspense>
|
|
),
|
|
})
|
|
|
|
const itemRoute = createRoute({
|
|
getParentRoute: () => rootRoute,
|
|
path: '/item/$id',
|
|
component: () => (
|
|
<Suspense fallback={<Spinner />}>
|
|
<ItemDetailPage />
|
|
</Suspense>
|
|
),
|
|
})
|
|
```
|
|
|
|
Keep intakeRoute and scanRoute as stubs (Plan 04 and 05 replace them).
|
|
|
|
Run `npm run build` to confirm TypeScript compiles with no errors.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -15</automated>
|
|
</verify>
|
|
<done>
|
|
`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.
|
|
</done>
|
|
</task>
|
|
|
|
<task type="checkpoint:human-verify" gate="blocking">
|
|
<what-built>
|
|
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
|
|
</what-built>
|
|
<how-to-verify>
|
|
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.
|
|
</how-to-verify>
|
|
<resume-signal>Type "approved" or describe visual/functional issues to fix</resume-signal>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## 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 |
|
|
</threat_model>
|
|
|
|
<verification>
|
|
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
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- 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
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/03-dashboard-intake-ui/03-03-SUMMARY.md` following the summary template.
|
|
</output>
|