feat: Suchbegriff-Spalte + Filter-Chips im LeadVault

- GET /api/leads/search-terms: distinct Suchbegriffe aus DB
- Filter-Bar: Suchbegriff-Chips (amber), klickbar zum Filtern
- Tabelle: Suchbegriff-Spalte mit Chip — Klick filtert direkt
- Mehrere Suchbegriffe gleichzeitig filterbar (OR-Logik)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Timo Uttenweiler
2026-03-20 17:53:40 +01:00
parent fa177a982f
commit 82c4244233
3 changed files with 78 additions and 10 deletions

View File

@@ -387,7 +387,8 @@ export default function LeadVaultPage() {
const [filterSource, setFilterSource] = useState<string[]>([]);
const [filterPriority, setFilterPriority] = useState<string[]>([]);
const [filterHasEmail, setFilterHasEmail] = useState("");
const [filterSearchTerm, setFilterSearchTerm] = useState("");
const [filterSearchTerms, setFilterSearchTerms] = useState<string[]>([]);
const [availableSearchTerms, setAvailableSearchTerms] = useState<string[]>([]);
// Table
const [leads, setLeads] = useState<Lead[]>([]);
@@ -429,7 +430,7 @@ export default function LeadVaultPage() {
filterSource.forEach(s => params.append("sourceTab", s));
filterPriority.forEach(p => params.append("priority", p));
if (filterHasEmail) params.set("hasEmail", filterHasEmail);
if (filterSearchTerm) params.set("searchTerm", filterSearchTerm);
filterSearchTerms.forEach(t => params.append("searchTerm", t));
const res = await fetch(`/api/leads?${params}`);
if (res.ok) {
@@ -441,10 +442,16 @@ export default function LeadVaultPage() {
} finally {
setLoading(false);
}
}, [page, perPage, debouncedSearch, sortBy, sortDir, filterStatus, filterSource, filterPriority, filterHasEmail, filterSearchTerm]);
}, [page, perPage, debouncedSearch, sortBy, sortDir, filterStatus, filterSource, filterPriority, filterHasEmail, filterSearchTerms]);
const loadSearchTerms = useCallback(async () => {
const res = await fetch("/api/leads/search-terms");
if (res.ok) setAvailableSearchTerms(await res.json() as string[]);
}, []);
useEffect(() => { loadStats(); const t = setInterval(loadStats, 30000); return () => clearInterval(t); }, [loadStats]);
useEffect(() => { loadLeads(); }, [loadLeads]);
useEffect(() => { loadSearchTerms(); }, [loadSearchTerms]);
function toggleSort(field: string) {
if (sortBy === field) setSortDir(d => d === "asc" ? "desc" : "asc");
@@ -459,7 +466,7 @@ export default function LeadVaultPage() {
function clearFilters() {
setSearch(""); setFilterStatus([]); setFilterSource([]);
setFilterPriority([]); setFilterHasEmail(""); setFilterSearchTerm("");
setFilterPriority([]); setFilterHasEmail(""); setFilterSearchTerms([]);
setPage(1);
}
@@ -503,7 +510,7 @@ export default function LeadVaultPage() {
const data = await res.json() as { added?: number; updated?: number; skipped?: number; error?: string };
if (!res.ok) throw new Error(data.error || "Fehler");
toast.success(`${data.added} Leads hinzugefügt, ${data.skipped} übersprungen`);
loadLeads(); loadStats();
loadLeads(); loadStats(); loadSearchTerms();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Fehler");
} finally {
@@ -523,7 +530,7 @@ export default function LeadVaultPage() {
const d = await res.json() as { updated: number };
toast.success(`${d.updated} Leads aktualisiert`);
setSelected(new Set());
loadLeads(); loadStats();
loadLeads(); loadStats(); loadSearchTerms();
}
}
@@ -700,7 +707,7 @@ export default function LeadVaultPage() {
{/* Clear + count */}
<div className="flex items-center gap-2 ml-auto">
{(search || filterStatus.length || filterSource.length || filterPriority.length || filterHasEmail || filterSearchTerm) && (
{(search || filterStatus.length || filterSource.length || filterPriority.length || filterHasEmail || filterSearchTerms.length) && (
<button onClick={clearFilters} className="text-xs text-gray-500 hover:text-white flex items-center gap-1">
<X className="w-3 h-3" /> Filter zurücksetzen
</button>
@@ -743,6 +750,26 @@ export default function LeadVaultPage() {
</button>
))}
</div>
{/* Search term filter chips */}
{availableSearchTerms.length > 0 && (
<>
<div className="w-px bg-[#2e2e3e] mx-1" />
<div className="flex gap-1 flex-wrap items-center">
<span className="text-[10px] text-gray-600 uppercase tracking-wider mr-1">Suchbegriff</span>
{availableSearchTerms.map(term => (
<button key={term} onClick={() => toggleFilter(filterSearchTerms, setFilterSearchTerms, term)}
className={`px-2.5 py-1 rounded-full text-xs border transition-all ${
filterSearchTerms.includes(term)
? "bg-amber-500/20 text-amber-300 border-amber-500/30"
: "border-[#2e2e3e] text-gray-600 hover:text-gray-400"
}`}>
🔎 {term}
</button>
))}
</div>
</>
)}
</div>
</Card>
@@ -803,6 +830,7 @@ export default function LeadVaultPage() {
["contactName", "Kontakt"],
["phone", "Telefon"],
["email", "E-Mail"],
["sourceTerm", "Suchbegriff"],
["sourceTab", "Quelle"],
["capturedAt", "Erfasst"],
].map(([field, label]) => (
@@ -912,6 +940,23 @@ export default function LeadVaultPage() {
</div>
)}
</td>
<td className="px-3 py-2.5 max-w-[160px]">
{lead.sourceTerm ? (
<button
onClick={e => { e.stopPropagation(); toggleFilter(filterSearchTerms, setFilterSearchTerms, lead.sourceTerm!); setPage(1); }}
className={`text-xs px-2 py-0.5 rounded-full border transition-all truncate max-w-full block ${
filterSearchTerms.includes(lead.sourceTerm)
? "bg-amber-500/20 text-amber-300 border-amber-500/30"
: "bg-[#1a1a28] text-gray-400 border-[#2e2e3e] hover:border-amber-500/30 hover:text-amber-300"
}`}
title={lead.sourceTerm}
>
🔎 {lead.sourceTerm}
</button>
) : (
<span className="text-xs text-gray-600"></span>
)}
</td>
<td className="px-3 py-2.5">
<span className="text-xs text-gray-400">{src?.icon} {src?.label || lead.sourceTab}</span>
</td>