921 lines
42 KiB
TypeScript
921 lines
42 KiB
TypeScript
"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;
|
||
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];
|
||
|
||
function copy(text: string, label: string) {
|
||
navigator.clipboard.writeText(text);
|
||
toast.success(`${label} kopiert`);
|
||
}
|
||
|
||
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>
|
||
)}
|
||
|
||
{/* 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">
|
||
{/* 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 aus allen Pipelines 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)],
|
||
] 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"],
|
||
["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={8} className="px-4 py-8 text-center text-gray-500 text-sm">Lädt...</td></tr>
|
||
) : leads.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={8} 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">
|
||
<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">{new Date(lead.capturedAt).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" })}</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>
|
||
{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>
|
||
);
|
||
}
|