feat(CC-056): add Global Search page with debounced input and grouped results

This commit is contained in:
admin-valentin 2026-08-01 20:55:52 +00:00
parent 5329442543
commit 1dafb7bbe7

View 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>
);
}