From 042fbeb67217fce4996cc25a480421985e02480d Mon Sep 17 00:00:00 2001 From: Timo Uttenweiler Date: Fri, 20 Mar 2026 17:33:12 +0100 Subject: [PATCH] feat: LeadVault - zentrale Lead-Datenbank mit CRM-Funktionen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Prisma-Schema: Lead + LeadEvent Modelle (Migration 20260320) - lib/services/leadVault.ts: sinkLeadsToVault mit Deduplizierung - Auto-Sync: alle 4 Pipelines schreiben Leads in LeadVault - GET /api/leads: Filter, Sortierung, Pagination (Server-side) - PATCH/DELETE /api/leads/[id]: Status, Priorität, Tags, Notizen - POST /api/leads/bulk: Bulk-Aktionen für mehrere Leads - GET /api/leads/stats: Statistiken + 7-Tage-Sparkline - POST /api/leads/quick-serp: SERP-Capture ohne Enrichment - GET /api/leads/export: CSV-Export mit allen Feldern - app/leadvault/page.tsx: vollständige UI mit Stats, Quick SERP, Filter-Leiste, sortierbare Tabelle, Bulk-Aktionen, Side Panel - Sidebar: LeadVault-Eintrag mit Live-Badge (neue Leads) Co-Authored-By: Claude Sonnet 4.6 --- app/api/jobs/airscale-enrich/route.ts | 17 + app/api/jobs/linkedin-enrich/route.ts | 18 + app/api/jobs/maps-enrich/route.ts | 19 + app/api/jobs/serp-enrich/route.ts | 24 + app/api/leads/[id]/route.ts | 70 ++ app/api/leads/bulk/route.ts | 64 ++ app/api/leads/export/route.ts | 93 ++ app/api/leads/quick-serp/route.ts | 71 ++ app/api/leads/route.ts | 87 ++ app/api/leads/stats/route.ts | 42 + app/leadvault/page.tsx | 993 ++++++++++++++++++ components/layout/Sidebar.tsx | 37 +- components/layout/TopBar.tsx | 1 + lib/services/leadVault.ts | 159 +++ .../migration.sql | 56 + prisma/schema.prisma | 54 + 16 files changed, 1800 insertions(+), 5 deletions(-) create mode 100644 app/api/leads/[id]/route.ts create mode 100644 app/api/leads/bulk/route.ts create mode 100644 app/api/leads/export/route.ts create mode 100644 app/api/leads/quick-serp/route.ts create mode 100644 app/api/leads/route.ts create mode 100644 app/api/leads/stats/route.ts create mode 100644 app/leadvault/page.tsx create mode 100644 lib/services/leadVault.ts create mode 100644 prisma/migrations/20260320162355_add_lead_vault/migration.sql diff --git a/app/api/jobs/airscale-enrich/route.ts b/app/api/jobs/airscale-enrich/route.ts index 0a3a6f6..7284a2e 100644 --- a/app/api/jobs/airscale-enrich/route.ts +++ b/app/api/jobs/airscale-enrich/route.ts @@ -3,6 +3,7 @@ import { prisma } from "@/lib/db"; import { getApiKey } from "@/lib/utils/apiKey"; import { cleanDomain } from "@/lib/utils/domains"; import { bulkSearchDomains, type DecisionMakerCategory } from "@/lib/services/anymailfinder"; +import { sinkLeadsToVault } from "@/lib/services/leadVault"; export async function POST(req: NextRequest) { try { @@ -93,6 +94,22 @@ async function runEnrichment( where: { id: jobId }, data: { status: "complete", emailsFound, totalLeads: results.length }, }); + + // Sync to LeadVault + await sinkLeadsToVault( + results.map(r => ({ + domain: r.domain || null, + companyName: domainMap.get(r.domain || "") || null, + contactName: r.person_full_name || null, + contactTitle: r.person_job_title || null, + email: r.email || null, + linkedinUrl: r.person_linkedin_url || null, + emailConfidence: r.valid_email ? 1.0 : r.email_status === "risky" ? 0.5 : 0, + })), + "airscale", + undefined, + jobId, + ); } catch (err) { const message = err instanceof Error ? err.message : String(err); await prisma.job.update({ diff --git a/app/api/jobs/linkedin-enrich/route.ts b/app/api/jobs/linkedin-enrich/route.ts index 6827508..7dfbf28 100644 --- a/app/api/jobs/linkedin-enrich/route.ts +++ b/app/api/jobs/linkedin-enrich/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/db"; import { getApiKey } from "@/lib/utils/apiKey"; +import { sinkLeadsToVault } from "@/lib/services/leadVault"; import { submitBulkPersonSearch, getBulkSearchStatus, @@ -150,6 +151,23 @@ async function runLinkedInEnrich( where: { id: enrichJobId }, data: { status: "complete", emailsFound }, }); + + // Sync final state to LeadVault + const finalResults = await prisma.leadResult.findMany({ where: { jobId: enrichJobId } }); + await sinkLeadsToVault( + finalResults.map(r => ({ + domain: r.domain, + companyName: r.companyName, + contactName: r.contactName, + contactTitle: r.contactTitle, + email: r.email, + linkedinUrl: r.linkedinUrl, + emailConfidence: r.confidence, + })), + "linkedin", + undefined, + enrichJobId, + ); } catch (err) { const message = err instanceof Error ? err.message : String(err); await prisma.job.update({ diff --git a/app/api/jobs/maps-enrich/route.ts b/app/api/jobs/maps-enrich/route.ts index e63cec6..3cd5c57 100644 --- a/app/api/jobs/maps-enrich/route.ts +++ b/app/api/jobs/maps-enrich/route.ts @@ -3,6 +3,7 @@ import { prisma } from "@/lib/db"; import { getApiKey } from "@/lib/utils/apiKey"; import { searchPlacesMultiQuery } from "@/lib/services/googlemaps"; import { bulkSearchDomains, type DecisionMakerCategory } from "@/lib/services/anymailfinder"; +import { sinkLeadsToVault } from "@/lib/services/leadVault"; export async function POST(req: NextRequest) { try { @@ -147,6 +148,24 @@ async function runMapsEnrich( data: { status: "complete", totalLeads: places.length }, }); } + + // Sync to LeadVault + const finalResults = await prisma.leadResult.findMany({ where: { jobId } }); + await sinkLeadsToVault( + finalResults.map(r => ({ + domain: r.domain, + companyName: r.companyName, + contactName: r.contactName, + contactTitle: r.contactTitle, + email: r.email, + linkedinUrl: r.linkedinUrl, + emailConfidence: r.confidence, + phone: (() => { try { return JSON.parse(r.source || "{}").phone ?? null; } catch { return null; } })(), + })), + "maps", + params.queries.join(", "), + jobId, + ); } catch (err) { const message = err instanceof Error ? err.message : String(err); await prisma.job.update({ diff --git a/app/api/jobs/serp-enrich/route.ts b/app/api/jobs/serp-enrich/route.ts index f7c6e56..8a35b65 100644 --- a/app/api/jobs/serp-enrich/route.ts +++ b/app/api/jobs/serp-enrich/route.ts @@ -4,6 +4,7 @@ import { getApiKey } from "@/lib/utils/apiKey"; import { isSocialOrDirectory } from "@/lib/utils/domains"; import { runGoogleSerpScraper, pollRunStatus, fetchDatasetItems } from "@/lib/services/apify"; import { bulkSearchDomains, type DecisionMakerCategory } from "@/lib/services/anymailfinder"; +import { sinkLeadsToVault } from "@/lib/services/leadVault"; export async function POST(req: NextRequest) { try { @@ -137,6 +138,29 @@ async function runSerpEnrich( where: { id: jobId }, data: { status: "complete", emailsFound, totalLeads: enrichResults.length }, }); + + // Sync to LeadVault + await sinkLeadsToVault( + enrichResults.map(r => { + const serpData = serpMap.get(r.domain || ""); + return { + domain: r.domain || null, + companyName: serpData?.title || null, + contactName: r.person_full_name || null, + contactTitle: r.person_job_title || null, + email: r.email || null, + linkedinUrl: r.person_linkedin_url || null, + emailConfidence: r.valid_email ? 1.0 : r.email_status === "risky" ? 0.5 : 0, + serpTitle: serpData?.title || null, + serpSnippet: serpData?.description || null, + serpRank: serpData?.position ?? null, + serpUrl: serpData?.url || null, + }; + }), + "serp", + params.query, + jobId, + ); } catch (err) { const message = err instanceof Error ? err.message : String(err); await prisma.job.update({ diff --git a/app/api/leads/[id]/route.ts b/app/api/leads/[id]/route.ts new file mode 100644 index 0000000..ff57055 --- /dev/null +++ b/app/api/leads/[id]/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; + +export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const body = await req.json() as Record; + + const oldLead = await prisma.lead.findUnique({ where: { id } }); + if (!oldLead) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const allowedFields = [ + "status", "priority", "notes", "tags", "country", "headcount", + "industry", "contactedAt", "companyName", "contactName", "contactTitle", + "email", "phone", "linkedinUrl", "domain", + ]; + + const data: Record = {}; + for (const field of allowedFields) { + if (field in body) data[field] = body[field]; + } + + const updated = await prisma.lead.update({ where: { id }, data }); + + // Track status change events + if (body.status && body.status !== oldLead.status) { + await prisma.leadEvent.create({ + data: { + leadId: id, + event: `Status geändert zu "${body.status}"`, + }, + }); + + // Auto-set contactedAt when marking as contacted + if (body.status === "contacted" && !oldLead.contactedAt) { + await prisma.lead.update({ where: { id }, data: { contactedAt: new Date() } }); + } + } + + return NextResponse.json(updated); + } catch (err) { + console.error("PATCH /api/leads/[id] error:", err); + return NextResponse.json({ error: "Update failed" }, { status: 500 }); + } +} + +export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + await prisma.lead.delete({ where: { id } }); + return NextResponse.json({ deleted: true }); + } catch (err) { + console.error("DELETE /api/leads/[id] error:", err); + return NextResponse.json({ error: "Delete failed" }, { status: 500 }); + } +} + +export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const lead = await prisma.lead.findUnique({ + where: { id }, + include: { events: { orderBy: { at: "asc" } } }, + }); + if (!lead) return NextResponse.json({ error: "Not found" }, { status: 404 }); + return NextResponse.json(lead); + } catch (err) { + return NextResponse.json({ error: "Fetch failed" }, { status: 500 }); + } +} diff --git a/app/api/leads/bulk/route.ts b/app/api/leads/bulk/route.ts new file mode 100644 index 0000000..3a01764 --- /dev/null +++ b/app/api/leads/bulk/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; + +export async function POST(req: NextRequest) { + try { + const body = await req.json() as { + ids: string[]; + action: "status" | "priority" | "tag" | "delete"; + value: string; + }; + const { ids, action, value } = body; + + if (!ids?.length) return NextResponse.json({ error: "No IDs provided" }, { status: 400 }); + + if (action === "delete") { + const { count } = await prisma.lead.deleteMany({ where: { id: { in: ids } } }); + return NextResponse.json({ updated: count }); + } + + if (action === "status") { + const { count } = await prisma.lead.updateMany({ + where: { id: { in: ids } }, + data: { status: value }, + }); + // Create events for status change + for (const id of ids) { + await prisma.leadEvent.create({ + data: { leadId: id, event: `Status geändert zu "${value}" (Bulk)` }, + }).catch(() => {}); // ignore if lead was deleted + } + return NextResponse.json({ updated: count }); + } + + if (action === "priority") { + const { count } = await prisma.lead.updateMany({ + where: { id: { in: ids } }, + data: { priority: value }, + }); + return NextResponse.json({ updated: count }); + } + + if (action === "tag") { + // Add tag to each lead's tags JSON array + const leads = await prisma.lead.findMany({ where: { id: { in: ids } }, select: { id: true, tags: true } }); + let count = 0; + for (const lead of leads) { + const existing: string[] = JSON.parse(lead.tags || "[]"); + if (!existing.includes(value)) { + await prisma.lead.update({ + where: { id: lead.id }, + data: { tags: JSON.stringify([...existing, value]) }, + }); + count++; + } + } + return NextResponse.json({ updated: count }); + } + + return NextResponse.json({ error: "Unknown action" }, { status: 400 }); + } catch (err) { + console.error("POST /api/leads/bulk error:", err); + return NextResponse.json({ error: "Bulk action failed" }, { status: 500 }); + } +} diff --git a/app/api/leads/export/route.ts b/app/api/leads/export/route.ts new file mode 100644 index 0000000..f8258aa --- /dev/null +++ b/app/api/leads/export/route.ts @@ -0,0 +1,93 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { Prisma } from "@prisma/client"; + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const search = searchParams.get("search") || ""; + const statuses = searchParams.getAll("status"); + const sourceTabs = searchParams.getAll("sourceTab"); + const priorities = searchParams.getAll("priority"); + const hasEmail = searchParams.get("hasEmail"); + const emailOnly = searchParams.get("emailOnly") === "true"; + + const where: Prisma.LeadWhereInput = {}; + if (search) { + where.OR = [ + { domain: { contains: search } }, + { companyName: { contains: search } }, + { contactName: { contains: search } }, + { email: { contains: search } }, + ]; + } + if (statuses.length > 0) where.status = { in: statuses }; + if (sourceTabs.length > 0) where.sourceTab = { in: sourceTabs }; + if (priorities.length > 0) where.priority = { in: priorities }; + if (hasEmail === "yes" || emailOnly) where.email = { not: null }; + else if (hasEmail === "no") where.email = null; + + const leads = await prisma.lead.findMany({ + where, + orderBy: { capturedAt: "desc" }, + take: 10000, + }); + + const columns = [ + "status", "priority", "company_name", "domain", "contact_name", + "contact_title", "email", "email_confidence", "linkedin_url", "phone", + "source_tab", "source_term", "tags", "country", "headcount", "industry", + "notes", "captured_at", "contacted_at", "serp_rank", "serp_url", + "serp_title", "serp_snippet", "lead_id", + ]; + + const rows = leads.map(l => ({ + status: l.status, + priority: l.priority, + company_name: l.companyName || "", + domain: l.domain || "", + contact_name: l.contactName || "", + contact_title: l.contactTitle || "", + email: l.email || "", + email_confidence: l.emailConfidence != null ? Math.round(l.emailConfidence * 100) + "%" : "", + linkedin_url: l.linkedinUrl || "", + phone: l.phone || "", + source_tab: l.sourceTab, + source_term: l.sourceTerm || "", + tags: l.tags || "", + country: l.country || "", + headcount: l.headcount || "", + industry: l.industry || "", + notes: l.notes || "", + captured_at: l.capturedAt.toISOString(), + contacted_at: l.contactedAt?.toISOString() || "", + serp_rank: l.serpRank?.toString() || "", + serp_url: l.serpUrl || "", + serp_title: l.serpTitle || "", + serp_snippet: l.serpSnippet || "", + lead_id: l.id, + })); + + const csv = [ + columns.join(","), + ...rows.map(r => + columns.map(c => { + const v = String(r[c as keyof typeof r] || ""); + return v.includes(",") || v.includes('"') || v.includes("\n") + ? `"${v.replace(/"/g, '""')}"` + : v; + }).join(",") + ), + ].join("\n"); + + return new NextResponse(csv, { + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="leadflow-vault-${new Date().toISOString().split("T")[0]}.csv"`, + }, + }); + } catch (err) { + console.error("GET /api/leads/export error:", err); + return NextResponse.json({ error: "Export failed" }, { status: 500 }); + } +} diff --git a/app/api/leads/quick-serp/route.ts b/app/api/leads/quick-serp/route.ts new file mode 100644 index 0000000..84e2201 --- /dev/null +++ b/app/api/leads/quick-serp/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getApiKey } from "@/lib/utils/apiKey"; +import { runGoogleSerpScraper, pollRunStatus, fetchDatasetItems } from "@/lib/services/apify"; +import { isSocialOrDirectory } from "@/lib/utils/domains"; +import { sinkLeadsToVault } from "@/lib/services/leadVault"; + +function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)); } + +export async function POST(req: NextRequest) { + try { + const body = await req.json() as { + query: string; + count: number; + country: string; + language: string; + filterSocial: boolean; + }; + + const { query, count, country, language, filterSocial } = body; + if (!query?.trim()) return NextResponse.json({ error: "Kein Suchbegriff" }, { status: 400 }); + + const apifyToken = await getApiKey("apify"); + if (!apifyToken) return NextResponse.json({ error: "Apify API-Key fehlt" }, { status: 400 }); + + const maxPages = Math.ceil(count / 10); + const runId = await runGoogleSerpScraper(query, maxPages, country, language, apifyToken); + + let runStatus = ""; + let datasetId = ""; + while (runStatus !== "SUCCEEDED" && runStatus !== "FAILED" && runStatus !== "ABORTED") { + await sleep(3000); + const result = await pollRunStatus(runId, apifyToken); + runStatus = result.status; + datasetId = result.defaultDatasetId; + } + + if (runStatus !== "SUCCEEDED") throw new Error(`Apify run ${runStatus}`); + + let serpResults = await fetchDatasetItems(datasetId, apifyToken); + + if (filterSocial) { + serpResults = serpResults.filter(r => !isSocialOrDirectory(r.domain)); + } + + // Deduplicate by domain + const seen = new Set(); + const unique = serpResults.filter(r => { + if (!r.domain || seen.has(r.domain)) return false; + seen.add(r.domain); + return true; + }).slice(0, count); + + const stats = await sinkLeadsToVault( + unique.map((r, i) => ({ + domain: r.domain, + companyName: r.title || null, + serpTitle: r.title || null, + serpSnippet: r.description || null, + serpRank: r.position ?? i + 1, + serpUrl: r.url || null, + })), + "quick-serp", + query, + ); + + return NextResponse.json(stats); + } catch (err) { + console.error("POST /api/leads/quick-serp error:", err); + return NextResponse.json({ error: err instanceof Error ? err.message : "Fehler" }, { status: 500 }); + } +} diff --git a/app/api/leads/route.ts b/app/api/leads/route.ts new file mode 100644 index 0000000..c6358c4 --- /dev/null +++ b/app/api/leads/route.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { Prisma } from "@prisma/client"; + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + + const page = Math.max(1, Number(searchParams.get("page") || 1)); + const perPage = Math.min(100, Math.max(10, Number(searchParams.get("perPage") || 50))); + const search = searchParams.get("search") || ""; + const sortBy = searchParams.get("sortBy") || "capturedAt"; + const sortDir = (searchParams.get("sortDir") || "desc") as "asc" | "desc"; + const hasEmail = searchParams.get("hasEmail"); // "yes" | "no" | null + const capturedFrom = searchParams.get("capturedFrom"); + const capturedTo = searchParams.get("capturedTo"); + + const statuses = searchParams.getAll("status"); + const sourceTabs = searchParams.getAll("sourceTab"); + const priorities = searchParams.getAll("priority"); + const tags = searchParams.getAll("tags"); + const searchTerm = searchParams.get("searchTerm") || ""; + + const where: Prisma.LeadWhereInput = {}; + + if (search) { + where.OR = [ + { domain: { contains: search } }, + { companyName: { contains: search } }, + { contactName: { contains: search } }, + { email: { contains: search } }, + { notes: { contains: search } }, + ]; + } + + if (statuses.length > 0) where.status = { in: statuses }; + if (sourceTabs.length > 0) where.sourceTab = { in: sourceTabs }; + if (priorities.length > 0) where.priority = { in: priorities }; + + if (hasEmail === "yes") where.email = { not: null }; + else if (hasEmail === "no") where.email = null; + + if (capturedFrom || capturedTo) { + where.capturedAt = {}; + if (capturedFrom) where.capturedAt.gte = new Date(capturedFrom); + if (capturedTo) where.capturedAt.lte = new Date(capturedTo); + } + + if (searchTerm) { + where.sourceTerm = { contains: searchTerm }; + } + + if (tags.length > 0) { + // SQLite JSON contains — search for each tag in the JSON string + where.AND = tags.map(tag => ({ + tags: { contains: tag }, + })); + } + + const validSortFields: Record = { + capturedAt: true, status: true, priority: true, companyName: true, + domain: true, email: true, contactName: true, + }; + const orderByField = validSortFields[sortBy] ? sortBy : "capturedAt"; + + const [total, leads] = await Promise.all([ + prisma.lead.count({ where }), + prisma.lead.findMany({ + where, + orderBy: { [orderByField]: sortDir }, + skip: (page - 1) * perPage, + take: perPage, + }), + ]); + + return NextResponse.json({ + leads, + total, + page, + pages: Math.ceil(total / perPage), + perPage, + }); + } catch (err) { + console.error("GET /api/leads error:", err); + return NextResponse.json({ error: "Failed to fetch leads" }, { status: 500 }); + } +} diff --git a/app/api/leads/stats/route.ts b/app/api/leads/stats/route.ts new file mode 100644 index 0000000..33e43ae --- /dev/null +++ b/app/api/leads/stats/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; + +export async function GET() { + try { + const [total, newLeads, contacted, withEmail] = await Promise.all([ + prisma.lead.count(), + prisma.lead.count({ where: { status: "new" } }), + prisma.lead.count({ where: { status: { in: ["contacted", "in_progress"] } } }), + prisma.lead.count({ where: { email: { not: null } } }), + ]); + + // Daily counts for last 7 days + const sevenDaysAgo = new Date(); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 6); + sevenDaysAgo.setHours(0, 0, 0, 0); + + const recentLeads = await prisma.lead.findMany({ + where: { capturedAt: { gte: sevenDaysAgo } }, + select: { capturedAt: true }, + }); + + // Build daily counts map + const dailyMap: Record = {}; + for (let i = 0; i < 7; i++) { + const d = new Date(sevenDaysAgo); + d.setDate(d.getDate() + i); + dailyMap[d.toISOString().split("T")[0]] = 0; + } + for (const lead of recentLeads) { + const key = lead.capturedAt.toISOString().split("T")[0]; + if (key in dailyMap) dailyMap[key]++; + } + + const dailyCounts = Object.entries(dailyMap).map(([date, count]) => ({ date, count })); + + return NextResponse.json({ total, new: newLeads, contacted, withEmail, dailyCounts }); + } catch (err) { + console.error("GET /api/leads/stats error:", err); + return NextResponse.json({ error: "Failed" }, { status: 500 }); + } +} diff --git a/app/leadvault/page.tsx b/app/leadvault/page.tsx new file mode 100644 index 0000000..23b3bd0 --- /dev/null +++ b/app/leadvault/page.tsx @@ -0,0 +1,993 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { toast } from "sonner"; +import { + Database, Search, X, ChevronDown, ChevronUp, Trash2, ExternalLink, + Mail, Star, Tag, FileText, ArrowUpDown, ArrowUp, ArrowDown, + CheckSquare, Square, Download, Plus, RefreshCw, Phone +} from "lucide-react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface Lead { + id: string; + domain: string | null; + companyName: string | null; + contactName: string | null; + contactTitle: string | null; + email: string | null; + linkedinUrl: string | null; + phone: string | null; + sourceTab: string; + sourceTerm: string | null; + sourceJobId: string | null; + serpTitle: string | null; + serpSnippet: string | null; + serpRank: number | null; + serpUrl: string | null; + emailConfidence: number | null; + status: string; + priority: string; + notes: string | null; + tags: string | null; + country: string | null; + headcount: string | null; + industry: string | null; + capturedAt: string; + contactedAt: string | null; + events?: Array<{ id: string; event: string; at: string }>; +} + +interface Stats { + total: number; + new: number; + contacted: number; + withEmail: number; + dailyCounts: Array<{ date: string; count: number }>; +} + +// ─── Constants ───────────────────────────────────────────────────────────────── + +const STATUS_CONFIG: Record = { + 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 PRIORITY_CONFIG: Record = { + high: { label: "Hoch", icon: "↑", color: "text-red-400" }, + normal: { label: "Normal", icon: "→", color: "text-gray-400" }, + low: { label: "Niedrig",icon: "↓", color: "text-blue-400" }, +}; + +const TAG_PRESETS = [ + "solar", "b2b", "deutschland", "founder", "kmu", "warmkontakt", + "kaltakquise", "follow-up", "interessiert", "angebot-gesendet", +]; + +function tagColor(tag: string): string { + let hash = 0; + for (let i = 0; i < tag.length; i++) hash = tag.charCodeAt(i) + ((hash << 5) - hash); + const colors = [ + "bg-blue-500/20 text-blue-300 border-blue-500/30", + "bg-purple-500/20 text-purple-300 border-purple-500/30", + "bg-green-500/20 text-green-300 border-green-500/30", + "bg-amber-500/20 text-amber-300 border-amber-500/30", + "bg-pink-500/20 text-pink-300 border-pink-500/30", + "bg-teal-500/20 text-teal-300 border-teal-500/30", + "bg-orange-500/20 text-orange-300 border-orange-500/30", + ]; + return colors[Math.abs(hash) % colors.length]; +} + +function relativeTime(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return "gerade eben"; + if (mins < 60) return `vor ${mins} Min.`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `vor ${hrs} Std.`; + const days = Math.floor(hrs / 24); + if (days < 30) return `vor ${days} Tagen`; + return new Date(dateStr).toLocaleDateString("de-DE"); +} + +// ─── Sparkline ──────────────────────────────────────────────────────────────── + +function Sparkline({ data, color }: { data: number[]; color: string }) { + if (!data.length) return null; + const max = Math.max(...data, 1); + const w = 60, h = 24; + const points = data.map((v, i) => `${(i / (data.length - 1)) * w},${h - (v / max) * h}`).join(" "); + return ( + + + + ); +} + +// ─── Status Badge ───────────────────────────────────────────────────────────── + +function StatusBadge({ status, onChange }: { status: string; onChange: (s: string) => void }) { + const [open, setOpen] = useState(false); + const cfg = STATUS_CONFIG[status] || STATUS_CONFIG.new; + return ( +
+ + {open && ( +
+ {Object.entries(STATUS_CONFIG).map(([key, c]) => ( + + ))} +
+ )} +
+ ); +} + +// ─── Side Panel ─────────────────────────────────────────────────────────────── + +function SidePanel({ lead, onClose, onUpdate }: { + lead: Lead; + onClose: () => void; + onUpdate: (updated: Partial) => void; +}) { + const [notes, setNotes] = useState(lead.notes || ""); + const [tagInput, setTagInput] = useState(""); + const [fullLead, setFullLead] = useState(lead); + const notesTimer = useRef | null>(null); + + useEffect(() => { + // Load full lead with events + fetch(`/api/leads/${lead.id}`).then(r => r.json()).then(setFullLead).catch(() => {}); + }, [lead.id]); + + const tags: string[] = JSON.parse(fullLead.tags || "[]"); + + async function patch(data: Partial) { + const res = await fetch(`/api/leads/${lead.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (res.ok) { + const updated = await res.json() as Lead; + setFullLead(prev => ({ ...prev, ...updated })); + onUpdate(updated); + } + } + + function handleNotesChange(v: string) { + setNotes(v); + if (notesTimer.current) clearTimeout(notesTimer.current); + notesTimer.current = setTimeout(() => patch({ notes: v }), 500); + } + + function addTag(tag: string) { + const trimmed = tag.trim().toLowerCase(); + if (!trimmed || tags.includes(trimmed)) return; + const newTags = [...tags, trimmed]; + patch({ tags: JSON.stringify(newTags) }); + setTagInput(""); + } + + function removeTag(tag: string) { + patch({ tags: JSON.stringify(tags.filter(t => t !== tag)) }); + } + + const conf = fullLead.emailConfidence; + const confColor = conf == null ? "text-gray-500" : conf >= 0.8 ? "text-green-400" : conf >= 0.5 ? "text-yellow-400" : "text-red-400"; + + return ( +
+
+
e.stopPropagation()} + > + + + {/* Header */} +
+
+

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

+

{fullLead.domain}

+
+ +
+ +
+ {/* Status + Priority */} +
+ patch({ status: s })} /> +
+ {Object.entries(PRIORITY_CONFIG).map(([key, p]) => ( + + ))} +
+
+ + {/* Contact Info */} +
+

Kontakt

+ {fullLead.contactName &&

{fullLead.contactName}{fullLead.contactTitle && · {fullLead.contactTitle}}

} + {fullLead.email && ( +
+ + {fullLead.email} + + {conf != null && {Math.round(conf * 100)}%} +
+ )} + {fullLead.phone && ( +
+ + {fullLead.phone} +
+ )} + {fullLead.linkedinUrl && ( + + LinkedIn + + )} +
+ + {/* Source Info */} +
+

Quelle

+

+ {SOURCE_CONFIG[fullLead.sourceTab]?.icon} {SOURCE_CONFIG[fullLead.sourceTab]?.label || fullLead.sourceTab} +

+ {fullLead.sourceTerm &&

Suche: {fullLead.sourceTerm}

} +

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

+ {fullLead.serpRank &&

SERP Rang: #{fullLead.serpRank}

} + {fullLead.serpUrl && URL öffnen} +
+ + {/* Tags */} +
+

Tags

+
+ {tags.map(tag => ( + + {tag} + + + ))} +
+
+ {TAG_PRESETS.filter(t => !tags.includes(t)).slice(0, 5).map(t => ( + + ))} +
+
+ setTagInput(e.target.value)} + onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); addTag(tagInput); } }} + placeholder="Tag hinzufügen..." + className="flex-1 bg-[#0d0d18] border border-[#2e2e3e] rounded px-2 py-1 text-xs text-white outline-none focus:border-purple-500" + /> + +
+
+ + {/* Notes */} +
+

Notizen

+