chore: merge 03-03 worktree (dashboard+detail) — resolved router/api/AppShell conflicts
This commit is contained in:
commit
8c7563af9a
12 changed files with 718 additions and 40 deletions
145
.planning/phases/03-dashboard-intake-ui/03-03-SUMMARY.md
Normal file
145
.planning/phases/03-dashboard-intake-ui/03-03-SUMMARY.md
Normal file
|
|
@ -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
|
||||
81
web/src/components/inventory/FilterBar.tsx
Normal file
81
web/src/components/inventory/FilterBar.tsx
Normal file
|
|
@ -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<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>
|
||||
)
|
||||
}
|
||||
69
web/src/components/inventory/ItemCard.tsx
Normal file
69
web/src/components/inventory/ItemCard.tsx
Normal file
|
|
@ -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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
58
web/src/components/inventory/ItemRow.tsx
Normal file
58
web/src/components/inventory/ItemRow.tsx
Normal file
|
|
@ -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<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>
|
||||
)
|
||||
}
|
||||
16
web/src/components/inventory/StatusBadge.tsx
Normal file
16
web/src/components/inventory/StatusBadge.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
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>
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="min-h-screen bg-canvas text-white">
|
||||
<main className="mx-auto max-w-2xl px-4 py-8">
|
||||
<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>
|
||||
|
|
|
|||
27
web/src/components/layout/TopBar.tsx
Normal file
27
web/src/components/layout/TopBar.tsx
Normal file
|
|
@ -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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
17
web/src/hooks/useInventory.ts
Normal file
17
web/src/hooks/useInventory.ts
Normal file
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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<InventoryItem[]> {
|
||||
const res = await fetch('/api/inventory')
|
||||
const BASE = '/api'
|
||||
|
||||
async function fetchJSON<T>(url: string): Promise<T> {
|
||||
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<InventoryItem[]>
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export async function fetchInventoryItem(id: number): Promise<InventoryItem> {
|
||||
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<InventoryItem>
|
||||
export const fetchInventory = (): Promise<InventoryItem[]> =>
|
||||
fetchJSON<InventoryItem[]>(`${BASE}/inventory`)
|
||||
|
||||
export const fetchInventoryItem = (id: number): Promise<InventoryItem> =>
|
||||
fetchJSON<InventoryItem>(`${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<IntakeResponse> {
|
||||
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<IntakeResponse>
|
||||
}
|
||||
|
|
|
|||
90
web/src/pages/DashboardPage.tsx
Normal file
90
web/src/pages/DashboardPage.tsx
Normal file
|
|
@ -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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
152
web/src/pages/ItemDetailPage.tsx
Normal file
152
web/src/pages/ItemDetailPage.tsx
Normal file
|
|
@ -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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="min-h-screen bg-canvas flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-volt border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
|
||||
// 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: () => (
|
||||
<div className="min-h-screen bg-canvas flex items-center justify-center">
|
||||
<p className="text-volt font-display font-black text-2xl">HWLab — Dashboard loading…</p>
|
||||
</div>
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<DashboardPage />
|
||||
</Suspense>
|
||||
),
|
||||
})
|
||||
|
||||
|
|
@ -37,9 +37,9 @@ const itemRoute = createRoute({
|
|||
getParentRoute: () => rootRoute,
|
||||
path: '/item/$id',
|
||||
component: () => (
|
||||
<div className="min-h-screen bg-canvas flex items-center justify-center">
|
||||
<p className="text-volt font-display font-black text-2xl">Item detail loading…</p>
|
||||
</div>
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<ItemDetailPage />
|
||||
</Suspense>
|
||||
),
|
||||
})
|
||||
|
||||
|
|
@ -47,9 +47,9 @@ const intakeRoute = createRoute({
|
|||
getParentRoute: () => rootRoute,
|
||||
path: '/intake',
|
||||
component: () => (
|
||||
<div className="min-h-screen bg-canvas flex items-center justify-center">
|
||||
<p className="text-volt font-display font-black text-2xl">Intake wizard loading…</p>
|
||||
</div>
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<IntakePage />
|
||||
</Suspense>
|
||||
),
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue