Files
lead-scraper/app/leadvault/page.tsx
Timo Uttenweiler 82c4244233 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>
2026-03-20 17:53:40 +01:00

1063 lines
50 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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;
address: 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" />
<a href={`tel:${fullLead.phone}`} className="text-sm text-gray-300 hover:text-white">{fullLead.phone}</a>
</div>
)}
{fullLead.address && (
<div className="flex items-start gap-2">
<span className="text-gray-500 mt-0.5">📍</span>
<span className="text-sm text-gray-300">{fullLead.address}</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 [filterSearchTerms, setFilterSearchTerms] = useState<string[]>([]);
const [availableSearchTerms, setAvailableSearchTerms] = useState<string[]>([]);
// 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);
filterSearchTerms.forEach(t => params.append("searchTerm", t));
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, 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");
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(""); setFilterSearchTerms([]);
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(); loadSearchTerms();
} 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(); loadSearchTerms();
}
}
function exportFile(format: "csv" | "xlsx", emailOnly = false) {
const params = new URLSearchParams({ search: debouncedSearch, sortBy, sortDir, format });
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)", () => exportFile("csv")],
["Aktuelle Ansicht (Excel)", () => exportFile("xlsx")],
["Nur mit E-Mail (CSV)", () => exportFile("csv", true)],
["Nur mit E-Mail (Excel)", () => exportFile("xlsx", 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 || 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>
)}
<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>
{/* 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>
{/* 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"],
["phone", "Telefon"],
["email", "E-Mail"],
["sourceTerm", "Suchbegriff"],
["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-[160px]">
{lead.phone ? (
<a href={`tel:${lead.phone}`} onClick={e => e.stopPropagation()}
className="text-xs text-gray-300 hover:text-white whitespace-nowrap">
{lead.phone}
</a>
) : lead.address ? (
<p className="text-xs text-gray-500 truncate" title={lead.address}>{lead.address}</p>
) : (
<span className="text-xs text-gray-600"></span>
)}
{lead.phone && lead.address && (
<p className="text-[11px] text-gray-600 truncate" title={lead.address}>{lead.address}</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 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>
<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>
);
}