feat: LeadVault - zentrale Lead-Datenbank mit CRM-Funktionen

- Prisma-Schema: Lead + LeadEvent Modelle (Migration 20260320)
- lib/services/leadVault.ts: sinkLeadsToVault mit Deduplizierung
- Auto-Sync: alle 4 Pipelines schreiben Leads in LeadVault
- GET /api/leads: Filter, Sortierung, Pagination (Server-side)
- PATCH/DELETE /api/leads/[id]: Status, Priorität, Tags, Notizen
- POST /api/leads/bulk: Bulk-Aktionen für mehrere Leads
- GET /api/leads/stats: Statistiken + 7-Tage-Sparkline
- POST /api/leads/quick-serp: SERP-Capture ohne Enrichment
- GET /api/leads/export: CSV-Export mit allen Feldern
- app/leadvault/page.tsx: vollständige UI mit Stats, Quick SERP,
  Filter-Leiste, sortierbare Tabelle, Bulk-Aktionen, Side Panel
- Sidebar: LeadVault-Eintrag mit Live-Badge (neue Leads)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Timo Uttenweiler
2026-03-20 17:33:12 +01:00
parent 6711633a5d
commit 042fbeb672
16 changed files with 1800 additions and 5 deletions

993
app/leadvault/page.tsx Normal file
View File

