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 sourceTabs = searchParams.getAll("sourceTab");
|
||||||
const priorities = searchParams.getAll("priority");
|
const priorities = searchParams.getAll("priority");
|
||||||
const tags = searchParams.getAll("tags");
|
const tags = searchParams.getAll("tags");
|
||||||
const searchTerm = searchParams.get("searchTerm") || "";
|
const searchTerms = searchParams.getAll("searchTerm");
|
||||||
|
|
||||||
const where: Prisma.LeadWhereInput = {};
|
const where: Prisma.LeadWhereInput = {};
|
||||||
|
|
||||||
@@ -46,8 +46,8 @@ export async function GET(req: NextRequest) {
|
|||||||
if (capturedTo) where.capturedAt.lte = new Date(capturedTo);
|
if (capturedTo) where.capturedAt.lte = new Date(capturedTo);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchTerm) {
|
if (searchTerms.length > 0) {
|
||||||
where.sourceTerm = { contains: searchTerm };
|
where.sourceTerm = { in: searchTerms };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tags.length > 0) {
|
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 [filterSource, setFilterSource] = useState<string[]>([]);
|
||||||
const [filterPriority, setFilterPriority] = useState<string[]>([]);
|
const [filterPriority, setFilterPriority] = useState<string[]>([]);
|
||||||
const [filterHasEmail, setFilterHasEmail] = useState("");
|
const [filterHasEmail, setFilterHasEmail] = useState("");
|
||||||
const [filterSearchTerm, setFilterSearchTerm] = useState("");
|
const [filterSearchTerms, setFilterSearchTerms] = useState<string[]>([]);
|
||||||
|
const [availableSearchTerms, setAvailableSearchTerms] = useState<string[]>([]);
|
||||||
|
|
||||||
// Table
|
// Table
|
||||||
const [leads, setLeads] = useState<Lead[]>([]);
|
const [leads, setLeads] = useState<Lead[]>([]);
|
||||||
@@ -429,7 +430,7 @@ export default function LeadVaultPage() {
|
|||||||
filterSource.forEach(s => params.append("sourceTab", s));
|
filterSource.forEach(s => params.append("sourceTab", s));
|
||||||
filterPriority.forEach(p => params.append("priority", p));
|
filterPriority.forEach(p => params.append("priority", p));
|
||||||
if (filterHasEmail) params.set("hasEmail", filterHasEmail);
|
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}`);
|
const res = await fetch(`/api/leads?${params}`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -441,10 +442,16 @@ export default function LeadVaultPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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(() => { loadStats(); const t = setInterval(loadStats, 30000); return () => clearInterval(t); }, [loadStats]);
|
||||||
useEffect(() => { loadLeads(); }, [loadLeads]);
|
useEffect(() => { loadLeads(); }, [loadLeads]);
|
||||||
|
useEffect(() => { loadSearchTerms(); }, [loadSearchTerms]);
|
||||||
|
|
||||||
function toggleSort(field: string) {
|
function toggleSort(field: string) {
|
||||||
if (sortBy === field) setSortDir(d => d === "asc" ? "desc" : "asc");
|
if (sortBy === field) setSortDir(d => d === "asc" ? "desc" : "asc");
|
||||||
@@ -459,7 +466,7 @@ export default function LeadVaultPage() {
|
|||||||
|
|
||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
setSearch(""); setFilterStatus([]); setFilterSource([]);
|
setSearch(""); setFilterStatus([]); setFilterSource([]);
|
||||||
setFilterPriority([]); setFilterHasEmail(""); setFilterSearchTerm("");
|
setFilterPriority([]); setFilterHasEmail(""); setFilterSearchTerms([]);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,7 +510,7 @@ export default function LeadVaultPage() {
|
|||||||
const data = await res.json() as { added?: number; updated?: number; skipped?: number; error?: string };
|
const data = await res.json() as { added?: number; updated?: number; skipped?: number; error?: string };
|
||||||
if (!res.ok) throw new Error(data.error || "Fehler");
|
if (!res.ok) throw new Error(data.error || "Fehler");
|
||||||
toast.success(`${data.added} Leads hinzugefügt, ${data.skipped} übersprungen`);
|
toast.success(`${data.added} Leads hinzugefügt, ${data.skipped} übersprungen`);
|
||||||
loadLeads(); loadStats();
|
loadLeads(); loadStats(); loadSearchTerms();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "Fehler");
|
toast.error(err instanceof Error ? err.message : "Fehler");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -523,7 +530,7 @@ export default function LeadVaultPage() {
|
|||||||
const d = await res.json() as { updated: number };
|
const d = await res.json() as { updated: number };
|
||||||
toast.success(`${d.updated} Leads aktualisiert`);
|
toast.success(`${d.updated} Leads aktualisiert`);
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
loadLeads(); loadStats();
|
loadLeads(); loadStats(); loadSearchTerms();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -700,7 +707,7 @@ export default function LeadVaultPage() {
|
|||||||
|
|
||||||
{/* Clear + count */}
|
{/* Clear + count */}
|
||||||
<div className="flex items-center gap-2 ml-auto">
|
<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">
|
<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
|
<X className="w-3 h-3" /> Filter zurücksetzen
|
||||||
</button>
|
</button>
|
||||||
@@ -743,6 +750,26 @@ export default function LeadVaultPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -803,6 +830,7 @@ export default function LeadVaultPage() {
|
|||||||
["contactName", "Kontakt"],
|
["contactName", "Kontakt"],
|
||||||
["phone", "Telefon"],
|
["phone", "Telefon"],
|
||||||
["email", "E-Mail"],
|
["email", "E-Mail"],
|
||||||
|
["sourceTerm", "Suchbegriff"],
|
||||||
["sourceTab", "Quelle"],
|
["sourceTab", "Quelle"],
|
||||||
["capturedAt", "Erfasst"],
|
["capturedAt", "Erfasst"],
|
||||||
].map(([field, label]) => (
|
].map(([field, label]) => (
|
||||||
@@ -912,6 +940,23 @@ export default function LeadVaultPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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">
|
<td className="px-3 py-2.5">
|
||||||
<span className="text-xs text-gray-400">{src?.icon} {src?.label || lead.sourceTab}</span>
|
<span className="text-xs text-gray-400">{src?.icon} {src?.label || lead.sourceTab}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user