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:
parent
86d0a949c5
commit
1867846a9f
7 changed files with 230 additions and 0 deletions
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>
|
||||
}
|
||||
13
web/src/components/layout/AppShell.tsx
Normal file
13
web/src/components/layout/AppShell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
30
web/src/lib/api.ts
Normal file
30
web/src/lib/api.ts
Normal 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}`)
|
||||
Loading…
Add table
Reference in a new issue