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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user