feat(03-03): API client, TanStack Query hooks, layout shell, inventory item components

- web/src/lib/api.ts: typed fetch wrappers for GET /api/inventory and GET /api/inventory/:id
- web/src/hooks/useInventory.ts: useInventory + useInventoryItem TanStack Query hooks
- web/src/components/inventory/StatusBadge.tsx: catalog_status → Badge variant mapping
- web/src/components/inventory/ItemCard.tsx: grid card with photo, HW ID, name, status, NetBox link
- web/src/components/inventory/ItemRow.tsx: list-mode row with status color indicator
- web/src/components/layout/TopBar.tsx: sticky nav with HWLab volt brand, Add Item + Scan buttons
- web/src/components/layout/AppShell.tsx: TopBar + main content wrapper
This commit is contained in:
Mikkel Georgsen 2026-04-10 06:21:43 +00:00
parent 86d0a949c5
commit 1867846a9f
7 changed files with 230 additions and 0 deletions

View 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>
)
}

View 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>
)
}

View 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>
}

View file

@ -0,0 +1,13 @@
import * as React from 'react'
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>
)
}

View 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>
)
}

View 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,
})
}

30
web/src/lib/api.ts Normal file
View file

@ -0,0 +1,30 @@
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 as { error?: string }).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}`)