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:
@@ -19,7 +19,7 @@ export async function GET(req: NextRequest) {
|
||||
const sourceTabs = searchParams.getAll("sourceTab");
|
||||
const priorities = searchParams.getAll("priority");
|
||||
const tags = searchParams.getAll("tags");
|
||||
const searchTerm = searchParams.get("searchTerm") || "";
|
||||
const searchTerms = searchParams.getAll("searchTerm");
|
||||
|
||||
const where: Prisma.LeadWhereInput = {};
|
||||
|
||||
@@ -46,8 +46,8 @@ export async function GET(req: NextRequest) {
|
||||
if (capturedTo) where.capturedAt.lte = new Date(capturedTo);
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
where.sourceTerm = { contains: searchTerm };
|
||||
if (searchTerms.length > 0) {
|
||||
where.sourceTerm = { in: searchTerms };
|
||||
}
|
||||
|
||||
if (tags.length > 0) {
|
||||
|
||||
23
app/api/leads/search-terms/route.ts
Normal file
23
app/api/leads/search-terms/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const results = await prisma.lead.findMany({
|
||||
where: { sourceTerm: { not: null } },
|
||||
select: { sourceTerm: true },
|
||||
distinct: ["sourceTerm"],
|
||||
orderBy: { sourceTerm: "asc" },
|
||||
});
|
||||
|
||||
const terms = results
|
||||
.map(r => r.sourceTerm!)
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
|
||||
return NextResponse.json(terms);
|
||||
} catch (err) {
|
||||
console.error("GET /api/leads/search-terms error:", err);
|
||||
return NextResponse.json([], { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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