@@ -0,0 +1,993 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import {
Database, Search, X, ChevronDown, ChevronUp, Trash2, ExternalLink,
Mail, Star, Tag, FileText, ArrowUpDown, ArrowUp, ArrowDown,
CheckSquare, Square, Download, Plus, RefreshCw, Phone
} from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
// ─── Types ────────────────────────────────────────────────────────────────────
interface Lead {
id: string;
domain: string | null;
companyName: string | null;
contactName: string | null;
contactTitle: string | null;
email: string | null;
linkedinUrl: string | null;
phone: string | null;
sourceTab: string;
sourceTerm: string | null;
sourceJobId: string | null;
serpTitle: string | null;
serpSnippet: string | null;
serpRank: number | null;
serpUrl: string | null;
emailConfidence: number | null;
status: string;
priority: string;
notes: string | null;
tags: string | null;
country: string | null;
headcount: string | null;
industry: string | null;
capturedAt: string;
contactedAt: string | null;
events?: Array<{ id: string; event: string; at: string }>;
}
interface Stats {
total: number;
new: number;
contacted: number;
withEmail: number;
dailyCounts: Array<{ date: string; count: number }>;
}
// ─── Constants ─────────────────────────────────────────────────────────────────
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
new: { label: "Neu", color: "text-blue-300", bg: "bg-blue-500/20 border-blue-500/30" },
in_progress: { label: "In Bearbeitung",color: "text-purple-300", bg: "bg-purple-500/20 border-purple-500/30" },
contacted: { label: "Kontaktiert", color: "text-teal-300", bg: "bg-teal-500/20 border-teal-500/30" },
qualified: { label: "Qualifiziert", color: "text-green-300", bg: "bg-green-500/20 border-green-500/30" },
not_relevant: { label: "Nicht relevant",color: "text-gray-400", bg: "bg-gray-500/20 border-gray-500/30" },
converted: { label: "Konvertiert", color: "text-amber-300", bg: "bg-amber-500/20 border-amber-500/30" },
};
const SOURCE_CONFIG: Record<string, { label: string; icon: string }> = {
airscale: { label: "AirScale", icon: "🏢" },
linkedin: { label: "LinkedIn", icon: "💼" },
serp: { label: "SERP", icon: "🔍" },
"quick-serp": { label: "Quick SERP", icon: "⚡" },
maps: { label: "Maps", icon: "📍" },
};
const PRIORITY_CONFIG: Record<string, { label: string; icon: string; color: string }> = {
high: { label: "Hoch", icon: "↑", color: "text-red-400" },
normal: { label: "Normal", icon: "→", color: "text-gray-400" },
low: { label: "Niedrig",icon: "↓", color: "text-blue-400" },
};
const TAG_PRESETS = [
"solar", "b2b", "deutschland", "founder", "kmu", "warmkontakt",
"kaltakquise", "follow-up", "interessiert", "angebot-gesendet",
];
function tagColor(tag: string): string {
let hash = 0;
for (let i = 0; i < tag.length; i++) hash = tag.charCodeAt(i) + ((hash << 5) - hash);
const colors = [
"bg-blue-500/20 text-blue-300 border-blue-500/30",
"bg-purple-500/20 text-purple-300 border-purple-500/30",
"bg-green-500/20 text-green-300 border-green-500/30",
"bg-amber-500/20 text-amber-300 border-amber-500/30",
"bg-pink-500/20 text-pink-300 border-pink-500/30",
"bg-teal-500/20 text-teal-300 border-teal-500/30",
"bg-orange-500/20 text-orange-300 border-orange-500/30",
];
return colors[Math.abs(hash) % colors.length];
}
function relativeTime(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "gerade eben";
if (mins < 60) return `vor ${mins} Min.`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `vor ${hrs} Std.`;
const days = Math.floor(hrs / 24);
if (days < 30) return `vor ${days} Tagen`;
return new Date(dateStr).toLocaleDateString("de-DE");
}
// ─── Sparkline ────────────────────────────────────────────────────────────────
function Sparkline({ data, color }: { data: number[]; color: string }) {
if (!data.length) return null;
const max = Math.max(...data, 1);
const w = 60, h = 24;
const points = data.map((v, i) => `${(i / (data.length - 1)) * w},${h - (v / max) * h}`).join(" ");
return (
<svg width={w} height={h} className="opacity-70">
<polyline points={points} fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
// ─── Status Badge ─────────────────────────────────────────────────────────────
function StatusBadge({ status, onChange }: { status: string; onChange: (s: string) => void }) {
const [open, setOpen] = useState(false);
const cfg = STATUS_CONFIG[status] || STATUS_CONFIG.new;
return (
<div className="relative">
<button
onClick={() => setOpen(!open)}
className={`px-2 py-0.5 rounded-full text-xs font-medium border ${cfg.bg} ${cfg.color} whitespace-nowrap`}
>
{cfg.label}
</button>
{open && (
<div className="absolute z-50 top-7 left-0 bg-[#1a1a28] border border-[#2e2e3e] rounded-lg shadow-xl p-1 min-w-[160px]">
{Object.entries(STATUS_CONFIG).map(([key, c]) => (
<button
key={key}
onClick={() => { onChange(key); setOpen(false); }}
className={`w-full text-left px-3 py-1.5 rounded text-xs hover:bg-[#2e2e3e] ${c.color} ${status === key ? "font-bold" : ""}`}
>
{c.label}
</button>
))}
</div>
)}
</div>
);
}
// ─── Side Panel ───────────────────────────────────────────────────────────────
function SidePanel({ lead, onClose, onUpdate }: {
lead: Lead;
onClose: () => void;
onUpdate: (updated: Partial<Lead>) => void;
}) {
const [notes, setNotes] = useState(lead.notes || "");
const [tagInput, setTagInput] = useState("");
const [fullLead, setFullLead] = useState<Lead>(lead);
const notesTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
// Load full lead with events
fetch(`/api/leads/${lead.id}`).then(r => r.json()).then(setFullLead).catch(() => {});
}, [lead.id]);
const tags: string[] = JSON.parse(fullLead.tags || "[]");
async function patch(data: Partial<Lead>) {
const res = await fetch(`/api/leads/${lead.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (res.ok) {
const updated = await res.json() as Lead;
setFullLead(prev => ({ ...prev, ...updated }));
onUpdate(updated);
}
}
function handleNotesChange(v: string) {
setNotes(v);
if (notesTimer.current) clearTimeout(notesTimer.current);
notesTimer.current = setTimeout(() => patch({ notes: v }), 500);
}
function addTag(tag: string) {
const trimmed = tag.trim().toLowerCase();
if (!trimmed || tags.includes(trimmed)) return;
const newTags = [...tags, trimmed];
patch({ tags: JSON.stringify(newTags) });
setTagInput("");
}
function removeTag(tag: string) {
patch({ tags: JSON.stringify(tags.filter(t => t !== tag)) });
}
const conf = fullLead.emailConfidence;
const confColor = conf == null ? "text-gray-500" : conf >= 0.8 ? "text-green-400" : conf >= 0.5 ? "text-yellow-400" : "text-red-400";
return (
<div className="fixed inset-0 z-40 flex justify-end" onClick={onClose}>
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" />
<div
className="relative z-50 w-full max-w-md h-full bg-[#0e0e1a] border-l border-[#1e1e2e] overflow-y-auto flex flex-col"
style={{ animation: "slideIn 200ms ease-out" }}
onClick={e => e.stopPropagation()}
>
<style>{`@keyframes slideIn { from { transform: translateX(100%) } to { transform: translateX(0) } }`}</style>
{/* Header */}
<div className="sticky top-0 bg-[#0e0e1a] border-b border-[#1e1e2e] p-4 flex items-start gap-3">
<div className="flex-1 min-w-0">
<h2 className="text-base font-bold text-white truncate">{fullLead.companyName || fullLead.domain || "Unbekannt"}</h2>
<p className="text-xs text-gray-400">{fullLead.domain}</p>
</div>
<button onClick={onClose} className="text-gray-500 hover:text-white p-1 rounded">
<X className="w-4 h-4" />
</button>
</div>
<div className="p-4 space-y-4 flex-1">
{/* Status + Priority */}
<div className="flex items-center gap-3">
<StatusBadge status={fullLead.status} onChange={s => patch({ status: s })} />
<div className="flex gap-1">
{Object.entries(PRIORITY_CONFIG).map(([key, p]) => (
<button
key={key}
onClick={() => patch({ priority: key })}
className={`px-2 py-0.5 rounded text-xs border transition-all ${
fullLead.priority === key
? "bg-[#2e2e3e] border-[#4e4e6e] " + p.color
: "border-[#1e1e2e] text-gray-600 hover:border-[#2e2e3e]"
}`}
>
{p.icon} {p.label}
</button>
))}
</div>
</div>
{/* Contact Info */}
<div className="bg-[#111118] rounded-lg border border-[#1e1e2e] p-3 space-y-2">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Kontakt</h3>
{fullLead.contactName && <p className="text-sm text-white">{fullLead.contactName}{fullLead.contactTitle && <span className="text-gray-400 ml-1">· {fullLead.contactTitle}</span>}</p>}
{fullLead.email && (
<div className="flex items-center gap-2">
<Mail className="w-3.5 h-3.5 text-gray-500" />
<span className={`text-sm font-mono ${confColor}`}>{fullLead.email}</span>
<button onClick={() => { navigator.clipboard.writeText(fullLead.email!); toast.success("Kopiert"); }} className="text-gray-600 hover:text-white">
<FileText className="w-3 h-3" />
</button>
{conf != null && <span className={`text-xs ${confColor}`}>{Math.round(conf * 100)}%</span>}
</div>
)}
{fullLead.phone && (
<div className="flex items-center gap-2">
<Phone className="w-3.5 h-3.5 text-gray-500" />
<span className="text-sm text-gray-300">{fullLead.phone}</span>
</div>
)}
{fullLead.linkedinUrl && (
<a href={fullLead.linkedinUrl} target="_blank" rel="noreferrer" className="flex items-center gap-1 text-xs text-blue-400 hover:underline">
<ExternalLink className="w-3 h-3" /> LinkedIn
</a>
)}
</div>
{/* Source Info */}
<div className="bg-[#111118] rounded-lg border border-[#1e1e2e] p-3 space-y-1.5">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Quelle</h3>
<p className="text-sm text-gray-300">
{SOURCE_CONFIG[fullLead.sourceTab]?.icon} {SOURCE_CONFIG[fullLead.sourceTab]?.label || fullLead.sourceTab}
</p>
{fullLead.sourceTerm && <p className="text-xs text-gray-500">Suche: <span className="text-gray-300">{fullLead.sourceTerm}</span></p>}
<p className="text-xs text-gray-500">Erfasst: {new Date(fullLead.capturedAt).toLocaleString("de-DE")}</p>
{fullLead.serpRank && <p className="text-xs text-gray-500">SERP Rang: #{fullLead.serpRank}</p>}
{fullLead.serpUrl && <a href={fullLead.serpUrl} target="_blank" rel="noreferrer" className="text-xs text-blue-400 hover:underline flex items-center gap-1"><ExternalLink className="w-3 h-3" />URL öffnen</a>}
</div>
{/* Tags */}
<div className="bg-[#111118] rounded-lg border border-[#1e1e2e] p-3 space-y-2">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Tags</h3>
<div className="flex flex-wrap gap-1.5">
{tags.map(tag => (
<span key={tag} className={`flex items-center gap-1 px-2 py-0.5 rounded-full text-xs border ${tagColor(tag)}`}>
{tag}
<button onClick={() => removeTag(tag)} className="hover:text-white"><X className="w-2.5 h-2.5" /></button>
</span>
))}
</div>
<div className="flex flex-wrap gap-1">
{TAG_PRESETS.filter(t => !tags.includes(t)).slice(0, 5).map(t => (
<button key={t} onClick={() => addTag(t)} className="text-[10px] px-1.5 py-0.5 rounded border border-[#2e2e3e] text-gray-600 hover:text-gray-300 hover:border-[#4e4e6e]">
+ {t}
</button>
))}
</div>
<div className="flex gap-1.5">
<input
value={tagInput}
onChange={e => setTagInput(e.target.value)}
onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); addTag(tagInput); } }}
placeholder="Tag hinzufügen..."
className="flex-1 bg-[#0d0d18] border border-[#2e2e3e] rounded px-2 py-1 text-xs text-white outline-none focus:border-purple-500"
/>
<button onClick={() => addTag(tagInput)} className="text-xs px-2 py-1 rounded bg-purple-500/20 text-purple-300 border border-purple-500/30">+</button>
</div>
</div>
{/* Notes */}
<div className="bg-[#111118] rounded-lg border border-[#1e1e2e] p-3 space-y-2">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Notizen</h3>
<textarea
value={notes}
onChange={e => handleNotesChange(e.target.value)}
placeholder="Notizen hier eingeben..."
rows={4}
className="w-full bg-[#0d0d18] border border-[#2e2e3e] rounded px-2 py-1.5 text-sm text-white outline-none focus:border-purple-500 resize-none"
/>
</div>
{/* Timeline */}
{fullLead.events && fullLead.events.length > 0 && (
<div className="bg-[#111118] rounded-lg border border-[#1e1e2e] p-3 space-y-2">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Verlauf</h3>
<div className="space-y-1.5">
<div className="flex items-start gap-2 text-xs text-gray-500">
<span className="mt-0.5">📥</span>
<div>
<span className="text-gray-400">Erfasst</span>
<span className="ml-2">{new Date(fullLead.capturedAt).toLocaleString("de-DE")}</span>
</div>
</div>
{fullLead.events.map(ev => (
<div key={ev.id} className="flex items-start gap-2 text-xs text-gray-500">
<span className="mt-0.5"></span>
<div>
<span className="text-gray-400">{ev.event}</span>
<span className="ml-2">{new Date(ev.at).toLocaleString("de-DE")}</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}
// ─── Main Page ─────────────────────────────────────────────────────────────────
export default function LeadVaultPage() {
// Stats
const [stats, setStats] = useState<Stats | null>(null);
// Quick SERP
const [serpOpen, setSerpOpen] = useState(false);
const [serpQuery, setSerpQuery] = useState("");
const [serpCount, setSerpCount] = useState("25");
const [serpCountry, setSerpCountry] = useState("de");
const [serpLanguage, setSerpLanguage] = useState("de");
const [serpFilter, setSerpFilter] = useState(true);
const [serpRunning, setSerpRunning] = useState(false);
// Filters
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [filterStatus, setFilterStatus] = useState<string[]>([]);
const [filterSource, setFilterSource] = useState<string[]>([]);
const [filterPriority, setFilterPriority] = useState<string[]>([]);
const [filterHasEmail, setFilterHasEmail] = useState("");
const [filterSearchTerm, setFilterSearchTerm] = useState("");
// Table
const [leads, setLeads] = useState<Lead[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(50);
const [pages, setPages] = useState(1);
const [sortBy, setSortBy] = useState("capturedAt");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
// Side panel
const [panelLead, setPanelLead] = useState<Lead | null>(null);
// Bulk
const [bulkStatus, setBulkStatus] = useState("");
const [bulkTag, setBulkTag] = useState("");
// Debounce search
useEffect(() => {
const t = setTimeout(() => { setDebouncedSearch(search); setPage(1); }, 300);
return () => clearTimeout(t);
}, [search]);
const loadStats = useCallback(async () => {
const res = await fetch("/api/leads/stats");
if (res.ok) setStats(await res.json() as Stats);
}, []);
const loadLeads = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
page: String(page), perPage: String(perPage),
search: debouncedSearch, sortBy, sortDir,
});
filterStatus.forEach(s => params.append("status", s));
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);
const res = await fetch(`/api/leads?${params}`);
if (res.ok) {
const data = await res.json() as { leads: Lead[]; total: number; pages: number };
setLeads(data.leads);
setTotal(data.total);
setPages(data.pages);
}
} finally {
setLoading(false);
}
}, [page, perPage, debouncedSearch, sortBy, sortDir, filterStatus, filterSource, filterPriority, filterHasEmail, filterSearchTerm]);
useEffect(() => { loadStats(); const t = setInterval(loadStats, 30000); return () => clearInterval(t); }, [loadStats]);
useEffect(() => { loadLeads(); }, [loadLeads]);
function toggleSort(field: string) {
if (sortBy === field) setSortDir(d => d === "asc" ? "desc" : "asc");
else { setSortBy(field); setSortDir("desc"); }
setPage(1);
}
function toggleFilter(arr: string[], setArr: (v: string[]) => void, val: string) {
setArr(arr.includes(val) ? arr.filter(x => x !== val) : [...arr, val]);
setPage(1);
}
function clearFilters() {
setSearch(""); setFilterStatus([]); setFilterSource([]);
setFilterPriority([]); setFilterHasEmail(""); setFilterSearchTerm("");
setPage(1);
}
async function updateLead(id: string, data: Partial<Lead>) {
// Optimistic update
setLeads(prev => prev.map(l => l.id === id ? { ...l, ...data } : l));
if (panelLead?.id === id) setPanelLead(prev => prev ? { ...prev, ...data } : prev);
const res = await fetch(`/api/leads/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) {
toast.error("Update fehlgeschlagen");
loadLeads(); // Revert
} else {
loadStats();
}
}
async function deleteLead(id: string) {
if (!confirm("Lead löschen?")) return;
await fetch(`/api/leads/${id}`, { method: "DELETE" });
setLeads(prev => prev.filter(l => l.id !== id));
setTotal(prev => prev - 1);
if (panelLead?.id === id) setPanelLead(null);
loadStats();
toast.success("Lead gelöscht");
}
async function runQuickSerp() {
if (!serpQuery.trim()) return toast.error("Suchbegriff eingeben");
setSerpRunning(true);
try {
const res = await fetch("/api/leads/quick-serp", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: serpQuery, count: Number(serpCount), country: serpCountry, language: serpLanguage, filterSocial: serpFilter }),
});
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();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Fehler");
} finally {
setSerpRunning(false);
}
}
async function bulkAction(action: "status" | "priority" | "tag" | "delete", value: string) {
if (!selected.size) return;
if (action === "delete" && !confirm(`${selected.size} Leads löschen?`)) return;
const res = await fetch("/api/leads/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: Array.from(selected), action, value }),
});
if (res.ok) {
const d = await res.json() as { updated: number };
toast.success(`${d.updated} Leads aktualisiert`);
setSelected(new Set());
loadLeads(); loadStats();
}
}
function exportCSV(emailOnly = false) {
const params = new URLSearchParams({ search: debouncedSearch, sortBy, sortDir });
filterStatus.forEach(s => params.append("status", s));
filterSource.forEach(s => params.append("sourceTab", s));
if (filterHasEmail) params.set("hasEmail", filterHasEmail);
if (emailOnly) params.set("emailOnly", "true");
window.open(`/api/leads/export?${params}`, "_blank");
}
function SortIcon({ field }: { field: string }) {
if (sortBy !== field) return <ArrowUpDown className="w-3 h-3 text-gray-600" />;
return sortDir === "asc" ? <ArrowUp className="w-3 h-3 text-purple-400" /> : <ArrowDown className="w-3 h-3 text-purple-400" />;
}
const allSelected = leads.length > 0 && leads.every(l => selected.has(l.id));
return (
<div className="space-y-5 max-w-[1400px]">
{/* Header */}
<div className="relative rounded-2xl bg-gradient-to-r from-purple-600/10 to-blue-600/10 border border-[#1e1e2e] p-6 overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-purple-500/5 to-transparent" />
<div className="relative flex items-center justify-between">
<div>
<div className="flex items-center gap-2 text-sm text-purple-400 mb-2">
<Database className="w-4 h-4" />
<span>Zentrale Datenbank</span>
</div>
<h1 className="text-2xl font-bold text-white">🗄 LeadVault</h1>
<p className="text-gray-400 mt-1 text-sm">Alle Leads aus allen Pipelines an einem Ort.</p>
</div>
<div className="flex gap-2">
<button onClick={() => { loadLeads(); loadStats(); }} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-[#2e2e3e] text-gray-400 hover:text-white hover:border-[#4e4e6e] text-sm transition-all">
<RefreshCw className="w-3.5 h-3.5" /> Aktualisieren
</button>
<div className="relative group">
<button className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-purple-500/30 bg-purple-500/10 text-purple-300 hover:bg-purple-500/20 text-sm transition-all">
<Download className="w-3.5 h-3.5" /> Export
</button>
<div className="absolute right-0 top-9 hidden group-hover:block z-50 bg-[#1a1a28] border border-[#2e2e3e] rounded-lg shadow-xl p-1 min-w-[220px]">
{[
["Aktuelle Ansicht (CSV)", () => exportCSV()],
["Alle Leads (CSV)", () => exportCSV()],
["Nur mit E-Mail (CSV)", () => exportCSV(true)],
].map(([label, fn]) => (
<button key={label as string} onClick={fn as () => void}
className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-[#2e2e3e] rounded">
{label as string}
</button>
))}
</div>
</div>
</div>
</div>
</div>
{/* Stats */}
{stats && (
<div className="grid grid-cols-4 gap-4">
{[
{ label: "Leads gesamt", value: stats.total, color: "#a78bfa" },
{ label: "Neu / Nicht kontaktiert", value: stats.new, color: "#60a5fa" },
{ label: "Kontaktiert / In Bearbeitung", value: stats.contacted, color: "#2dd4bf" },
{ label: "Mit verifizierter E-Mail", value: stats.withEmail, color: "#34d399" },
].map(({ label, value, color }) => (
<Card key={label} className="bg-[#111118] border-[#1e1e2e] p-4">
<p className="text-xs text-gray-500 mb-1">{label}</p>
<div className="flex items-end justify-between">
<p className="text-2xl font-bold text-white">{value.toLocaleString()}</p>
<Sparkline data={stats.dailyCounts.map(d => d.count)} color={color} />
</div>
</Card>
))}
</div>
)}
{/* Quick SERP */}
<Card className="bg-[#111118] border-[#1e1e2e] overflow-hidden">
<button
onClick={() => setSerpOpen(!serpOpen)}
className="w-full flex items-center justify-between px-5 py-3.5 text-sm font-medium text-gray-300 hover:text-white transition-colors"
>
<span className="flex items-center gap-2"><Search className="w-4 h-4 text-purple-400" /> Quick SERP Capture</span>
{serpOpen ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{serpOpen && (
<div className="px-5 pb-5 border-t border-[#1e1e2e] pt-4 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<Input
placeholder='Suchbegriff, z.B. "Dachdecker Freiburg"'
value={serpQuery}
onChange={e => setSerpQuery(e.target.value)}
onKeyDown={e => e.key === "Enter" && runQuickSerp()}
className="bg-[#0d0d18] border-[#2e2e3e] text-white placeholder:text-gray-600 focus:border-purple-500"
/>
</div>
<Select value={serpCount} onValueChange={v => setSerpCount(v ?? "25")}>
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#111118] border-[#2e2e3e]">
{["10", "25", "50", "100"].map(v => (
<SelectItem key={v} value={v} className="text-gray-300">{v} Ergebnisse</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-2">
<Select value={serpCountry} onValueChange={v => setSerpCountry(v ?? "de")}>
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#111118] border-[#2e2e3e]">
{[["de","DE"],["at","AT"],["ch","CH"],["us","US"]].map(([v,l]) => (
<SelectItem key={v} value={v} className="text-gray-300">{l}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={serpLanguage} onValueChange={v => setSerpLanguage(v ?? "de")}>
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#111118] border-[#2e2e3e]">
{[["de","Deutsch"],["en","English"]].map(([v,l]) => (
<SelectItem key={v} value={v} className="text-gray-300">{l}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 text-sm text-gray-400 cursor-pointer">
<input type="checkbox" checked={serpFilter} onChange={e => setSerpFilter(e.target.checked)} className="rounded" />
Social-Media / Verzeichnisse herausfiltern
</label>
<Button
onClick={runQuickSerp}
disabled={serpRunning || !serpQuery.trim()}
className="bg-gradient-to-r from-purple-500 to-blue-600 hover:from-purple-600 hover:to-blue-700 text-white font-medium px-6"
>
{serpRunning ? "Suche läuft..." : "🔍 SERP Capture starten"}
</Button>
</div>
</div>
)}
</Card>
{/* Filter Bar */}
<Card className="bg-[#111118] border-[#1e1e2e] p-4 space-y-3">
<div className="flex items-center gap-3 flex-wrap">
{/* Search */}
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-500" />
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Domain, Firma, Name, E-Mail suchen..."
className="w-full bg-[#0d0d18] border border-[#2e2e3e] rounded-lg pl-8 pr-3 py-1.5 text-sm text-white outline-none focus:border-purple-500"
/>
</div>
{/* Has Email toggle */}
<div className="flex gap-1 bg-[#0d0d18] border border-[#2e2e3e] rounded-lg p-1">
{[["", "Alle"], ["yes", "Mit E-Mail"], ["no", "Ohne E-Mail"]].map(([v, l]) => (
<button key={v} onClick={() => { setFilterHasEmail(v); setPage(1); }}
className={`px-2.5 py-1 rounded text-xs font-medium transition-all ${filterHasEmail === v ? "bg-purple-500/30 text-purple-300" : "text-gray-500 hover:text-gray-300"}`}>
{l}
</button>
))}
</div>
{/* Clear + count */}
<div className="flex items-center gap-2 ml-auto">
{(search || filterStatus.length || filterSource.length || filterPriority.length || filterHasEmail || filterSearchTerm) && (
<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>
)}
<span className="text-xs text-gray-500">{total.toLocaleString()} Leads</span>
</div>
</div>
<div className="flex gap-2 flex-wrap">
{/* Status filter */}
<div className="flex gap-1 flex-wrap">
{Object.entries(STATUS_CONFIG).map(([key, c]) => (
<button key={key} onClick={() => toggleFilter(filterStatus, setFilterStatus, key)}
className={`px-2.5 py-1 rounded-full text-xs border transition-all ${filterStatus.includes(key) ? c.bg + " " + c.color : "border-[#2e2e3e] text-gray-600 hover:text-gray-400"}`}>
{c.label}
</button>
))}
</div>
<div className="w-px bg-[#2e2e3e] mx-1" />
{/* Source filter */}
<div className="flex gap-1 flex-wrap">
{Object.entries(SOURCE_CONFIG).map(([key, s]) => (
<button key={key} onClick={() => toggleFilter(filterSource, setFilterSource, key)}
className={`px-2.5 py-1 rounded-full text-xs border transition-all ${filterSource.includes(key) ? "bg-purple-500/20 text-purple-300 border-purple-500/30" : "border-[#2e2e3e] text-gray-600 hover:text-gray-400"}`}>
{s.icon} {s.label}
</button>
))}
</div>
<div className="w-px bg-[#2e2e3e] mx-1" />
{/* Priority filter */}
<div className="flex gap-1">
{Object.entries(PRIORITY_CONFIG).map(([key, p]) => (
<button key={key} onClick={() => toggleFilter(filterPriority, setFilterPriority, key)}
className={`px-2.5 py-1 rounded-full text-xs border transition-all ${filterPriority.includes(key) ? "bg-[#2e2e3e] border-[#4e4e6e] " + p.color : "border-[#2e2e3e] text-gray-600 hover:text-gray-400"}`}>
{p.icon} {p.label}
</button>
))}
</div>
</div>
</Card>
{/* Bulk Actions */}
{selected.size > 0 && (
<div className="flex items-center gap-3 bg-purple-500/10 border border-purple-500/20 rounded-xl px-4 py-2.5">
<span className="text-sm text-purple-300 font-medium">{selected.size} ausgewählt</span>
<div className="flex gap-2 flex-wrap">
<select
value={bulkStatus}
onChange={e => { setBulkStatus(e.target.value); if (e.target.value) bulkAction("status", e.target.value); }}
className="bg-[#1a1a28] border border-[#2e2e3e] text-gray-300 text-xs rounded px-2 py-1"
>
<option value="">Status ändern...</option>
{Object.entries(STATUS_CONFIG).map(([k, c]) => <option key={k} value={k}>{c.label}</option>)}
</select>
<div className="flex gap-1">
<input
value={bulkTag}
onChange={e => setBulkTag(e.target.value)}
placeholder="Tag hinzufügen..."
className="bg-[#1a1a28] border border-[#2e2e3e] text-gray-300 text-xs rounded px-2 py-1 w-32 outline-none"
/>
<button onClick={() => { if (bulkTag) { bulkAction("tag", bulkTag); setBulkTag(""); } }}
className="text-xs px-2 py-1 rounded bg-[#2e2e3e] text-gray-300 hover:bg-[#3e3e5e]">
<Tag className="w-3 h-3" />
</button>
</div>
<button onClick={() => bulkAction("delete", "")}
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded bg-red-500/20 text-red-300 border border-red-500/30 hover:bg-red-500/30">
<Trash2 className="w-3 h-3" /> Löschen
</button>
<button onClick={() => setSelected(new Set())} className="text-xs text-gray-500 hover:text-white px-2"></button>
</div>
</div>
)}
{/* Table */}
<Card className="bg-[#111118] border-[#1e1e2e] overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#1e1e2e]">
<th className="px-3 py-2.5 text-left">
<button onClick={() => {
if (allSelected) setSelected(new Set());
else setSelected(new Set(leads.map(l => l.id)));
}}>
{allSelected
? <CheckSquare className="w-4 h-4 text-purple-400" />
: <Square className="w-4 h-4 text-gray-600" />}
</button>
</th>
{[
["status", "Status"],
["priority", "Priorität"],
["companyName", "Unternehmen"],
["contactName", "Kontakt"],
["email", "E-Mail"],
["sourceTab", "Quelle"],
["capturedAt", "Erfasst"],
].map(([field, label]) => (
<th key={field} className="px-3 py-2.5 text-left text-xs font-medium text-gray-500 whitespace-nowrap">
<button onClick={() => toggleSort(field)} className="flex items-center gap-1 hover:text-gray-300">
{label} <SortIcon field={field} />
</button>
</th>
))}
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500">Tags</th>
<th className="px-3 py-2.5 text-right text-xs font-medium text-gray-500">Aktionen</th>
</tr>
</thead>
<tbody>
{loading && !leads.length ? (
<tr><td colSpan={10} className="px-4 py-8 text-center text-gray-500 text-sm">Lädt...</td></tr>
) : leads.length === 0 ? (
<tr>
<td colSpan={10} className="px-4 py-16 text-center">
<Database className="w-8 h-8 text-gray-700 mx-auto mb-3" />
<p className="text-gray-500 text-sm">
{(search || filterStatus.length || filterSource.length)
? "Keine Leads für diese Filter."
: "Noch keine Leads. Pipeline ausführen oder Quick SERP nutzen."}
</p>
{(search || filterStatus.length) && (
<button onClick={clearFilters} className="mt-2 text-xs text-purple-400 hover:underline">Filter zurücksetzen</button>
)}
</td>
</tr>
) : leads.map((lead, i) => {
const tags: string[] = JSON.parse(lead.tags || "[]");
const isSelected = selected.has(lead.id);
const isHigh = lead.priority === "high";
const cfg = STATUS_CONFIG[lead.status] || STATUS_CONFIG.new;
const prio = PRIORITY_CONFIG[lead.priority] || PRIORITY_CONFIG.normal;
const src = SOURCE_CONFIG[lead.sourceTab];
return (
<tr
key={lead.id}
onClick={() => setPanelLead(lead)}
className={`border-b border-[#1a1a28] cursor-pointer transition-colors hover:bg-[#1a1a28] ${
isSelected ? "bg-[#1a1a2e]" : i % 2 === 0 ? "bg-[#111118]" : "bg-[#0f0f16]"
} ${isHigh ? "border-l-2 border-l-red-500" : ""}`}
>
<td className="px-3 py-2.5" onClick={e => e.stopPropagation()}>
<button onClick={() => {
setSelected(prev => {
const n = new Set(prev);
if (n.has(lead.id)) n.delete(lead.id); else n.add(lead.id);
return n;
});
}}>
{isSelected
? <CheckSquare className="w-4 h-4 text-purple-400" />
: <Square className="w-4 h-4 text-gray-600" />}
</button>
</td>
<td className="px-3 py-2.5" onClick={e => e.stopPropagation()}>
<StatusBadge status={lead.status} onChange={s => updateLead(lead.id, { status: s })} />
</td>
<td className="px-3 py-2.5">
<span className={`text-xs font-bold ${prio.color}`}>{prio.icon}</span>
</td>
<td className="px-3 py-2.5 max-w-[180px]">
<p className="text-sm text-white truncate font-medium">{lead.companyName || ""}</p>
<p className="text-xs text-gray-500 truncate">{lead.domain}</p>
</td>
<td className="px-3 py-2.5 max-w-[160px]">
<p className="text-sm text-gray-300 truncate">{lead.contactName || ""}</p>
{lead.contactTitle && <p className="text-xs text-gray-600 truncate">{lead.contactTitle}</p>}
</td>
<td className="px-3 py-2.5 max-w-[200px]" onClick={e => e.stopPropagation()}>
{lead.email ? (
<button
onClick={() => { navigator.clipboard.writeText(lead.email!); toast.success("E-Mail kopiert"); }}
className="text-xs font-mono text-green-400 hover:text-green-300 truncate block max-w-full"
title={lead.email}
>
{lead.email}
</button>
) : (
<span className="text-xs text-gray-600"></span>
)}
{lead.emailConfidence != null && lead.email && (
<div className="w-16 h-1 bg-[#2e2e3e] rounded-full mt-1">
<div
className={`h-full rounded-full ${lead.emailConfidence >= 0.8 ? "bg-green-500" : lead.emailConfidence >= 0.5 ? "bg-yellow-500" : "bg-red-500"}`}
style={{ width: `${lead.emailConfidence * 100}%` }}
/>
</div>
)}
</td>
<td className="px-3 py-2.5">
<span className="text-xs text-gray-400">{src?.icon} {src?.label || lead.sourceTab}</span>
</td>
<td className="px-3 py-2.5 whitespace-nowrap" title={new Date(lead.capturedAt).toLocaleString("de-DE")}>
<span className="text-xs text-gray-500">{relativeTime(lead.capturedAt)}</span>
</td>
<td className="px-3 py-2.5">
<div className="flex gap-1 flex-wrap max-w-[120px]">
{tags.slice(0, 2).map(tag => (
<span key={tag} className={`text-[10px] px-1.5 py-0.5 rounded-full border ${tagColor(tag)}`}>{tag}</span>
))}
{tags.length > 2 && <span className="text-[10px] text-gray-500">+{tags.length - 2}</span>}
</div>
</td>
<td className="px-3 py-2.5" onClick={e => e.stopPropagation()}>
<div className="flex items-center gap-1 justify-end">
<button onClick={() => updateLead(lead.id, { status: "contacted" })}
title="Als kontaktiert markieren"
className="p-1 rounded text-gray-600 hover:text-teal-400 hover:bg-teal-500/10 transition-all">
<Mail className="w-3.5 h-3.5" />
</button>
<button onClick={() => {
const cycle: Record<string, string> = { low: "normal", normal: "high", high: "low" };
updateLead(lead.id, { priority: cycle[lead.priority] || "normal" });
}}
title="Priorität wechseln"
className="p-1 rounded text-gray-600 hover:text-amber-400 hover:bg-amber-500/10 transition-all">
<Star className="w-3.5 h-3.5" />
</button>
{lead.domain && (
<a href={`https://${lead.domain}`} target="_blank" rel="noreferrer"
className="p-1 rounded text-gray-600 hover:text-blue-400 hover:bg-blue-500/10 transition-all">
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
<button onClick={() => deleteLead(lead.id)}
className="p-1 rounded text-gray-600 hover:text-red-400 hover:bg-red-500/10 transition-all">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Pagination */}
{pages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-[#1e1e2e]">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Zeilen pro Seite:</span>
<select
value={perPage}
onChange={e => { setPerPage(Number(e.target.value)); setPage(1); }}
className="bg-[#0d0d18] border border-[#2e2e3e] text-gray-300 text-xs rounded px-2 py-1"
>
{[25, 50, 100].map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
<div className="flex items-center gap-1">
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}
className="px-2.5 py-1 rounded text-xs text-gray-400 border border-[#2e2e3e] hover:border-[#4e4e6e] disabled:opacity-30">
Zurück
</button>
{Array.from({ length: Math.min(7, pages) }, (_, i) => {
let p = i + 1;
if (pages > 7) {
if (page <= 4) p = i + 1;
else if (page >= pages - 3) p = pages - 6 + i;
else p = page - 3 + i;
}
return (
<button key={p} onClick={() => setPage(p)}
className={`w-7 h-7 rounded text-xs ${page === p ? "bg-purple-500/30 text-purple-300 border border-purple-500/30" : "text-gray-500 hover:text-gray-300"}`}>
{p}
</button>
);
})}
<button disabled={page >= pages} onClick={() => setPage(p => p + 1)}
className="px-2.5 py-1 rounded text-xs text-gray-400 border border-[#2e2e3e] hover:border-[#4e4e6e] disabled:opacity-30">
Weiter
</button>
</div>
<span className="text-xs text-gray-500">Seite {page} von {pages}</span>
</div>
)}
</Card>
{/* Side Panel */}
{panelLead && (
<SidePanel
lead={panelLead}
onClose={() => setPanelLead(null)}
onUpdate={updated => {
setLeads(prev => prev.map(l => l.id === panelLead.id ? { ...l, ...updated } : l));
}}
/>
)}
</div>
);
}