feat(CC-056): add Global Search page with debounced input and grouped results
This commit is contained in:
parent
5329442543
commit
1dafb7bbe7
1 changed files with 183 additions and 0 deletions
183
src/app/dashboard/search/page.tsx
Normal file
183
src/app/dashboard/search/page.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { apiFetch } from '../../../lib/api';
|
||||
import { useSession } from '../../../components/session-provider';
|
||||
|
||||
interface SearchItem {
|
||||
type: string;
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
route: string;
|
||||
}
|
||||
|
||||
interface SearchGroup {
|
||||
type: string;
|
||||
label: string;
|
||||
items: SearchItem[];
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
q: string;
|
||||
totalCount: number;
|
||||
groups: SearchGroup[];
|
||||
}
|
||||
|
||||
const TYPE_META: Record<string, { emoji: string; color: string }> = {
|
||||
organization: { emoji: '🏢', color: 'bg-blue-500/10 text-blue-700 dark:text-blue-300' },
|
||||
task: { emoji: '✅', color: 'bg-amber-500/10 text-amber-700 dark:text-amber-300' },
|
||||
goal: { emoji: '🎯', color: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' },
|
||||
decision: { emoji: '⚖️', color: 'bg-purple-500/10 text-purple-700 dark:text-purple-300' },
|
||||
research: { emoji: '📋', color: 'bg-sky-500/10 text-sky-700 dark:text-sky-300' },
|
||||
segment: { emoji: '📊', color: 'bg-rose-500/10 text-rose-700 dark:text-rose-300' },
|
||||
};
|
||||
|
||||
export default function SearchPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [debouncedQ, setDebouncedQ] = useState('');
|
||||
const [results, setResults] = useState<SearchResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedQ(query), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [query]);
|
||||
|
||||
const doSearch = useCallback(async (q: string) => {
|
||||
if (!q || q.length < 2 || !tenantId) {
|
||||
setResults(null);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await apiFetch<SearchResponse>(`/v1/search?q=${encodeURIComponent(q)}`, { tenantId });
|
||||
setResults(data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Eroare la căutare');
|
||||
setResults(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
useEffect(() => {
|
||||
doSearch(debouncedQ);
|
||||
}, [debouncedQ, doSearch]);
|
||||
|
||||
const totalCount = results?.totalCount ?? 0;
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-6 p-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Căutare globală</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">
|
||||
Caută în organizații, taskuri, obiective, decizii, research și segmente
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-4 flex items-center">
|
||||
<span className="text-ink-faint">🔍</span>
|
||||
</div>
|
||||
<input
|
||||
autoFocus
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Caută orice — organizație, task, decizie…"
|
||||
className="w-full rounded-xl border bg-background pl-10 pr-4 py-3 text-sm
|
||||
shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
{loading && (
|
||||
<div className="absolute inset-y-0 right-4 flex items-center">
|
||||
<span className="text-xs text-ink-faint animate-pulse">Se caută…</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty query */}
|
||||
{!query && (
|
||||
<div className="text-center py-12 space-y-2">
|
||||
<p className="text-3xl">🔍</p>
|
||||
<p className="text-sm text-ink-faint">Introdu cel puțin 2 caractere pentru a căuta</p>
|
||||
<div className="flex flex-wrap gap-2 justify-center mt-4">
|
||||
{['GmbH', 'angajat', 'contract', 'decizie', 'buget'].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setQuery(s)}
|
||||
className="rounded-full border px-3 py-1 text-xs text-ink-faint hover:text-ink hover:border-ink/40 transition-colors"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No results */}
|
||||
{query.length >= 2 && !loading && results && totalCount === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-sm text-ink-faint">
|
||||
Niciun rezultat pentru <strong>"{query}"</strong>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{results && totalCount > 0 && (
|
||||
<div className="space-y-6">
|
||||
<p className="text-xs text-ink-faint">
|
||||
{totalCount} rezultat{totalCount !== 1 ? 'e' : ''} pentru <strong>"{results.q}"</strong>
|
||||
</p>
|
||||
|
||||
{results.groups.map((group) => (
|
||||
<div key={group.type} className="space-y-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-ink-faint">
|
||||
{group.label}
|
||||
</h2>
|
||||
<div className="divide-y divide-border/50 rounded-xl border bg-card overflow-hidden">
|
||||
{group.items.map((item) => {
|
||||
const meta = TYPE_META[item.type] ?? { emoji: '•', color: 'bg-muted/50 text-ink-faint' };
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.route}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<span className={`shrink-0 rounded-md px-1.5 py-0.5 text-xs font-medium ${meta.color}`}>
|
||||
{meta.emoji}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-ink truncate">{item.title}</p>
|
||||
{item.subtitle && (
|
||||
<p className="text-xs text-ink-faint truncate">{item.subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-ink-faint">→</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue