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
This commit is contained in:
parent
1867846a9f
commit
19c2bb7d05
4 changed files with 339 additions and 7 deletions
81
web/src/components/inventory/FilterBar.tsx
Normal file
81
web/src/components/inventory/FilterBar.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { Search, LayoutGrid, List } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useUIStore } from '@/store/ui'
|
||||||
|
|
||||||
|
interface FilterBarProps {
|
||||||
|
search: string
|
||||||
|
onSearchChange: (v: string) => void
|
||||||
|
statusFilter: string
|
||||||
|
onStatusChange: (v: string) => void
|
||||||
|
totalCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUSES = ['', 'draft', 'indexed', 'needs_research', 'researched', 'complete']
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
'': 'All Statuses',
|
||||||
|
draft: 'Draft',
|
||||||
|
indexed: 'Indexed',
|
||||||
|
needs_research: 'Needs Research',
|
||||||
|
researched: 'Researched',
|
||||||
|
complete: 'Complete',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterBar({ search, onSearchChange, statusFilter, onStatusChange, totalCount }: FilterBarProps) {
|
||||||
|
const { viewMode, setViewMode } = useUIStore()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mb-6">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative flex-1 min-w-48">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#a0a0a0]" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search items…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-3 py-2 bg-near-black border border-charcoal/80 rounded-sharp text-sm text-white placeholder-[#585858] focus:outline-none focus:border-volt/60 focus:ring-1 focus:ring-volt/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status filter */}
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => onStatusChange(e.target.value)}
|
||||||
|
className="px-3 py-2 bg-near-black border border-charcoal/80 rounded-sharp text-sm text-white focus:outline-none focus:border-volt/60"
|
||||||
|
>
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s} className="bg-near-black">
|
||||||
|
{STATUS_LABELS[s]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Item count */}
|
||||||
|
<span className="text-xs text-[#a0a0a0] label-upper mr-auto">
|
||||||
|
{totalCount} items
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* View toggle */}
|
||||||
|
<div className="flex rounded-sharp border border-charcoal/80 overflow-hidden">
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'grid' ? 'default' : 'secondary'}
|
||||||
|
size="icon"
|
||||||
|
className="rounded-none h-9 w-9"
|
||||||
|
onClick={() => setViewMode('grid')}
|
||||||
|
title="Grid view"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'list' ? 'default' : 'secondary'}
|
||||||
|
size="icon"
|
||||||
|
className="rounded-none h-9 w-9 border-l border-charcoal/80"
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
title="List view"
|
||||||
|
>
|
||||||
|
<List className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
90
web/src/pages/DashboardPage.tsx
Normal file
90
web/src/pages/DashboardPage.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { AppShell } from '@/components/layout/AppShell'
|
||||||
|
import { FilterBar } from '@/components/inventory/FilterBar'
|
||||||
|
import { ItemCard } from '@/components/inventory/ItemCard'
|
||||||
|
import { ItemRow } from '@/components/inventory/ItemRow'
|
||||||
|
import { useInventory } from '@/hooks/useInventory'
|
||||||
|
import { useUIStore } from '@/store/ui'
|
||||||
|
import { Loader2, AlertCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
const { data: items, isLoading, error } = useInventory()
|
||||||
|
const { viewMode } = useUIStore()
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState('')
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!items) return []
|
||||||
|
return items.filter((item) => {
|
||||||
|
const matchSearch =
|
||||||
|
!search ||
|
||||||
|
item.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
(item.hw_id?.toLowerCase() ?? '').includes(search.toLowerCase()) ||
|
||||||
|
(item.asset_tag?.toLowerCase() ?? '').includes(search.toLowerCase())
|
||||||
|
const matchStatus = !statusFilter || item.catalog_status === statusFilter
|
||||||
|
return matchSearch && matchStatus
|
||||||
|
})
|
||||||
|
}, [items, search, statusFilter])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="font-display font-black text-3xl text-white mb-1">Inventory</h1>
|
||||||
|
<p className="text-sm text-[#a0a0a0]">All cataloged hardware in your homelab</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FilterBar
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
onStatusChange={setStatusFilter}
|
||||||
|
totalCount={filtered.length}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center py-24 text-[#a0a0a0]">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
||||||
|
Loading inventory…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<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}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!isLoading && !error && filtered.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||||
|
<p className="font-display font-black text-4xl text-volt mb-2">0</p>
|
||||||
|
<p className="text-[#a0a0a0] text-sm">
|
||||||
|
{items && items.length > 0 ? 'No items match your filters' : 'No items cataloged yet — add your first item'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Grid view */}
|
||||||
|
{!isLoading && !error && filtered.length > 0 && viewMode === 'grid' && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||||
|
{filtered.map((item) => (
|
||||||
|
<ItemCard key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* List view */}
|
||||||
|
{!isLoading && !error && filtered.length > 0 && viewMode === 'list' && (
|
||||||
|
<div className="border border-charcoal/80 rounded-card overflow-hidden">
|
||||||
|
{filtered.map((item) => (
|
||||||
|
<ItemRow key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
152
web/src/pages/ItemDetailPage.tsx
Normal file
152
web/src/pages/ItemDetailPage.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
|
import { lazy, Suspense } from 'react'
|
||||||
import { createRouter, createRoute, createRootRoute, Outlet } from '@tanstack/react-router'
|
import { createRouter, createRoute, createRootRoute, Outlet } from '@tanstack/react-router'
|
||||||
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
|
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
|
||||||
|
|
||||||
|
const DashboardPage = lazy(() => import('./pages/DashboardPage').then(m => ({ default: m.DashboardPage })))
|
||||||
|
const ItemDetailPage = lazy(() => import('./pages/ItemDetailPage').then(m => ({ default: m.ItemDetailPage })))
|
||||||
|
|
||||||
|
const Spinner = () => (
|
||||||
|
<div className="min-h-screen bg-canvas flex items-center justify-center">
|
||||||
|
<div className="w-6 h-6 border-2 border-volt border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
// Root layout — wraps all routes with the app shell
|
// Root layout — wraps all routes with the app shell
|
||||||
const rootRoute = createRootRoute({
|
const rootRoute = createRootRoute({
|
||||||
component: () => (
|
component: () => (
|
||||||
|
|
@ -11,14 +21,13 @@ const rootRoute = createRootRoute({
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Routes — components are lazy-imported in Plan 03 and 04; stubs for now
|
|
||||||
const indexRoute = createRoute({
|
const indexRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/',
|
path: '/',
|
||||||
component: () => (
|
component: () => (
|
||||||
<div className="min-h-screen bg-canvas flex items-center justify-center">
|
<Suspense fallback={<Spinner />}>
|
||||||
<p className="text-volt font-display font-black text-2xl">HWLab — Dashboard loading…</p>
|
<DashboardPage />
|
||||||
</div>
|
</Suspense>
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -26,9 +35,9 @@ const itemRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/item/$id',
|
path: '/item/$id',
|
||||||
component: () => (
|
component: () => (
|
||||||
<div className="min-h-screen bg-canvas flex items-center justify-center">
|
<Suspense fallback={<Spinner />}>
|
||||||
<p className="text-volt font-display font-black text-2xl">Item detail loading…</p>
|
<ItemDetailPage />
|
||||||
</div>
|
</Suspense>
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue