- web/src/components/inventory/FilterBar.tsx: search + status dropdown + grid/list toggle - web/src/pages/DashboardPage.tsx: / route with grid/list view, filters, loading/error/empty states - web/src/pages/ItemDetailPage.tsx: /item/$id route, two-column desktop, single-column mobile - web/src/router.tsx: lazy-load DashboardPage + ItemDetailPage, keep intake/scan stubs
152 lines
5.6 KiB
TypeScript
152 lines
5.6 KiB
TypeScript
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>
|
|
)
|
|
}
|