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