From df90477bef8fcdf1c1b47d33c411b2d875fd87d6 Mon Sep 17 00:00:00 2001 From: Timo Uttenweiler Date: Fri, 27 Mar 2026 16:59:59 +0100 Subject: [PATCH] Use admin leadspeicher UI in customer branch, fix Topbar import Co-Authored-By: Claude Sonnet 4.6 --- app/layout.tsx | 2 +- app/leadspeicher/page.tsx | 1119 ++++++++++++++++++++++++++++--------- 2 files changed, 853 insertions(+), 268 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index 3e54b3b..a614bc2 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,6 @@ import type { Metadata } from "next"; import "./globals.css"; -import { Topbar } from "@/components/layout/Topbar"; +import { Topbar } from "@/components/layout/TopBar"; import { Toaster } from "@/components/ui/sonner"; export const metadata: Metadata = { diff --git a/app/leadspeicher/page.tsx b/app/leadspeicher/page.tsx index 9e5a4c9..cb19685 100644 --- a/app/leadspeicher/page.tsx +++ b/app/leadspeicher/page.tsx @@ -1,335 +1,920 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; -import { useRouter } from "next/navigation"; +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 { LeadsToolbar } from "@/components/leadspeicher/LeadsToolbar"; -import { LeadsTable } from "@/components/leadspeicher/LeadsTable"; -import type { Lead } from "@/components/leadspeicher/LeadsTable"; -import { BulkActionBar } from "@/components/leadspeicher/BulkActionBar"; +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"; -interface LeadsResponse { - leads: Lead[]; - total: number; - page: number; - pages: number; - perPage: number; +// ─── 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 StatsResponse { +interface Stats { total: number; new: number; + contacted: number; withEmail: number; + dailyCounts: Array<{ date: string; count: number }>; } -export default function LeadspeicherPage() { - const router = useRouter(); +// ─── Constants ───────────────────────────────────────────────────────────────── + +const STATUS_CONFIG: Record = { + 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 = { + 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 ( + + + + ); +} + +// ─── Side Panel ─────────────────────────────────────────────────────────────── + +function SidePanel({ lead, onClose, onUpdate, onDelete }: { + lead: Lead; + onClose: () => void; + onUpdate: (updated: Partial) => 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 ( +
+
+
e.stopPropagation()} + > + + + {/* Header */} +
+
+

{lead.companyName || lead.domain || "Unbekannt"}

+ {lead.domain && ( + + {lead.domain} + + )} + {lead.industry &&

{lead.industry}

} +
+ +
+ + {/* Scrollable body */} +
+ + {/* Description */} + {lead.description && ( +
+

Beschreibung

+

{lead.description}

+
+ )} + + {/* Contact */} + {(lead.contactName || lead.email || lead.phone || lead.address || lead.linkedinUrl) && ( +
+

Kontakt

+
+ {lead.contactName && ( +
+ + {lead.contactName} + {lead.contactTitle && · {lead.contactTitle}} + +
+ )} + {lead.email && ( +
+ + {lead.email} + +
+ )} + {lead.phone && ( +
+ + {lead.phone} + +
+ )} + {lead.address && ( +
+ + {lead.address} + + + +
+ )} + {lead.linkedinUrl && ( + + )} +
+
+ )} + + {/* Company info */} + {(lead.headcount || lead.country) && ( +
+

Unternehmen

+
+ {lead.headcount && 👥 {lead.headcount} Mitarbeiter} + {lead.country && 🌍 {lead.country}} +
+
+ )} + + {/* Source */} +
+

Quelle

+
+

{src?.icon} {src?.label || lead.sourceTab}

+ {lead.sourceTerm && ( +

Suche: "{lead.sourceTerm}"

+ )} +

Erfasst: {new Date(lead.capturedAt).toLocaleString("de-DE")}

+
+
+ + {/* Tags (read-only) */} + {tags.length > 0 && ( +
+

Tags

+
+ {tags.map(tag => ( + {tag} + ))} +
+
+ )} +
+ + {/* Delete */} +
+ +
+
+
+ ); +} + +// ─── Main Page ───────────────────────────────────────────────────────────────── + +export default function LeadVaultPage() { + // Stats + const [stats, setStats] = useState(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([]); + const [filterSource, setFilterSource] = useState([]); + const [filterHasEmail, setFilterHasEmail] = useState(""); + const [filterContacted, setFilterContacted] = useState(false); + const [filterFavorite, setFilterFavorite] = useState(false); + const [filterSearchTerms, setFilterSearchTerms] = useState([]); + const [availableSearchTerms, setAvailableSearchTerms] = useState([]); + + // Table const [leads, setLeads] = useState([]); const [total, setTotal] = useState(0); - const [pages, setPages] = useState(1); const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(50); - const [search, setSearch] = useState(""); - const [status, setStatus] = useState(""); - const [loading, setLoading] = useState(true); + 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>(new Set()); - const [stats, setStats] = useState({ total: 0, new: 0, withEmail: 0 }); - const fetchRef = useRef(0); + // Side panel + const [panelLead, setPanelLead] = useState(null); - const fetchLeads = useCallback(async () => { - const id = ++fetchRef.current; + // 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), + page: String(page), perPage: String(perPage), + search: debouncedSearch, sortBy, sortDir, }); - if (search) params.set("search", search); - if (status) params.append("status", status); + 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.toString()}`); - if (!res.ok) throw new Error("fetch failed"); - const data = await res.json() as LeadsResponse; - if (id === fetchRef.current) { + 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); } - } catch { - // silent } finally { - if (id === fetchRef.current) setLoading(false); + setLoading(false); } - }, [page, perPage, search, status]); + }, [page, perPage, debouncedSearch, sortBy, sortDir, filterStatus, filterSource, filterHasEmail, filterContacted, filterFavorite, filterSearchTerms]); - const fetchStats = useCallback(async () => { - try { - const res = await fetch("/api/leads/stats"); - const data = await res.json() as StatsResponse; - setStats(data); - } catch { - // silent - } + const loadSearchTerms = useCallback(async () => { + const res = await fetch("/api/leads/search-terms"); + if (res.ok) setAvailableSearchTerms(await res.json() as string[]); }, []); - useEffect(() => { - fetchLeads(); - fetchStats(); - }, [fetchLeads, fetchStats]); + useEffect(() => { loadStats(); const t = setInterval(loadStats, 30000); return () => clearInterval(t); }, [loadStats]); + useEffect(() => { loadLeads(); }, [loadLeads]); + useEffect(() => { loadSearchTerms(); }, [loadSearchTerms]); - function handleLeadUpdate(id: string, updates: Partial) { - setLeads((prev) => - prev.map((l) => (l.id === id ? { ...l, ...updates } : l)) - ); + function toggleSort(field: string) { + if (sortBy === field) setSortDir(d => d === "asc" ? "desc" : "asc"); + else { setSortBy(field); setSortDir("desc"); } + setPage(1); } - function handleToggleSelect(id: string) { - setSelected((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; + 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) { + // 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), }); - } - - function handleToggleAll() { - if (selected.size === leads.length && leads.length > 0) { - setSelected(new Set()); + if (!res.ok) { + toast.error("Update fehlgeschlagen"); + loadLeads(); // Revert } else { - setSelected(new Set(leads.map((l) => l.id))); + loadStats(); } } - async function handleBulkStatus(newStatus: string) { - const ids = Array.from(selected); - // optimistic - setLeads((prev) => - prev.map((l) => (selected.has(l.id) ? { ...l, status: newStatus } : l)) - ); - setSelected(new Set()); + 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 { - await fetch("/api/leads/bulk", { - method: "PATCH", + const res = await fetch("/api/leads/quick-serp", { + method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ids, status: newStatus }), + body: JSON.stringify({ query: serpQuery, count: Number(serpCount), country: serpCountry, language: serpLanguage, filterSocial: serpFilter }), }); - } catch { - toast.error("Status-Update fehlgeschlagen"); - fetchLeads(); + 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 handleBulkDelete() { - if (!confirm(`${selected.size} Lead(s) wirklich löschen?`)) return; - const ids = Array.from(selected); - setLeads((prev) => prev.filter((l) => !selected.has(l.id))); - setSelected(new Set()); - try { - await Promise.all( - ids.map((id) => - fetch(`/api/leads/${id}`, { method: "DELETE" }) - ) - ); - fetchStats(); - } catch { - toast.error("Löschen fehlgeschlagen"); - fetchLeads(); + 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(); } } - const hasFilter = search !== "" || status !== ""; - const withEmailCount = stats.withEmail; - const withEmailPct = stats.total > 0 ? Math.round((withEmailCount / stats.total) * 100) : 0; + 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"); + } - const exportParams: Record = {}; - if (search) exportParams.search = search; - if (status) exportParams.status = status; + function SortIcon({ field }: { field: string }) { + if (sortBy !== field) return ; + return sortDir === "asc" ? : ; + } + + const allSelected = leads.length > 0 && leads.every(l => selected.has(l.id)); return ( -
- {/* Toolbar */} - { setSearch(v); setPage(1); }} - onStatusChange={(v) => { setStatus(v); setPage(1); }} - exportParams={exportParams} - /> - - {/* Table area */} -
- {loading && leads.length === 0 ? ( - // Loading skeleton -
- Wird geladen… +
+ {/* Header */} +
+
+
+
+
+ + Zentrale Datenbank +
+

🗄️ Leadspeicher

+

Alle Leads aus allen Pipelines an einem Ort.

- ) : leads.length === 0 ? ( - // Empty state - hasFilter ? ( -
-
🔍
-
- Keine Leads gefunden -
-
- Versuche andere Filtereinstellungen. -
- -
- ) : ( -
-
- - - - - - - - -
-
- Noch keine Leads gespeichert -
-
- Starte eine Suche — die Ergebnisse erscheinen hier automatisch. -
- -
- ) - ) : ( - 0} - onLeadUpdate={handleLeadUpdate} - /> - )} +
+ +
+
- {/* Footer */} - {(total > 0 || leads.length > 0) && ( -
+
setExportOpen(false)} /> +
+ {([ + ["Aktuelle Ansicht", () => exportFile("xlsx")], + ["Nur mit E-Mail", () => exportFile("xlsx", true)], + ] as [string, () => void][]).map(([label, fn]) => ( + + ))} +
+ + )} + + {/* Stats */} + {stats && ( +
+ {[ + { 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 }) => ( + +

{label}

+
+

{value.toLocaleString()}

+ d.count)} color={color} /> +
+
+ ))} +
+ )} + + {/* Quick SERP */} + + + {serpOpen && ( +
+
+
+ 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" + /> +
+ +
+
+ + +
+
+ )} +
+ + {/* Filter Bar */} + +
+ {/* Search */} +
+ + 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" + />
- {/* Pagination */} -
- - -
- - - Seite {page} von {pages} - -
+ + {/* Kontaktiert toggle */} + + + {/* Favoriten toggle */} + + + {/* Clear + count */} +
+ {(search || filterHasEmail || filterContacted || filterFavorite || filterSearchTerms.length) && ( + + )} + {total.toLocaleString()} Leads +
+
+ + + + {/* Bulk Actions */} + {selected.size > 0 && ( +
+ {selected.size} ausgewählt +
+
+ 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" + /> +
+ +
)} - {/* Bulk action bar */} - + {/* Table */} + +
+ + + + + {[ + ["companyName", "Unternehmen"], + ["contactName", "Kontakt"], + ["phone", "Telefon"], + ["email", "E-Mail"], + ["sourceTerm", "Suchbegriff"], + ["sourceTab", "Quelle"], + ["capturedAt", "Erfasst"], + ].map(([field, label]) => ( + + ))} + + + + + + {loading && !leads.length ? ( + + ) : leads.length === 0 ? ( + + + + ) : 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 ( + setPanelLead(lead)} + className={`border-b border-[#1a1a28] cursor-pointer transition-colors hover:bg-[#1a1a28] ${ + isSelected ? "bg-[#1a1a2e]" : i % 2 === 0 ? "bg-[#111118]" : "bg-[#0f0f16]" + }`} + > + + + + + + + + + + + + ); + })} + +
+ + + + TagsAktionen
Lädt...
+ +

+ {(search || filterSource.length || filterContacted || filterFavorite) + ? "Keine Leads für diese Filter." + : "Noch keine Leads. Pipeline ausführen oder Quick SERP nutzen."} +

+ {search && ( + + )} +
e.stopPropagation()}> + + +

{lead.companyName || "–"}

+

{lead.domain}

+ {(lead.description || lead.industry) && ( +

+ {lead.industry ? `${lead.industry}${lead.description ? " · " + lead.description : ""}` : lead.description} +

+ )} +
+

{lead.contactName || "–"}

+ {lead.contactTitle &&

{lead.contactTitle}

} +
+ {lead.phone ? ( + e.stopPropagation()} + className="text-xs text-gray-300 hover:text-white whitespace-nowrap"> + {lead.phone} + + ) : lead.address ? ( +

{lead.address}

+ ) : ( + + )} + {lead.phone && lead.address && ( +

{lead.address}

+ )} +
e.stopPropagation()}> + {lead.email ? ( + + ) : ( + + )} + + {lead.sourceTerm ? ( + + ) : ( + + )} + + {src?.icon} {src?.label || lead.sourceTab} + + {new Date(lead.capturedAt).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" })} + +
+ {tags.slice(0, 2).map(tag => ( + {tag} + ))} + {tags.length > 2 && +{tags.length - 2}} +
+
e.stopPropagation()}> +
+ + + {lead.domain && ( + + + + )} + +
+
+
+ + {/* Pagination */} + {pages > 1 && ( +
+
+ Zeilen pro Seite: + +
+
+ + {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 ( + + ); + })} + +
+ Seite {page} von {pages} +
+ )} +
+ + {/* Side Panel */} + {panelLead && ( + 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); }} + /> + )}
); }