homelabby/web/src/pages/ItemDetailPage.tsx
Mikkel Georgsen 19c2bb7d05 feat(03-03): DashboardPage, ItemDetailPage, FilterBar, and router wiring
- 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
2026-04-10 06:21:48 +00:00

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