feat(07-02): frontend NL search bar wired to GET /api/search
- web/src/lib/api.ts: add fetchSearch(q) returning Promise<InventoryItem[]> - web/src/components/inventory/FilterBar.tsx: NL search input with 400ms debounce, volt accent styling, Sparkles icon, Loader2 spinner during search - web/src/pages/DashboardPage.tsx: nlQuery state + useQuery hook (enabled >2 chars), displays searchResults when NL active, local filter unchanged when NL empty
This commit is contained in:
parent
9db7707a64
commit
7db093c696
3 changed files with 133 additions and 60 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
import { Search, LayoutGrid, List } from 'lucide-react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Search, LayoutGrid, List, Loader2, Sparkles } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useUIStore } from '@/store/ui'
|
import { useUIStore } from '@/store/ui'
|
||||||
|
|
||||||
|
|
@ -8,6 +9,9 @@ interface FilterBarProps {
|
||||||
statusFilter: string
|
statusFilter: string
|
||||||
onStatusChange: (v: string) => void
|
onStatusChange: (v: string) => void
|
||||||
totalCount: number
|
totalCount: number
|
||||||
|
nlQuery: string
|
||||||
|
onNlQueryChange: (v: string) => void
|
||||||
|
nlSearchLoading: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUSES = ['', 'draft', 'indexed', 'needs_research', 'researched', 'complete']
|
const STATUSES = ['', 'draft', 'indexed', 'needs_research', 'researched', 'complete']
|
||||||
|
|
@ -20,61 +24,104 @@ const STATUS_LABELS: Record<string, string> = {
|
||||||
complete: 'Complete',
|
complete: 'Complete',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilterBar({ search, onSearchChange, statusFilter, onStatusChange, totalCount }: FilterBarProps) {
|
export function FilterBar({
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
|
statusFilter,
|
||||||
|
onStatusChange,
|
||||||
|
totalCount,
|
||||||
|
nlQuery,
|
||||||
|
onNlQueryChange,
|
||||||
|
nlSearchLoading,
|
||||||
|
}: FilterBarProps) {
|
||||||
const { viewMode, setViewMode } = useUIStore()
|
const { viewMode, setViewMode } = useUIStore()
|
||||||
|
|
||||||
|
// Local state for the NL input value — debounced before calling onNlQueryChange.
|
||||||
|
const [nlInputValue, setNlInputValue] = useState(nlQuery)
|
||||||
|
|
||||||
|
// Sync if parent resets nlQuery to empty (e.g. clear action).
|
||||||
|
useEffect(() => {
|
||||||
|
if (nlQuery === '') setNlInputValue('')
|
||||||
|
}, [nlQuery])
|
||||||
|
|
||||||
|
// 400ms debounce: propagate to parent only after user stops typing.
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
onNlQueryChange(nlInputValue)
|
||||||
|
}, 400)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [nlInputValue, onNlQueryChange])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-3 mb-6">
|
<div className="flex flex-col gap-3 mb-6">
|
||||||
{/* Search */}
|
{/* Row 1: text filter + status + view toggle */}
|
||||||
<div className="relative flex-1 min-w-48">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#a0a0a0]" />
|
{/* Local text search */}
|
||||||
<input
|
<div className="relative flex-1 min-w-48">
|
||||||
type="text"
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#a0a0a0]" />
|
||||||
placeholder="Search items…"
|
<input
|
||||||
value={search}
|
type="text"
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
placeholder="Search items…"
|
||||||
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"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Status filter */}
|
{/* Row 2: Natural language search */}
|
||||||
<select
|
<div className="relative w-full">
|
||||||
value={statusFilter}
|
<Sparkles className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-volt/60" />
|
||||||
onChange={(e) => onStatusChange(e.target.value)}
|
<input
|
||||||
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"
|
type="text"
|
||||||
>
|
placeholder="Ask anything: show me free 10GbE NICs…"
|
||||||
{STATUSES.map((s) => (
|
value={nlInputValue}
|
||||||
<option key={s} value={s} className="bg-near-black">
|
onChange={(e) => setNlInputValue(e.target.value)}
|
||||||
{STATUS_LABELS[s]}
|
className="w-full pl-9 pr-9 py-2 bg-[#0a0a0a] border border-volt/20 rounded-card text-sm text-white placeholder-[#585858] focus:outline-none focus:border-volt focus:ring-1 focus:ring-volt/30 transition-colors"
|
||||||
</option>
|
/>
|
||||||
))}
|
{nlSearchLoading && (
|
||||||
</select>
|
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-volt" />
|
||||||
|
)}
|
||||||
{/* 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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ async function fetchJSON<T>(url: string): Promise<T> {
|
||||||
export const fetchInventory = (): Promise<InventoryItem[]> =>
|
export const fetchInventory = (): Promise<InventoryItem[]> =>
|
||||||
fetchJSON<InventoryItem[]>(`${BASE}/inventory`)
|
fetchJSON<InventoryItem[]>(`${BASE}/inventory`)
|
||||||
|
|
||||||
|
export const fetchSearch = (q: string): Promise<InventoryItem[]> =>
|
||||||
|
fetchJSON<InventoryItem[]>(`${BASE}/search?q=${encodeURIComponent(q)}`)
|
||||||
|
|
||||||
export const fetchInventoryItem = (id: number): Promise<InventoryItem> =>
|
export const fetchInventoryItem = (id: number): Promise<InventoryItem> =>
|
||||||
fetchJSON<InventoryItem>(`${BASE}/inventory/${id}`)
|
fetchJSON<InventoryItem>(`${BASE}/inventory/${id}`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { AppShell } from '@/components/layout/AppShell'
|
import { AppShell } from '@/components/layout/AppShell'
|
||||||
import { FilterBar } from '@/components/inventory/FilterBar'
|
import { FilterBar } from '@/components/inventory/FilterBar'
|
||||||
import { ItemCard } from '@/components/inventory/ItemCard'
|
import { ItemCard } from '@/components/inventory/ItemCard'
|
||||||
import { ItemRow } from '@/components/inventory/ItemRow'
|
import { ItemRow } from '@/components/inventory/ItemRow'
|
||||||
import { useInventory } from '@/hooks/useInventory'
|
import { useInventory } from '@/hooks/useInventory'
|
||||||
import { useUIStore } from '@/store/ui'
|
import { useUIStore } from '@/store/ui'
|
||||||
|
import { fetchSearch } from '@/lib/api'
|
||||||
import { Loader2, AlertCircle } from 'lucide-react'
|
import { Loader2, AlertCircle } from 'lucide-react'
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
|
|
@ -12,7 +14,17 @@ export function DashboardPage() {
|
||||||
const { viewMode } = useUIStore()
|
const { viewMode } = useUIStore()
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [statusFilter, setStatusFilter] = useState('')
|
const [statusFilter, setStatusFilter] = useState('')
|
||||||
|
const [nlQuery, setNlQuery] = useState('')
|
||||||
|
|
||||||
|
// NL search via GET /api/search — only fires when nlQuery has > 2 chars.
|
||||||
|
const { data: searchResults, isLoading: searchLoading } = useQuery({
|
||||||
|
queryKey: ['search', nlQuery],
|
||||||
|
queryFn: () => fetchSearch(nlQuery),
|
||||||
|
enabled: nlQuery.trim().length > 2,
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Local filter (used when nlQuery is empty).
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!items) return []
|
if (!items) return []
|
||||||
return items.filter((item) => {
|
return items.filter((item) => {
|
||||||
|
|
@ -26,6 +38,10 @@ export function DashboardPage() {
|
||||||
})
|
})
|
||||||
}, [items, search, statusFilter])
|
}, [items, search, statusFilter])
|
||||||
|
|
||||||
|
// When nlQuery is active (> 2 chars), display NL results; otherwise local filter.
|
||||||
|
const displayItems = nlQuery.trim().length > 2 ? (searchResults ?? []) : filtered
|
||||||
|
const displayLoading = nlQuery.trim().length > 2 ? searchLoading : isLoading
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
{/* Page header */}
|
{/* Page header */}
|
||||||
|
|
@ -39,14 +55,17 @@ export function DashboardPage() {
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
statusFilter={statusFilter}
|
statusFilter={statusFilter}
|
||||||
onStatusChange={setStatusFilter}
|
onStatusChange={setStatusFilter}
|
||||||
totalCount={filtered.length}
|
totalCount={displayItems.length}
|
||||||
|
nlQuery={nlQuery}
|
||||||
|
onNlQueryChange={setNlQuery}
|
||||||
|
nlSearchLoading={searchLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Loading */}
|
{/* Loading */}
|
||||||
{isLoading && (
|
{displayLoading && (
|
||||||
<div className="flex items-center justify-center py-24 text-[#a0a0a0]">
|
<div className="flex items-center justify-center py-24 text-[#a0a0a0]">
|
||||||
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
||||||
Loading inventory…
|
{nlQuery.trim().length > 2 ? 'Searching…' : 'Loading inventory…'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -59,28 +78,32 @@ export function DashboardPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
{!isLoading && !error && filtered.length === 0 && (
|
{!displayLoading && !error && displayItems.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
<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="font-display font-black text-4xl text-volt mb-2">0</p>
|
||||||
<p className="text-[#a0a0a0] text-sm">
|
<p className="text-[#a0a0a0] text-sm">
|
||||||
{items && items.length > 0 ? 'No items match your filters' : 'No items cataloged yet — add your first item'}
|
{nlQuery.trim().length > 2
|
||||||
|
? 'No items match your search'
|
||||||
|
: items && items.length > 0
|
||||||
|
? 'No items match your filters'
|
||||||
|
: 'No items cataloged yet — add your first item'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Grid view */}
|
{/* Grid view */}
|
||||||
{!isLoading && !error && filtered.length > 0 && viewMode === 'grid' && (
|
{!displayLoading && !error && displayItems.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">
|
<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) => (
|
{displayItems.map((item) => (
|
||||||
<ItemCard key={item.id} item={item} />
|
<ItemCard key={item.id} item={item} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* List view */}
|
{/* List view */}
|
||||||
{!isLoading && !error && filtered.length > 0 && viewMode === 'list' && (
|
{!displayLoading && !error && displayItems.length > 0 && viewMode === 'list' && (
|
||||||
<div className="border border-charcoal/80 rounded-card overflow-hidden">
|
<div className="border border-charcoal/80 rounded-card overflow-hidden">
|
||||||
{filtered.map((item) => (
|
{displayItems.map((item) => (
|
||||||
<ItemRow key={item.id} item={item} />
|
<ItemRow key={item.id} item={item} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue