- 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
90 lines
3.2 KiB
TypeScript
90 lines
3.2 KiB
TypeScript
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>
|
|
)
|
|
}
|