Main: revise specs to 1.5.0 (completed)
This commit is contained in:
382
specs/06-tasks/TASK-FE-008-search-ui.md
Normal file
382
specs/06-tasks/TASK-FE-008-search-ui.md
Normal file
@@ -0,0 +1,382 @@
|
||||
# TASK-FE-008: Search & Global Filters UI
|
||||
|
||||
**ID:** TASK-FE-008
|
||||
**Title:** Global Search, Advanced Filters & Results UI
|
||||
**Category:** Supporting Features
|
||||
**Priority:** P2 (Medium)
|
||||
**Effort:** 3-4 days
|
||||
**Dependencies:** TASK-FE-003, TASK-BE-010
|
||||
**Assigned To:** Frontend Developer
|
||||
|
||||
---
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
Implement global search functionality with advanced filters, faceted search, and unified results display across all document types.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Objectives
|
||||
|
||||
1. Create global search bar in header
|
||||
2. Build advanced search page with filters
|
||||
3. Implement faceted search (by type, status, date)
|
||||
4. Create unified results display
|
||||
5. Add search suggestions/autocomplete
|
||||
6. Implement search history
|
||||
|
||||
---
|
||||
|
||||
## ✅ Acceptance Criteria
|
||||
|
||||
- [ ] Global search accessible from header
|
||||
- [ ] Advanced filters work (type, status, date range, organization)
|
||||
- [ ] Results show across all document types
|
||||
- [ ] Search suggestions appear as user types
|
||||
- [ ] Search history saved locally
|
||||
- [ ] Results paginated with highlighting
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Implementation Steps
|
||||
|
||||
### Step 1: Global Search Component in Header
|
||||
|
||||
```typescript
|
||||
// File: src/components/layout/global-search.tsx
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { useDebounce } from '@/hooks/use-debounce';
|
||||
import { searchApi } from '@/lib/api/search';
|
||||
|
||||
export function GlobalSearch() {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [suggestions, setSuggestions] = useState([]);
|
||||
|
||||
const debouncedQuery = useDebounce(query, 300);
|
||||
|
||||
// Fetch suggestions
|
||||
useEffect(() => {
|
||||
if (debouncedQuery.length > 2) {
|
||||
searchApi.suggest(debouncedQuery).then(setSuggestions);
|
||||
}
|
||||
}, [debouncedQuery]);
|
||||
|
||||
const handleSearch = () => {
|
||||
if (query.trim()) {
|
||||
router.push(`/search?q=${encodeURIComponent(query)}`);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="relative w-96">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Search documents..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-96 p-0" align="start">
|
||||
<Command>
|
||||
<CommandList>
|
||||
{suggestions.length === 0 ? (
|
||||
<CommandEmpty>No results found</CommandEmpty>
|
||||
) : (
|
||||
<CommandGroup heading="Suggestions">
|
||||
{suggestions.map((item: any) => (
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
onSelect={() => {
|
||||
setQuery(item.title);
|
||||
router.push(`/${item.type}s/${item.id}`);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500">{item.type}</span>
|
||||
<span>{item.title}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Advanced Search Page
|
||||
|
||||
```typescript
|
||||
// File: src/app/(dashboard)/search/page.tsx
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { SearchFilters } from '@/components/search/filters';
|
||||
import { SearchResults } from '@/components/search/results';
|
||||
import { searchApi } from '@/lib/api/search';
|
||||
|
||||
export default function SearchPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const query = searchParams.get('q') || '';
|
||||
const [results, setResults] = useState([]);
|
||||
const [filters, setFilters] = useState({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (query) {
|
||||
setLoading(true);
|
||||
searchApi
|
||||
.search({ query, ...filters })
|
||||
.then(setResults)
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [query, filters]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Search Results</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Found {results.length} results for "{query}"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-6">
|
||||
<div className="col-span-1">
|
||||
<SearchFilters onFilterChange={setFilters} />
|
||||
</div>
|
||||
|
||||
<div className="col-span-3">
|
||||
<SearchResults results={results} query={query} loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Search Filters Component
|
||||
|
||||
```typescript
|
||||
// File: src/components/search/filters.tsx
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
|
||||
export function SearchFilters({
|
||||
onFilterChange,
|
||||
}: {
|
||||
onFilterChange: (filters: any) => void;
|
||||
}) {
|
||||
const [filters, setFilters] = useState({
|
||||
types: [],
|
||||
statuses: [],
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
});
|
||||
|
||||
const handleFilterChange = (key: string, value: any) => {
|
||||
const newFilters = { ...filters, [key]: value };
|
||||
setFilters(newFilters);
|
||||
onFilterChange(newFilters);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-4 space-y-6">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Document Type</h3>
|
||||
<div className="space-y-2">
|
||||
{['Correspondence', 'RFA', 'Drawing', 'Transmittal'].map((type) => (
|
||||
<label key={type} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={filters.types.includes(type)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newTypes = checked
|
||||
? [...filters.types, type]
|
||||
: filters.types.filter((t) => t !== type);
|
||||
handleFilterChange('types', newTypes);
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm">{type}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Status</h3>
|
||||
<div className="space-y-2">
|
||||
{['Draft', 'Pending', 'Approved', 'Rejected'].map((status) => (
|
||||
<label key={status} className="flex items-center gap-2">
|
||||
<Checkbox />
|
||||
<span className="text-sm">{status}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Date Range</h3>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs">From</Label>
|
||||
<Calendar mode="single" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
setFilters({ types: [], statuses: [], dateFrom: null, dateTo: null });
|
||||
onFilterChange({});
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Search Results Component
|
||||
|
||||
```typescript
|
||||
// File: src/components/search/results.tsx
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import Link from 'next/link';
|
||||
import { FileText, Clipboard, Image } from 'lucide-react';
|
||||
|
||||
export function SearchResults({ results, query, loading }: any) {
|
||||
if (loading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<Card className="p-12 text-center text-gray-500">
|
||||
No results found for "{query}"
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'correspondence':
|
||||
return FileText;
|
||||
case 'rfa':
|
||||
return Clipboard;
|
||||
case 'drawing':
|
||||
return Image;
|
||||
default:
|
||||
return FileText;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{results.map((result: any) => {
|
||||
const Icon = getIcon(result.type);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={result.id}
|
||||
className="p-6 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<Link href={`/${result.type}s/${result.id}`}>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<Icon className="h-6 w-6 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-lg font-semibold hover:text-primary">
|
||||
{result.title}
|
||||
</h3>
|
||||
<Badge>{result.type}</Badge>
|
||||
<Badge variant="outline">{result.status}</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
{result.highlight || result.description}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-4 text-xs text-gray-500">
|
||||
<span>{result.documentNumber}</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{new Date(result.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Deliverables
|
||||
|
||||
- [ ] Global search component in header
|
||||
- [ ] Advanced search page
|
||||
- [ ] Filters panel (type, status, date)
|
||||
- [ ] Results display with highlighting
|
||||
- [ ] Search suggestions/autocomplete
|
||||
- [ ] Mobile responsive design
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Documents
|
||||
|
||||
- [TASK-BE-010: Search & Elasticsearch](./TASK-BE-010-search-elasticsearch.md)
|
||||
|
||||
---
|
||||
|
||||
**Created:** 2025-12-01
|
||||
**Status:** Ready
|
||||
Reference in New Issue
Block a user