Files
lead-scraper/app/leadspeicher/page.tsx
Timo Uttenweiler 7db914084e Stitch redesign, Energieversorger-Kampagne, UI improvements
- Apply Stitch design system to leadspeicher, suche, TopBar, globals.css
- Add Energieversorger queue campaign (Netzbetreiber, Fernwärme, Industriepark)
  with BW + Bayern priority, tracks usage per term+location combo
- Remove TopBar right-side actions (Leads finden, bell, settings)
- Remove mode tabs from manual search, rename KI button
- Fix Google Fonts @import order (move to <link> in layout.tsx)
- Add cursor-pointer globally via globals.css
- Responsive fixes for campaign buttons and KI button
- Fix .dockerignore to exclude .env from image build
- Add stadtwerke-cities API + city data (50 cities per Bundesland)

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

1086 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 flex flex-col overflow-hidden"
style={{ background: "#161c21", borderLeft: "1px solid rgba(255,255,255,0.07)", animation: "slideIn 200ms ease-out" }}
onClick={e => e.stopPropagation()}
>
<style>{`@keyframes slideIn { from { transform: translateX(100%) } to { transform: translateX(0) } }`}</style>
{/* Header */}
<div className="p-4 flex items-start gap-3 flex-shrink-0" style={{ borderBottom: "1px solid rgba(255,255,255,0.07)" }}>
<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 mt-0.5" style={{ color: "#adc7ff" }}>{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 flex-shrink-0" style={{ borderTop: "1px solid rgba(255,255,255,0.07)" }}>
<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" style={{ color: "#adc7ff" }} /> : <ArrowDown className="w-3 h-3" style={{ color: "#adc7ff" }} />;
}
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 p-6 overflow-hidden" style={{ background: "#161c21", border: "1px solid rgba(255,255,255,0.05)" }}>
<div className="absolute -top-24 -right-24 w-64 h-64 rounded-full pointer-events-none" style={{ background: "rgba(173,199,255,0.08)", filter: "blur(80px)" }} />
<div className="relative flex items-center justify-between">
<div>
<div className="flex items-center gap-2 text-sm mb-2" style={{ color: "#adc7ff" }}>
<Database className="w-4 h-4" />
<span style={{ fontFamily: "Inter, sans-serif", fontWeight: 500, letterSpacing: "0.05em", textTransform: "uppercase", fontSize: 11 }}>Zentrale Datenbank</span>
</div>
<h1 className="text-2xl font-extrabold" style={{ fontFamily: "Manrope, sans-serif", color: "#dce3ea" }}>Leadspeicher</h1>
<p className="mt-1 text-sm" style={{ color: "#8b909f" }}>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 text-sm transition-all"
style={{ border: "1px solid rgba(173,199,255,0.2)", background: "rgba(173,199,255,0.08)", color: "#adc7ff" }}
>
<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 rounded-lg shadow-2xl p-1 min-w-[230px]" style={{ background: "#1a2025", border: "1px solid rgba(255,255,255,0.08)" }}>
{([
["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: "#adc7ff", accent: "rgba(173,199,255,0.15)" },
{ label: "Neu / Unbearbeitet", value: stats.new, color: "#adc7ff", accent: "rgba(173,199,255,0.1)" },
{ label: "Kontaktiert", value: stats.contacted, color: "#a0d82c", accent: "rgba(160,216,44,0.1)" },
{ label: "Mit E-Mail", value: stats.withEmail, color: "#ffb77b", accent: "rgba(255,183,123,0.1)" },
].map(({ label, value, color, accent }) => (
<div key={label} className="rounded-xl p-4" style={{ background: "#161c21", border: "1px solid rgba(255,255,255,0.05)" }}>
<p className="text-xs font-medium uppercase tracking-wider mb-3" style={{ color: "#8b909f", fontFamily: "Inter, sans-serif", letterSpacing: "0.08em" }}>{label}</p>
<div className="flex items-end justify-between">
<p className="text-3xl font-extrabold" style={{ fontFamily: "Manrope, sans-serif", color: "#dce3ea" }}>{value.toLocaleString()}</p>
<div className="rounded-lg p-2" style={{ background: accent }}>
<Sparkline data={stats.dailyCounts.map(d => d.count)} color={color} />
</div>
</div>
</div>
))}
</div>
)}
{/* Quick SERP */}
<div className="rounded-xl overflow-hidden" style={{ background: "#161c21", border: "1px solid rgba(255,255,255,0.05)" }}>
<button
onClick={() => setSerpOpen(!serpOpen)}
className="w-full flex items-center justify-between px-5 py-3.5 text-sm font-medium transition-colors"
style={{ color: "#c1c6d6", fontFamily: "Inter, sans-serif" }}
>
<span className="flex items-center gap-2" style={{ color: "#adc7ff" }}><Search className="w-4 h-4" /> Schnell neue Suche</span>
{serpOpen ? <ChevronUp className="w-4 h-4" style={{ color: "#8b909f" }} /> : <ChevronDown className="w-4 h-4" style={{ color: "#8b909f" }} />}
</button>
{serpOpen && (
<div className="px-5 pb-5 pt-4 space-y-4" style={{ borderTop: "1px solid rgba(255,255,255,0.05)" }}>
<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="text-white placeholder:text-gray-600"
style={{ background: "#080f13", borderColor: "rgba(255,255,255,0.08)" }}
/>
</div>
<Select value={serpCount} onValueChange={v => setSerpCount(v ?? "25")}>
<SelectTrigger className="text-white" style={{ background: "#080f13", borderColor: "rgba(255,255,255,0.08)" }}>
<SelectValue />
</SelectTrigger>
<SelectContent style={{ background: "#1a2025", borderColor: "rgba(255,255,255,0.08)" }}>
{["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 cursor-pointer" style={{ color: "#8b909f" }}>
<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="px-6 py-2 rounded-xl font-bold text-sm transition-all active:scale-95 disabled:opacity-50"
style={{ fontFamily: "Manrope, sans-serif", background: "linear-gradient(135deg, #adc7ff, #1a73e8)", color: "#002e68" }}
>
{serpRunning ? "Suche läuft..." : "SERP Capture starten"}
</button>
</div>
</div>
)}
</div>
{/* Filter Bar */}
<div className="rounded-xl p-4 space-y-3" style={{ background: "#161c21", border: "1px solid rgba(255,255,255,0.05)" }}>
<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" style={{ color: "#8b909f" }} />
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Domain, Firma, Name, E-Mail suchen..."
className="w-full rounded-lg pl-8 pr-3 py-1.5 text-sm text-white outline-none"
style={{ background: "#080f13", border: "1px solid rgba(255,255,255,0.08)" }}
/>
</div>
{/* Has Email toggle */}
<div className="flex gap-1 rounded-lg p-1" style={{ background: "#080f13", border: "1px solid rgba(255,255,255,0.08)" }}>
{[["", "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"
style={filterHasEmail === v
? { background: "rgba(173,199,255,0.15)", color: "#adc7ff" }
: { color: "#8b909f" }}>
{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 transition-all"
style={filterContacted
? { background: "rgba(160,216,44,0.15)", color: "#a0d82c", border: "1px solid rgba(160,216,44,0.3)" }
: { background: "#080f13", color: "#8b909f", border: "1px solid rgba(255,255,255,0.08)" }}
>
Kontaktiert
</button>
{/* Favoriten toggle */}
<button
onClick={() => { setFilterFavorite(v => !v); setPage(1); }}
className="px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all"
style={filterFavorite
? { background: "rgba(255,183,123,0.15)", color: "#ffb77b", border: "1px solid rgba(255,183,123,0.3)" }
: { background: "#080f13", color: "#8b909f", border: "1px solid rgba(255,255,255,0.08)" }}
>
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 flex items-center gap-1" style={{ color: "#8b909f" }}>
<X className="w-3 h-3" /> Filter zurücksetzen
</button>
)}
<span className="text-xs" style={{ color: "#8b909f" }}>{total.toLocaleString()} Leads</span>
</div>
</div>
</div>
{/* Bulk Actions */}
{selected.size > 0 && (
<div className="flex items-center gap-3 rounded-xl px-4 py-2.5" style={{ background: "rgba(173,199,255,0.07)", border: "1px solid rgba(173,199,255,0.15)" }}>
<span className="text-sm font-medium" style={{ color: "#adc7ff", fontFamily: "Manrope, sans-serif" }}>{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="text-gray-300 text-xs rounded px-2 py-1 w-32 outline-none"
style={{ background: "#1a2025", border: "1px solid rgba(255,255,255,0.08)" }}
/>
<button onClick={() => { if (bulkTag) { bulkAction("tag", bulkTag); setBulkTag(""); } }}
className="text-xs px-2 py-1 rounded text-gray-300"
style={{ background: "#242b30" }}>
<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 text-red-300 border border-red-500/30"
style={{ background: "rgba(255,100,100,0.1)" }}>
<Trash2 className="w-3 h-3" /> Löschen
</button>
<button onClick={() => setSelected(new Set())} className="text-xs px-2" style={{ color: "#8b909f" }}></button>
</div>
</div>
)}
{/* Table */}
<div className="rounded-xl overflow-hidden" style={{ background: "#161c21", border: "1px solid rgba(255,255,255,0.05)" }}>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr style={{ borderBottom: "1px solid rgba(255,255,255,0.05)" }}>
<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" style={{ color: "#adc7ff" }} />
: <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 hover:underline" style={{ color: "#adc7ff" }}>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="cursor-pointer transition-colors"
style={{
borderBottom: "1px solid rgba(255,255,255,0.03)",
background: isSelected ? "rgba(173,199,255,0.07)" : i % 2 === 0 ? "#161c21" : "#1a2025",
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = "#242b30"; }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = i % 2 === 0 ? "#161c21" : "#1a2025"; }}
>
<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" style={{ color: "#adc7ff" }} />
: <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"
style={filterSearchTerms.includes(lead.sourceTerm)
? { background: "rgba(255,183,123,0.15)", color: "#ffb77b", borderColor: "rgba(255,183,123,0.3)" }
: { background: "#1a2025", color: "#8b909f", borderColor: "rgba(255,255,255,0.08)" }}
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" style={{ borderTop: "1px solid rgba(255,255,255,0.05)" }}>
<div className="flex items-center gap-2">
<span className="text-xs" style={{ color: "#8b909f" }}>Zeilen pro Seite:</span>
<select
value={perPage}
onChange={e => { setPerPage(Number(e.target.value)); setPage(1); }}
className="text-gray-300 text-xs rounded px-2 py-1"
style={{ background: "#080f13", border: "1px solid rgba(255,255,255,0.08)" }}
>
{[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 disabled:opacity-30"
style={{ border: "1px solid rgba(255,255,255,0.08)" }}>
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"
style={page === p
? { background: "rgba(173,199,255,0.15)", color: "#adc7ff", border: "1px solid rgba(173,199,255,0.3)" }
: { color: "#8b909f" }}>
{p}
</button>
);
})}
<button disabled={page >= pages} onClick={() => setPage(p => p + 1)}
className="px-2.5 py-1 rounded text-xs text-gray-400 disabled:opacity-30"
style={{ border: "1px solid rgba(255,255,255,0.08)" }}>
Weiter
</button>
</div>
<span className="text-xs" style={{ color: "#8b909f" }}>Seite {page} von {pages}</span>
</div>
)}
</div>
{/* 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>
);
}