Files
lead-scraper/app/leadspeicher/page.tsx
Timo Uttenweiler 54e0d22f9c mein-solar: full feature set
- Schema: companyType, topics, salesScore, salesReason, offerPackage, approved, approvedAt, SearchHistory table
- /api/search-history: GET (by mode) + POST (save query)
- /api/ai-search: stadtwerke/industrie/custom prompts with history dedup
- /api/enrich-leads: website scraping + GPT-4o-mini enrichment (fire-and-forget after each job)
- /api/generate-email: personalized outreach via GPT-4o
- Suche page: 3 mode tabs (Stadtwerke/Industrie/Freie Suche), Alle-Bundesländer queue button, AiSearchModal gets searchMode + history
- Leadspeicher: Bewertung dots column, Paket badge column, Freigeben toggle button, email generator in SidePanel, approved-only export option
- Leads API: approvedOnly + companyType filters, new fields in PATCH

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:06:07 +02:00

1066 lines
51 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 } 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, ArrowUpDown, ArrowUp, ArrowDown,
CheckSquare, Square, Download, Phone, Copy, MapPin, Building2, Globe
} 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;
status: string;
priority: string;
notes: string | null;
tags: string | null;
country: string | null;
headcount: string | null;
industry: string | null;
description: string | null;
capturedAt: string;
contactedAt: string | null;
companyType: string | null;
topics: string | null;
salesScore: number | null;
salesReason: string | null;
offerPackage: string | null;
approved: boolean;
approvedAt: 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 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];
}
// ─── 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>
);
}
// ─── Side Panel ───────────────────────────────────────────────────────────────
function SidePanel({ lead, onClose, onUpdate, onDelete }: {
lead: Lead;
onClose: () => void;
onUpdate: (updated: Partial<Lead>) => void;
onDelete: (id: string) => void;
}) {
const tags: string[] = JSON.parse(lead.tags || "[]");
const src = SOURCE_CONFIG[lead.sourceTab];
const [emailLoading, setEmailLoading] = useState(false);
const [generatedEmail, setGeneratedEmail] = useState<{ subject: string; body: string } | null>(null);
function copy(text: string, label: string) {
navigator.clipboard.writeText(text);
toast.success(`${label} kopiert`);
}
async function generateEmail() {
setEmailLoading(true);
setGeneratedEmail(null);
try {
const res = await fetch("/api/generate-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ leadId: lead.id }),
});
const data = await res.json() as { subject?: string; body?: string; error?: string };
if (!res.ok || !data.subject) throw new Error(data.error || "Fehler");
setGeneratedEmail({ subject: data.subject, body: data.body! });
} catch (e) {
toast.error(e instanceof Error ? e.message : "E-Mail-Generierung fehlgeschlagen");
} finally {
setEmailLoading(false);
}
}
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-sm h-full bg-[#0e0e1a] border-l border-[#1e1e2e] flex flex-col overflow-hidden"
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="border-b border-[#1e1e2e] p-4 flex items-start gap-3 flex-shrink-0">
<div className="flex-1 min-w-0">
<h2 className="text-base font-bold text-white truncate">{lead.companyName || lead.domain || "Unbekannt"}</h2>
{lead.domain && (
<a href={`https://${lead.domain}`} target="_blank" rel="noreferrer"
className="text-xs text-blue-400 hover:underline flex items-center gap-1 mt-0.5">
<Globe className="w-3 h-3" />{lead.domain}
</a>
)}
{lead.industry && <p className="text-xs text-purple-400 mt-0.5">{lead.industry}</p>}
</div>
<button onClick={onClose} className="text-gray-500 hover:text-white p-1 rounded flex-shrink-0">
<X className="w-4 h-4" />
</button>
</div>
{/* Scrollable body */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Description */}
{lead.description && (
<div>
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-1">Beschreibung</p>
<p className="text-xs text-gray-300 leading-relaxed">{lead.description}</p>
</div>
)}
{/* Contact */}
{(lead.contactName || lead.email || lead.phone || lead.address || lead.linkedinUrl) && (
<div>
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-2">Kontakt</p>
<div className="space-y-2">
{lead.contactName && (
<div className="flex items-center gap-2">
<Building2 className="w-3.5 h-3.5 text-gray-600 flex-shrink-0" />
<span className="text-sm text-white">{lead.contactName}
{lead.contactTitle && <span className="text-gray-500 ml-1 text-xs">· {lead.contactTitle}</span>}
</span>
</div>
)}
{lead.email && (
<div className="flex items-center gap-2">
<Mail className="w-3.5 h-3.5 text-gray-600 flex-shrink-0" />
<span className="text-xs font-mono text-green-400 truncate flex-1">{lead.email}</span>
<button onClick={() => copy(lead.email!, "E-Mail")} className="text-gray-600 hover:text-white flex-shrink-0">
<Copy className="w-3 h-3" />
</button>
</div>
)}
{lead.phone && (
<div className="flex items-center gap-2">
<Phone className="w-3.5 h-3.5 text-gray-600 flex-shrink-0" />
<a href={`tel:${lead.phone}`} className="text-xs text-gray-300 hover:text-white">{lead.phone}</a>
<button onClick={() => copy(lead.phone!, "Telefon")} className="text-gray-600 hover:text-white flex-shrink-0">
<Copy className="w-3 h-3" />
</button>
</div>
)}
{lead.address && (
<div className="flex items-start gap-2">
<MapPin className="w-3.5 h-3.5 text-gray-600 flex-shrink-0 mt-0.5" />
<span className="text-xs text-gray-300 flex-1">{lead.address}</span>
<a
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(lead.address)}`}
target="_blank" rel="noreferrer"
className="text-gray-600 hover:text-green-400 flex-shrink-0"
title="In Google Maps öffnen"
>
<ExternalLink className="w-3 h-3" />
</a>
</div>
)}
{lead.linkedinUrl && (
<div className="flex items-center gap-2">
<ExternalLink className="w-3.5 h-3.5 text-gray-600 flex-shrink-0" />
<a href={lead.linkedinUrl} target="_blank" rel="noreferrer"
className="text-xs text-blue-400 hover:underline truncate">
LinkedIn Profil
</a>
</div>
)}
</div>
</div>
)}
{/* KI-Bewertung */}
{(lead.salesScore || lead.offerPackage) && (
<div>
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-2">KI-Bewertung</p>
<div className="space-y-1.5">
{lead.salesScore && (
<div className="flex items-center gap-2">
<div className="flex gap-0.5">
{[1,2,3,4,5].map(i => (
<span key={i} style={{
width: 8, height: 8, borderRadius: "50%", display: "inline-block",
background: i <= lead.salesScore! ? (lead.salesScore! >= 4 ? "#22c55e" : lead.salesScore! >= 3 ? "#f59e0b" : "#6b7280") : "#1e1e2e",
}} />
))}
</div>
{lead.salesReason && <span className="text-xs text-gray-400">{lead.salesReason}</span>}
</div>
)}
{lead.offerPackage && <span className="text-xs text-gray-400">Paket: <span className="text-gray-200">{lead.offerPackage}</span></span>}
{lead.topics && (() => {
try { return <span className="text-xs text-gray-400">Themen: <span className="text-gray-200">{(JSON.parse(lead.topics) as string[]).join(", ")}</span></span>; }
catch { return null; }
})()}
</div>
</div>
)}
{/* E-Mail Generator */}
{lead.email && (
<div>
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-2">Erstansprache</p>
{!generatedEmail ? (
<button
onClick={generateEmail}
disabled={emailLoading}
style={{
width: "100%", padding: "8px 12px", borderRadius: 8, border: "1px solid rgba(139,92,246,0.35)",
background: "rgba(139,92,246,0.08)", color: "#a78bfa", fontSize: 12, cursor: emailLoading ? "not-allowed" : "pointer",
display: "flex", alignItems: "center", justifyContent: "center", gap: 6,
}}
>
{emailLoading ? "✨ E-Mail wird generiert…" : "✨ E-Mail generieren"}
</button>
) : (
<div style={{ background: "#0d0d18", border: "1px solid #1e1e2e", borderRadius: 8, padding: 12 }}>
<div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 10, color: "#6b7280", marginBottom: 4, textTransform: "uppercase", letterSpacing: "0.05em" }}>Betreff</div>
<div style={{ display: "flex", gap: 6, alignItems: "flex-start" }}>
<span style={{ fontSize: 12, color: "#fff", flex: 1 }}>{generatedEmail.subject}</span>
<button onClick={() => copy(generatedEmail.subject, "Betreff")} style={{ background: "none", border: "none", color: "#6b7280", cursor: "pointer", padding: 2 }}>
<Copy className="w-3 h-3" />
</button>
</div>
</div>
<div>
<div style={{ fontSize: 10, color: "#6b7280", marginBottom: 4, textTransform: "uppercase", letterSpacing: "0.05em" }}>Nachricht</div>
<div style={{ display: "flex", gap: 6, alignItems: "flex-start" }}>
<pre style={{ fontSize: 11, color: "#d1d5db", whiteSpace: "pre-wrap", flex: 1, fontFamily: "inherit", lineHeight: 1.6, margin: 0 }}>{generatedEmail.body}</pre>
<button onClick={() => copy(generatedEmail.body, "E-Mail")} style={{ background: "none", border: "none", color: "#6b7280", cursor: "pointer", padding: 2, flexShrink: 0 }}>
<Copy className="w-3 h-3" />
</button>
</div>
</div>
<button onClick={() => setGeneratedEmail(null)} style={{ marginTop: 8, fontSize: 11, color: "#6b7280", background: "none", border: "none", cursor: "pointer" }}>
Neu generieren
</button>
</div>
)}
</div>
)}
{/* Company info */}
{(lead.headcount || lead.country) && (
<div>
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-1">Unternehmen</p>
<div className="flex gap-3 text-xs text-gray-400">
{lead.headcount && <span>👥 {lead.headcount} Mitarbeiter</span>}
{lead.country && <span>🌍 {lead.country}</span>}
</div>
</div>
)}
{/* Source */}
<div>
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-1">Quelle</p>
<div className="space-y-1 text-xs text-gray-400">
<p>{src?.icon} {src?.label || lead.sourceTab}</p>
{lead.sourceTerm && (
<p>Suche: <span className="text-gray-300">"{lead.sourceTerm}"</span></p>
)}
<p>Erfasst: <span className="text-gray-300">{new Date(lead.capturedAt).toLocaleString("de-DE")}</span></p>
</div>
</div>
{/* Tags (read-only) */}
{tags.length > 0 && (
<div>
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-2">Tags</p>
<div className="flex flex-wrap gap-1.5">
{tags.map(tag => (
<span key={tag} className={`px-2 py-0.5 rounded-full text-xs border ${tagColor(tag)}`}>{tag}</span>
))}
</div>
</div>
)}
</div>
{/* Delete */}
<div className="p-4 border-t border-[#1e1e2e] flex-shrink-0">
<button
onClick={() => { onDelete(lead.id); onClose(); }}
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 text-sm transition-all"
>
<Trash2 className="w-3.5 h-3.5" /> Lead löschen
</button>
</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 = "de";
const serpLanguage = "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 [filterHasEmail, setFilterHasEmail] = useState("");
const [filterContacted, setFilterContacted] = useState(false);
const [filterFavorite, setFilterFavorite] = useState(false);
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);
// Export dropdown
const [exportOpen, setExportOpen] = useState(false);
// Bulk
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));
if (filterHasEmail) params.set("hasEmail", filterHasEmail);
if (filterContacted) params.set("contacted", "yes");
if (filterFavorite) params.set("favorite", "yes");
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, filterHasEmail, filterContacted, filterFavorite, 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([]);
setFilterHasEmail(""); setFilterContacted(false); setFilterFavorite(false);
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-full px-[72px] py-[72px]">
{/* 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">🗄 Leadspeicher</h1>
<p className="text-gray-400 mt-1 text-sm">Alle Leads an einem Ort.</p>
</div>
<div className="flex gap-2">
<button
onClick={() => setExportOpen(v => !v)}
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>
</div>
</div>
{/* Export dropdown — outside overflow-hidden header */}
{exportOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setExportOpen(false)} />
<div className="fixed top-20 right-6 z-50 bg-[#1a1a28] border border-[#2e2e3e] rounded-lg shadow-2xl p-1 min-w-[230px]">
{([
["Aktuelle Ansicht", () => exportFile("xlsx")],
["Nur mit E-Mail", () => exportFile("xlsx", true)],
["✓ Freigegebene exportieren", () => { const p = new URLSearchParams({ format: "xlsx", approvedOnly: "true" }); window.open(`/api/leads/export?${p}`, "_blank"); }],
] as [string, () => void][]).map(([label, fn]) => (
<button key={label} onClick={() => { fn(); setExportOpen(false); }}
className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-[#2e2e3e] rounded">
{label}
</button>
))}
</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" /> Schnell neue Suche</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>
<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>
{/* Kontaktiert toggle */}
<button
onClick={() => { setFilterContacted(v => !v); setPage(1); }}
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-all ${
filterContacted
? "bg-teal-500/20 text-teal-300 border-teal-500/30"
: "bg-[#0d0d18] text-gray-500 border-[#2e2e3e] hover:text-gray-300 hover:border-[#4e4e6e]"
}`}
>
Kontaktiert
</button>
{/* Favoriten toggle */}
<button
onClick={() => { setFilterFavorite(v => !v); setPage(1); }}
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-all ${
filterFavorite
? "bg-amber-500/20 text-amber-300 border-amber-500/30"
: "bg-[#0d0d18] text-gray-500 border-[#2e2e3e] hover:text-gray-300 hover:border-[#4e4e6e]"
}`}
>
Favoriten
</button>
{/* Clear + count */}
<div className="flex items-center gap-2 ml-auto">
{(search || filterHasEmail || filterContacted || filterFavorite || 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>
</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">
<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>
{[
["companyName", "Unternehmen"],
["contactName", "Kontakt"],
["phone", "Telefon"],
["email", "E-Mail"],
["sourceTerm", "Suchbegriff"],
["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">Bewertung</th>
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500">Paket</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={7} className="px-4 py-8 text-center text-gray-500 text-sm">Lädt...</td></tr>
) : leads.length === 0 ? (
<tr>
<td colSpan={7} 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 || filterSource.length || filterContacted || filterFavorite)
? "Keine Leads für diese Filter."
: "Noch keine Leads. Pipeline ausführen oder Quick SERP nutzen."}
</p>
{search && (
<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 cfg = STATUS_CONFIG[lead.status] || STATUS_CONFIG.new;
const src = SOURCE_CONFIG[lead.sourceTab];
const isFavorite = tags.includes("favorit");
const isContacted = tags.includes("kontaktiert");
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]"
}`}
>
<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 max-w-[220px]">
<p className="text-sm text-white truncate font-medium">{lead.companyName || ""}</p>
<p className="text-xs text-gray-500 truncate">{lead.domain}</p>
{(lead.description || lead.industry) && (
<p className="text-[10px] text-gray-600 truncate mt-0.5" title={lead.description || lead.industry || ""}>
{lead.industry ? `${lead.industry}${lead.description ? " · " + lead.description : ""}` : lead.description}
</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>
)}
</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 whitespace-nowrap" title={new Date(lead.capturedAt).toLocaleString("de-DE")}>
<span className="text-xs text-gray-500">{new Date(lead.capturedAt).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" })}</span>
</td>
{/* Bewertung */}
<td className="px-3 py-2.5" title={lead.salesReason || ""}>
{lead.salesScore ? (
<div className="flex gap-0.5">
{[1,2,3,4,5].map(i => (
<span key={i} style={{
width: 7, height: 7, borderRadius: "50%",
background: i <= lead.salesScore!
? (lead.salesScore! >= 4 ? "#22c55e" : lead.salesScore! >= 3 ? "#f59e0b" : "#6b7280")
: "#1e1e2e",
display: "inline-block",
}} />
))}
</div>
) : <span className="text-xs text-gray-600"></span>}
</td>
{/* Paket */}
<td className="px-3 py-2.5">
{lead.offerPackage ? (() => {
const pkgMap: Record<string, { label: string; style: React.CSSProperties }> = {
"solar-basis": { label: "Basis", style: { background: "#1e1e2e", color: "#9ca3af" } },
"solar-pro": { label: "Pro", style: { background: "#1e3a5f", color: "#93c5fd" } },
"solar-speicher": { label: "Solar+Speicher", style: { background: "#2e1065", color: "#d8b4fe" } },
"komplett": { label: "Komplett", style: { background: "#064e3b", color: "#6ee7b7" } },
};
const pkg = pkgMap[lead.offerPackage] ?? { label: lead.offerPackage, style: { background: "#1e1e2e", color: "#9ca3af" } };
return (
<span style={{ ...pkg.style, fontSize: 10, padding: "2px 7px", borderRadius: 10, whiteSpace: "nowrap" }}>
{pkg.label}
</span>
);
})() : <span className="text-xs text-gray-600"></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={e => {
e.stopPropagation();
if (isContacted) {
updateLead(lead.id, { tags: JSON.stringify(tags.filter(t => t !== "kontaktiert")) });
} else {
updateLead(lead.id, { tags: JSON.stringify([...tags, "kontaktiert"]) });
}
}}
title="Kontaktiert"
className={isContacted
? "p-1 rounded text-teal-400 bg-teal-500/10 transition-all"
: "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={e => {
e.stopPropagation();
if (isFavorite) {
updateLead(lead.id, { tags: JSON.stringify(tags.filter(t => t !== "favorit")) });
} else {
updateLead(lead.id, { tags: JSON.stringify([...tags, "favorit"]) });
}
}}
title="Favorit"
className={isFavorite
? "p-1 rounded text-amber-400 bg-amber-500/10 transition-all"
: "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>
<button
onClick={e => {
e.stopPropagation();
updateLead(lead.id, { approved: !lead.approved, approvedAt: !lead.approved ? new Date().toISOString() : null });
}}
title={lead.approved ? "Freigegeben" : "Freigeben"}
className={lead.approved
? "p-1 rounded text-green-400 bg-green-500/10 transition-all"
: "p-1 rounded text-gray-600 hover:text-green-400 hover:bg-green-500/10 transition-all"}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
</button>
{lead.domain && (
<a href={`https://${lead.domain}`} target="_blank" rel="noreferrer"
title="Website öffnen"
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 => {
setPanelLead(prev => prev ? { ...prev, ...updated } : prev);
setLeads(prev => prev.map(l => l.id === panelLead.id ? { ...l, ...updated } : l));
}}
onDelete={id => { deleteLead(id); }}
/>
)}
</div>
);
}