diff --git a/web/src/components/inventory/FilterBar.tsx b/web/src/components/inventory/FilterBar.tsx new file mode 100644 index 0000000..444c009 --- /dev/null +++ b/web/src/components/inventory/FilterBar.tsx @@ -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 = { + '': '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 ( +
+ {/* Search */} +
+ + 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" + /> +
+ + {/* Status filter */} + + + {/* Item count */} + + {totalCount} items + + + {/* View toggle */} +
+ + +
+
+ ) +} diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..57167a7 --- /dev/null +++ b/web/src/pages/DashboardPage.tsx @@ -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 ( + + {/* Page header */} +
+

Inventory

+

All cataloged hardware in your homelab

+
+ + + + {/* Loading */} + {isLoading && ( +
+ + Loading inventory… +
+ )} + + {/* Error */} + {error && ( +
+ + {(error as Error).message} +
+ )} + + {/* Empty state */} + {!isLoading && !error && filtered.length === 0 && ( +
+

0

+

+ {items && items.length > 0 ? 'No items match your filters' : 'No items cataloged yet — add your first item'} +

+
+ )} + + {/* Grid view */} + {!isLoading && !error && filtered.length > 0 && viewMode === 'grid' && ( +
+ {filtered.map((item) => ( + + ))} +
+ )} + + {/* List view */} + {!isLoading && !error && filtered.length > 0 && viewMode === 'list' && ( +
+ {filtered.map((item) => ( + + ))} +
+ )} +
+ ) +} diff --git a/web/src/pages/ItemDetailPage.tsx b/web/src/pages/ItemDetailPage.tsx new file mode 100644 index 0000000..33721cd --- /dev/null +++ b/web/src/pages/ItemDetailPage.tsx @@ -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 ( +
+ {label} + {value} +
+ ) +} + +export function ItemDetailPage() { + const { id } = useParams({ from: '/item/$id' }) + const numericId = parseInt(id, 10) + const { data: item, isLoading, error } = useInventoryItem(numericId) + + if (isLoading) { + return ( + +
+ + Loading item… +
+
+ ) + } + + if (error || !item) { + return ( + +
+ + {(error as Error)?.message ?? 'Item not found'} +
+
+ ) + } + + const netboxUrl = `http://netbox.local/dcim/devices/${item.id}/` + + return ( + + {/* Back nav */} +
+ +
+ + {/* Header */} +
+
+

{item.hw_id || item.asset_tag}

+

{item.name}

+
+
+ + +
+
+ + {/* Two-column layout: photos (left on lg+) / fields (right or below) */} +
+ {/* Photos */} + + + Photos + + + {item.photo_urls.length > 0 ? ( +
+ {item.photo_urls.map((url, i) => ( + + {`${item.name} + + ))} +
+ ) : ( +
+ +

No photos

+
+ )} +
+
+ + {/* Fields */} +
+ + + Details + + + + + + + + + + + {item.ai_notes && ( + + + AI Notes + + +

{item.ai_notes}

+
+
+ )} + + {item.test_data && ( + + + Test Data + + +
+                  {(() => {
+                    try { return JSON.stringify(JSON.parse(item.test_data!), null, 2) }
+                    catch { return item.test_data }
+                  })()}
+                
+
+
+ )} +
+
+
+ ) +} diff --git a/web/src/router.tsx b/web/src/router.tsx index 5113fc7..6f7b9ef 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -1,6 +1,16 @@ +import { lazy, Suspense } from 'react' import { createRouter, createRoute, createRootRoute, Outlet } from '@tanstack/react-router' 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 = () => ( +
+
+
+) + // Root layout — wraps all routes with the app shell const rootRoute = createRootRoute({ component: () => ( @@ -11,14 +21,13 @@ const rootRoute = createRootRoute({ ), }) -// Routes — components are lazy-imported in Plan 03 and 04; stubs for now const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: () => ( -
-

HWLab — Dashboard loading…

-
+ }> + + ), }) @@ -26,9 +35,9 @@ const itemRoute = createRoute({ getParentRoute: () => rootRoute, path: '/item/$id', component: () => ( -
-

Item detail loading…

-
+ }> + + ), })