feat: LeadVault - zentrale Lead-Datenbank mit CRM-Funktionen

- 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 <noreply@anthropic.com>
This commit is contained in:
Timo Uttenweiler
2026-03-20 17:33:12 +01:00
parent 6711633a5d
commit 042fbeb672
16 changed files with 1800 additions and 5 deletions

View File

@@ -3,6 +3,7 @@ import { prisma } from "@/lib/db";
import { getApiKey } from "@/lib/utils/apiKey"; import { getApiKey } from "@/lib/utils/apiKey";
import { cleanDomain } from "@/lib/utils/domains"; import { cleanDomain } from "@/lib/utils/domains";
import { bulkSearchDomains, type DecisionMakerCategory } from "@/lib/services/anymailfinder"; import { bulkSearchDomains, type DecisionMakerCategory } from "@/lib/services/anymailfinder";
import { sinkLeadsToVault } from "@/lib/services/leadVault";
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
@@ -93,6 +94,22 @@ async function runEnrichment(
where: { id: jobId }, where: { id: jobId },
data: { status: "complete", emailsFound, totalLeads: results.length }, 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) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
await prisma.job.update({ await prisma.job.update({

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { getApiKey } from "@/lib/utils/apiKey"; import { getApiKey } from "@/lib/utils/apiKey";
import { sinkLeadsToVault } from "@/lib/services/leadVault";
import { import {
submitBulkPersonSearch, submitBulkPersonSearch,
getBulkSearchStatus, getBulkSearchStatus,
@@ -150,6 +151,23 @@ async function runLinkedInEnrich(
where: { id: enrichJobId }, where: { id: enrichJobId },
data: { status: "complete", emailsFound }, 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) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
await prisma.job.update({ await prisma.job.update({

View File

@@ -3,6 +3,7 @@ import { prisma } from "@/lib/db";
import { getApiKey } from "@/lib/utils/apiKey"; import { getApiKey } from "@/lib/utils/apiKey";
import { searchPlacesMultiQuery } from "@/lib/services/googlemaps"; import { searchPlacesMultiQuery } from "@/lib/services/googlemaps";
import { bulkSearchDomains, type DecisionMakerCategory } from "@/lib/services/anymailfinder"; import { bulkSearchDomains, type DecisionMakerCategory } from "@/lib/services/anymailfinder";
import { sinkLeadsToVault } from "@/lib/services/leadVault";
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
@@ -147,6 +148,24 @@ async function runMapsEnrich(
data: { status: "complete", totalLeads: places.length }, 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) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
await prisma.job.update({ await prisma.job.update({

View File

@@ -4,6 +4,7 @@ import { getApiKey } from "@/lib/utils/apiKey";
import { isSocialOrDirectory } from "@/lib/utils/domains"; import { isSocialOrDirectory } from "@/lib/utils/domains";
import { runGoogleSerpScraper, pollRunStatus, fetchDatasetItems } from "@/lib/services/apify"; import { runGoogleSerpScraper, pollRunStatus, fetchDatasetItems } from "@/lib/services/apify";
import { bulkSearchDomains, type DecisionMakerCategory } from "@/lib/services/anymailfinder"; import { bulkSearchDomains, type DecisionMakerCategory } from "@/lib/services/anymailfinder";
import { sinkLeadsToVault } from "@/lib/services/leadVault";
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
@@ -137,6 +138,29 @@ async function runSerpEnrich(
where: { id: jobId }, where: { id: jobId },
data: { status: "complete", emailsFound, totalLeads: enrichResults.length }, 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) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
await prisma.job.update({ await prisma.job.update({

View File

@@ -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<string, unknown>;
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<string, unknown> = {};
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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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<string>();
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 });
}
}

87
app/api/leads/route.ts Normal file
View File

@@ -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<string, boolean> = {
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 });
}
}

View File

@@ -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<string, number> = {};
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 });
}
}

993
app/leadvault/page.tsx Normal file
View File

@@ -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<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 PRIORITY_CONFIG: Record<string, { label: string; icon: string; color: string }> = {
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 (
<svg width={w} height={h} className="opacity-70">
<polyline points={points} fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
// ─── 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 (
<div className="relative">
<button
onClick={() => setOpen(!open)}
className={`px-2 py-0.5 rounded-full text-xs font-medium border ${cfg.bg} ${cfg.color} whitespace-nowrap`}
>
{cfg.label}
</button>
{open && (
<div className="absolute z-50 top-7 left-0 bg-[#1a1a28] border border-[#2e2e3e] rounded-lg shadow-xl p-1 min-w-[160px]">
{Object.entries(STATUS_CONFIG).map(([key, c]) => (
<button
key={key}
onClick={() => { onChange(key); setOpen(false); }}
className={`w-full text-left px-3 py-1.5 rounded text-xs hover:bg-[#2e2e3e] ${c.color} ${status === key ? "font-bold" : ""}`}
>
{c.label}
</button>
))}
</div>
)}
</div>
);
}
// ─── Side Panel ───────────────────────────────────────────────────────────────
function SidePanel({ lead, onClose, onUpdate }: {
lead: Lead;
onClose: () => void;
onUpdate: (updated: Partial<Lead>) => void;
}) {
const [notes, setNotes] = useState(lead.notes || "");
const [tagInput, setTagInput] = useState("");
const [fullLead, setFullLead] = useState<Lead>(lead);
const notesTimer = useRef<ReturnType<typeof setTimeout> | 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<Lead>) {
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 (
<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-md h-full bg-[#0e0e1a] border-l border-[#1e1e2e] overflow-y-auto flex flex-col"
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="sticky top-0 bg-[#0e0e1a] border-b border-[#1e1e2e] p-4 flex items-start gap-3">
<div className="flex-1 min-w-0">
<h2 className="text-base font-bold text-white truncate">{fullLead.companyName || fullLead.domain || "Unbekannt"}</h2>
<p className="text-xs text-gray-400">{fullLead.domain}</p>
</div>
<button onClick={onClose} className="text-gray-500 hover:text-white p-1 rounded">
<X className="w-4 h-4" />
</button>
</div>
<div className="p-4 space-y-4 flex-1">
{/* Status + Priority */}
<div className="flex items-center gap-3">
<StatusBadge status={fullLead.status} onChange={s => patch({ status: s })} />
<div className="flex gap-1">
{Object.entries(PRIORITY_CONFIG).map(([key, p]) => (
<button
key={key}
onClick={() => patch({ priority: key })}
className={`px-2 py-0.5 rounded text-xs border transition-all ${
fullLead.priority === key
? "bg-[#2e2e3e] border-[#4e4e6e] " + p.color
: "border-[#1e1e2e] text-gray-600 hover:border-[#2e2e3e]"
}`}
>
{p.icon} {p.label}
</button>
))}
</div>
</div>
{/* Contact Info */}
<div className="bg-[#111118] rounded-lg border border-[#1e1e2e] p-3 space-y-2">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Kontakt</h3>
{fullLead.contactName && <p className="text-sm text-white">{fullLead.contactName}{fullLead.contactTitle && <span className="text-gray-400 ml-1">· {fullLead.contactTitle}</span>}</p>}
{fullLead.email && (
<div className="flex items-center gap-2">
<Mail className="w-3.5 h-3.5 text-gray-500" />
<span className={`text-sm font-mono ${confColor}`}>{fullLead.email}</span>
<button onClick={() => { navigator.clipboard.writeText(fullLead.email!); toast.success("Kopiert"); }} className="text-gray-600 hover:text-white">
<FileText className="w-3 h-3" />
</button>
{conf != null && <span className={`text-xs ${confColor}`}>{Math.round(conf * 100)}%</span>}
</div>
)}
{fullLead.phone && (
<div className="flex items-center gap-2">
<Phone className="w-3.5 h-3.5 text-gray-500" />
<span className="text-sm text-gray-300">{fullLead.phone}</span>
</div>
)}
{fullLead.linkedinUrl && (
<a href={fullLead.linkedinUrl} target="_blank" rel="noreferrer" className="flex items-center gap-1 text-xs text-blue-400 hover:underline">
<ExternalLink className="w-3 h-3" /> LinkedIn
</a>
)}
</div>
{/* Source Info */}
<div className="bg-[#111118] rounded-lg border border-[#1e1e2e] p-3 space-y-1.5">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Quelle</h3>
<p className="text-sm text-gray-300">
{SOURCE_CONFIG[fullLead.sourceTab]?.icon} {SOURCE_CONFIG[fullLead.sourceTab]?.label || fullLead.sourceTab}
</p>
{fullLead.sourceTerm && <p className="text-xs text-gray-500">Suche: <span className="text-gray-300">{fullLead.sourceTerm}</span></p>}
<p className="text-xs text-gray-500">Erfasst: {new Date(fullLead.capturedAt).toLocaleString("de-DE")}</p>
{fullLead.serpRank && <p className="text-xs text-gray-500">SERP Rang: #{fullLead.serpRank}</p>}
{fullLead.serpUrl && <a href={fullLead.serpUrl} target="_blank" rel="noreferrer" className="text-xs text-blue-400 hover:underline flex items-center gap-1"><ExternalLink className="w-3 h-3" />URL öffnen</a>}
</div>
{/* Tags */}
<div className="bg-[#111118] rounded-lg border border-[#1e1e2e] p-3 space-y-2">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Tags</h3>
<div className="flex flex-wrap gap-1.5">
{tags.map(tag => (
<span key={tag} className={`flex items-center gap-1 px-2 py-0.5 rounded-full text-xs border ${tagColor(tag)}`}>
{tag}
<button onClick={() => removeTag(tag)} className="hover:text-white"><X className="w-2.5 h-2.5" /></button>
</span>
))}
</div>
<div className="flex flex-wrap gap-1">
{TAG_PRESETS.filter(t => !tags.includes(t)).slice(0, 5).map(t => (
<button key={t} onClick={() => addTag(t)} className="text-[10px] px-1.5 py-0.5 rounded border border-[#2e2e3e] text-gray-600 hover:text-gray-300 hover:border-[#4e4e6e]">
+ {t}
</button>
))}
</div>
<div className="flex gap-1.5">
<input
value={tagInput}
onChange={e => 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"
/>
<button onClick={() => addTag(tagInput)} className="text-xs px-2 py-1 rounded bg-purple-500/20 text-purple-300 border border-purple-500/30">+</button>
</div>
</div>
{/* Notes */}
<div className="bg-[#111118] rounded-lg border border-[#1e1e2e] p-3 space-y-2">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Notizen</h3>
<textarea
value={notes}
onChange={e => handleNotesChange(e.target.value)}
placeholder="Notizen hier eingeben..."
rows={4}
className="w-full bg-[#0d0d18] border border-[#2e2e3e] rounded px-2 py-1.5 text-sm text-white outline-none focus:border-purple-500 resize-none"
/>
</div>
{/* Timeline */}
{fullLead.events && fullLead.events.length > 0 && (
<div className="bg-[#111118] rounded-lg border border-[#1e1e2e] p-3 space-y-2">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Verlauf</h3>
<div className="space-y-1.5">
<div className="flex items-start gap-2 text-xs text-gray-500">
<span className="mt-0.5">📥</span>
<div>
<span className="text-gray-400">Erfasst</span>
<span className="ml-2">{new Date(fullLead.capturedAt).toLocaleString("de-DE")}</span>
</div>
</div>
{fullLead.events.map(ev => (
<div key={ev.id} className="flex items-start gap-2 text-xs text-gray-500">
<span className="mt-0.5"></span>
<div>
<span className="text-gray-400">{ev.event}</span>
<span className="ml-2">{new Date(ev.at).toLocaleString("de-DE")}</span>
</div>
</div>
))}
</div>
</div>
)}
</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, setSerpCountry] = useState("de");
const [serpLanguage, setSerpLanguage] = useState("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 [filterPriority, setFilterPriority] = useState<string[]>([]);
const [filterHasEmail, setFilterHasEmail] = useState("");
const [filterSearchTerm, setFilterSearchTerm] = useState("");
// 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);
// Bulk
const [bulkStatus, setBulkStatus] = useState("");
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));
filterPriority.forEach(p => params.append("priority", p));
if (filterHasEmail) params.set("hasEmail", filterHasEmail);
if (filterSearchTerm) params.set("searchTerm", filterSearchTerm);
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, filterPriority, filterHasEmail, filterSearchTerm]);
useEffect(() => { loadStats(); const t = setInterval(loadStats, 30000); return () => clearInterval(t); }, [loadStats]);
useEffect(() => { loadLeads(); }, [loadLeads]);
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([]);
setFilterPriority([]); setFilterHasEmail(""); setFilterSearchTerm("");
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();
} 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();
}
}
function exportCSV(emailOnly = false) {
const params = new URLSearchParams({ 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 (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-[1400px]">
{/* 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">🗄 LeadVault</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={() => { loadLeads(); loadStats(); }} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-[#2e2e3e] text-gray-400 hover:text-white hover:border-[#4e4e6e] text-sm transition-all">
<RefreshCw className="w-3.5 h-3.5" /> Aktualisieren
</button>
<div className="relative group">
<button 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 className="absolute right-0 top-9 hidden group-hover:block z-50 bg-[#1a1a28] border border-[#2e2e3e] rounded-lg shadow-xl p-1 min-w-[220px]">
{[
["Aktuelle Ansicht (CSV)", () => exportCSV()],
["Alle Leads (CSV)", () => exportCSV()],
["Nur mit E-Mail (CSV)", () => exportCSV(true)],
].map(([label, fn]) => (
<button key={label as string} onClick={fn as () => void}
className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-[#2e2e3e] rounded">
{label as string}
</button>
))}
</div>
</div>
</div>
</div>
</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" /> Quick SERP Capture</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 className="flex gap-2">
<Select value={serpCountry} onValueChange={v => setSerpCountry(v ?? "de")}>
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#111118] border-[#2e2e3e]">
{[["de","DE"],["at","AT"],["ch","CH"],["us","US"]].map(([v,l]) => (
<SelectItem key={v} value={v} className="text-gray-300">{l}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={serpLanguage} onValueChange={v => setSerpLanguage(v ?? "de")}>
<SelectTrigger className="bg-[#0d0d18] border-[#2e2e3e] text-white flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#111118] border-[#2e2e3e]">
{[["de","Deutsch"],["en","English"]].map(([v,l]) => (
<SelectItem key={v} value={v} className="text-gray-300">{l}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</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>
{/* Clear + count */}
<div className="flex items-center gap-2 ml-auto">
{(search || filterStatus.length || filterSource.length || filterPriority.length || filterHasEmail || filterSearchTerm) && (
<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>
<div className="flex gap-2 flex-wrap">
{/* Status filter */}
<div className="flex gap-1 flex-wrap">
{Object.entries(STATUS_CONFIG).map(([key, c]) => (
<button key={key} onClick={() => toggleFilter(filterStatus, setFilterStatus, key)}
className={`px-2.5 py-1 rounded-full text-xs border transition-all ${filterStatus.includes(key) ? c.bg + " " + c.color : "border-[#2e2e3e] text-gray-600 hover:text-gray-400"}`}>
{c.label}
</button>
))}
</div>
<div className="w-px bg-[#2e2e3e] mx-1" />
{/* Source filter */}
<div className="flex gap-1 flex-wrap">
{Object.entries(SOURCE_CONFIG).map(([key, s]) => (
<button key={key} onClick={() => toggleFilter(filterSource, setFilterSource, key)}
className={`px-2.5 py-1 rounded-full text-xs border transition-all ${filterSource.includes(key) ? "bg-purple-500/20 text-purple-300 border-purple-500/30" : "border-[#2e2e3e] text-gray-600 hover:text-gray-400"}`}>
{s.icon} {s.label}
</button>
))}
</div>
<div className="w-px bg-[#2e2e3e] mx-1" />
{/* Priority filter */}
<div className="flex gap-1">
{Object.entries(PRIORITY_CONFIG).map(([key, p]) => (
<button key={key} onClick={() => toggleFilter(filterPriority, setFilterPriority, key)}
className={`px-2.5 py-1 rounded-full text-xs border transition-all ${filterPriority.includes(key) ? "bg-[#2e2e3e] border-[#4e4e6e] " + p.color : "border-[#2e2e3e] text-gray-600 hover:text-gray-400"}`}>
{p.icon} {p.label}
</button>
))}
</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">
<select
value={bulkStatus}
onChange={e => { setBulkStatus(e.target.value); if (e.target.value) bulkAction("status", e.target.value); }}
className="bg-[#1a1a28] border border-[#2e2e3e] text-gray-300 text-xs rounded px-2 py-1"
>
<option value="">Status ändern...</option>
{Object.entries(STATUS_CONFIG).map(([k, c]) => <option key={k} value={k}>{c.label}</option>)}
</select>
<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>
{[
["status", "Status"],
["priority", "Priorität"],
["companyName", "Unternehmen"],
["contactName", "Kontakt"],
["email", "E-Mail"],
["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={10} className="px-4 py-8 text-center text-gray-500 text-sm">Lädt...</td></tr>
) : leads.length === 0 ? (
<tr>
<td colSpan={10} 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 || filterStatus.length || filterSource.length)
? "Keine Leads für diese Filter."
: "Noch keine Leads. Pipeline ausführen oder Quick SERP nutzen."}
</p>
{(search || filterStatus.length) && (
<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 isHigh = lead.priority === "high";
const cfg = STATUS_CONFIG[lead.status] || STATUS_CONFIG.new;
const prio = PRIORITY_CONFIG[lead.priority] || PRIORITY_CONFIG.normal;
const src = SOURCE_CONFIG[lead.sourceTab];
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]"
} ${isHigh ? "border-l-2 border-l-red-500" : ""}`}
>
<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" onClick={e => e.stopPropagation()}>
<StatusBadge status={lead.status} onChange={s => updateLead(lead.id, { status: s })} />
</td>
<td className="px-3 py-2.5">
<span className={`text-xs font-bold ${prio.color}`}>{prio.icon}</span>
</td>
<td className="px-3 py-2.5 max-w-[180px]">
<p className="text-sm text-white truncate font-medium">{lead.companyName || ""}</p>
<p className="text-xs text-gray-500 truncate">{lead.domain}</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-[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>
)}
{lead.emailConfidence != null && lead.email && (
<div className="w-16 h-1 bg-[#2e2e3e] rounded-full mt-1">
<div
className={`h-full rounded-full ${lead.emailConfidence >= 0.8 ? "bg-green-500" : lead.emailConfidence >= 0.5 ? "bg-yellow-500" : "bg-red-500"}`}
style={{ width: `${lead.emailConfidence * 100}%` }}
/>
</div>
)}
</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">{relativeTime(lead.capturedAt)}</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={() => updateLead(lead.id, { status: "contacted" })}
title="Als kontaktiert markieren"
className="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={() => {
const cycle: Record<string, string> = { low: "normal", normal: "high", high: "low" };
updateLead(lead.id, { priority: cycle[lead.priority] || "normal" });
}}
title="Priorität wechseln"
className="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"
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 => {
setLeads(prev => prev.map(l => l.id === panelLead.id ? { ...l, ...updated } : l));
}}
/>
)}
</div>
);
}

View File

@@ -3,7 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Building2, Linkedin, Search, BarChart3, Settings, Zap, ChevronLeft, ChevronRight, MapPin } from "lucide-react"; import { Building2, Linkedin, Search, BarChart3, Settings, Zap, ChevronLeft, ChevronRight, MapPin, Database } from "lucide-react";
import { useAppStore } from "@/lib/store"; import { useAppStore } from "@/lib/store";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -12,8 +12,9 @@ const navItems = [
{ href: "/linkedin", icon: Linkedin, label: "LinkedIn → Email", color: "text-blue-500" }, { href: "/linkedin", icon: Linkedin, label: "LinkedIn → Email", color: "text-blue-500" },
{ href: "/serp", icon: Search, label: "SERP → Email", color: "text-purple-400" }, { href: "/serp", icon: Search, label: "SERP → Email", color: "text-purple-400" },
{ href: "/maps", icon: MapPin, label: "Maps → Email", color: "text-green-400" }, { href: "/maps", icon: MapPin, label: "Maps → Email", color: "text-green-400" },
{ href: "/results", icon: BarChart3, label: "Results & History", color: "text-yellow-400" }, { href: "/leadvault", icon: Database, label: "LeadVault", color: "text-violet-400", badge: true },
{ href: "/settings", icon: Settings, label: "Settings", color: "text-gray-400" }, { href: "/results", icon: BarChart3, label: "Ergebnisse & Verlauf", color: "text-yellow-400" },
{ href: "/settings", icon: Settings, label: "Einstellungen", color: "text-gray-400" },
]; ];
interface CredentialStatus { interface CredentialStatus {
@@ -27,6 +28,7 @@ export function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const { sidebarCollapsed, setSidebarCollapsed } = useAppStore(); const { sidebarCollapsed, setSidebarCollapsed } = useAppStore();
const [creds, setCreds] = useState<CredentialStatus>({ anymailfinder: false, apify: false, vayne: false, googlemaps: false }); const [creds, setCreds] = useState<CredentialStatus>({ anymailfinder: false, apify: false, vayne: false, googlemaps: false });
const [newLeadsCount, setNewLeadsCount] = useState(0);
useEffect(() => { useEffect(() => {
fetch("/api/credentials") fetch("/api/credentials")
@@ -35,6 +37,18 @@ export function Sidebar() {
.catch(() => {}); .catch(() => {});
}, []); }, []);
useEffect(() => {
function fetchNewLeads() {
fetch("/api/leads/stats")
.then(r => r.json())
.then((d: { new?: number }) => setNewLeadsCount(d.new ?? 0))
.catch(() => {});
}
fetchNewLeads();
const t = setInterval(fetchNewLeads, 30000);
return () => clearInterval(t);
}, []);
return ( return (
<aside <aside
className={cn( className={cn(
@@ -54,8 +68,9 @@ export function Sidebar() {
{/* Nav */} {/* Nav */}
<nav className="flex-1 px-2 py-4 space-y-1"> <nav className="flex-1 px-2 py-4 space-y-1">
{navItems.map(({ href, icon: Icon, label, color }) => { {navItems.map(({ href, icon: Icon, label, color, badge }) => {
const active = pathname === href || pathname.startsWith(href + "/"); const active = pathname === href || pathname.startsWith(href + "/");
const showBadge = badge && newLeadsCount > 0;
return ( return (
<Link <Link
key={href} key={href}
@@ -68,7 +83,19 @@ export function Sidebar() {
)} )}
> >
<Icon className={cn("w-5 h-5 flex-shrink-0", active ? color : "")} /> <Icon className={cn("w-5 h-5 flex-shrink-0", active ? color : "")} />
{!sidebarCollapsed && <span>{label}</span>} {!sidebarCollapsed && (
<>
<span className="flex-1">{label}</span>
{showBadge && (
<span className="ml-auto bg-blue-500 text-white text-[10px] font-bold rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1">
{newLeadsCount > 99 ? "99+" : newLeadsCount}
</span>
)}
</>
)}
{sidebarCollapsed && showBadge && (
<span className="absolute right-1 top-1 w-2 h-2 bg-blue-500 rounded-full" />
)}
</Link> </Link>
); );
})} })}

View File

@@ -9,6 +9,7 @@ const BREADCRUMBS: Record<string, string> = {
"/linkedin": "LinkedIn → E-Mail", "/linkedin": "LinkedIn → E-Mail",
"/serp": "SERP → E-Mail", "/serp": "SERP → E-Mail",
"/maps": "Google Maps → E-Mail", "/maps": "Google Maps → E-Mail",
"/leadvault": "🗄️ LeadVault",
"/results": "Ergebnisse & Verlauf", "/results": "Ergebnisse & Verlauf",
"/settings": "Einstellungen", "/settings": "Einstellungen",
}; };

159
lib/services/leadVault.ts Normal file
View File

@@ -0,0 +1,159 @@
import { prisma } from "@/lib/db";
export interface VaultLead {
domain?: string | null;
companyName?: string | null;
contactName?: string | null;
contactTitle?: string | null;
email?: string | null;
linkedinUrl?: string | null;
phone?: string | null;
emailConfidence?: number | null;
serpTitle?: string | null;
serpSnippet?: string | null;
serpRank?: number | null;
serpUrl?: string | null;
country?: string | null;
headcount?: string | null;
industry?: string | null;
}
/**
* Deduplication logic:
* - Same domain + same email → skip (already exists)
* - Same domain + no email yet + new version has email → update
* - Otherwise → create new lead
*/
export async function sinkLeadToVault(
lead: VaultLead,
sourceTab: string,
sourceTerm?: string,
sourceJobId?: string,
): Promise<string> {
const domain = lead.domain || null;
const email = lead.email || null;
if (domain) {
// Check for exact duplicate (same domain + same email)
if (email) {
const exact = await prisma.lead.findFirst({
where: { domain, email },
});
if (exact) return exact.id; // already exists, skip
}
// Check if same domain exists without email and we now have one
const existing = await prisma.lead.findFirst({
where: { domain, email: null },
});
if (existing && email) {
// Upgrade: fill in email + other missing fields
await prisma.lead.update({
where: { id: existing.id },
data: {
email,
emailConfidence: lead.emailConfidence ?? existing.emailConfidence,
contactName: lead.contactName || existing.contactName,
contactTitle: lead.contactTitle || existing.contactTitle,
linkedinUrl: lead.linkedinUrl || existing.linkedinUrl,
phone: lead.phone || existing.phone,
updatedAt: new Date(),
},
});
return existing.id;
}
}
const created = await prisma.lead.create({
data: {
domain,
companyName: lead.companyName || null,
contactName: lead.contactName || null,
contactTitle: lead.contactTitle || null,
email,
linkedinUrl: lead.linkedinUrl || null,
phone: lead.phone || null,
emailConfidence: lead.emailConfidence ?? null,
serpTitle: lead.serpTitle || null,
serpSnippet: lead.serpSnippet || null,
serpRank: lead.serpRank ?? null,
serpUrl: lead.serpUrl || null,
country: lead.country || null,
headcount: lead.headcount || null,
industry: lead.industry || null,
sourceTab,
sourceTerm: sourceTerm || null,
sourceJobId: sourceJobId || null,
status: "new",
priority: "normal",
},
});
return created.id;
}
export async function sinkLeadsToVault(
leads: VaultLead[],
sourceTab: string,
sourceTerm?: string,
sourceJobId?: string,
): Promise<{ added: number; updated: number; skipped: number }> {
let added = 0, updated = 0, skipped = 0;
for (const lead of leads) {
const domain = lead.domain || null;
const email = lead.email || null;
if (domain) {
if (email) {
const exact = await prisma.lead.findFirst({ where: { domain, email } });
if (exact) { skipped++; continue; }
}
const existing = await prisma.lead.findFirst({ where: { domain, email: null } });
if (existing && email) {
await prisma.lead.update({
where: { id: existing.id },
data: {
email,
emailConfidence: lead.emailConfidence ?? existing.emailConfidence,
contactName: lead.contactName || existing.contactName,
contactTitle: lead.contactTitle || existing.contactTitle,
linkedinUrl: lead.linkedinUrl || existing.linkedinUrl,
phone: lead.phone || existing.phone,
updatedAt: new Date(),
},
});
updated++;
continue;
}
}
await prisma.lead.create({
data: {
domain,
companyName: lead.companyName || null,
contactName: lead.contactName || null,
contactTitle: lead.contactTitle || null,
email,
linkedinUrl: lead.linkedinUrl || null,
phone: lead.phone || null,
emailConfidence: lead.emailConfidence ?? null,
serpTitle: lead.serpTitle || null,
serpSnippet: lead.serpSnippet || null,
serpRank: lead.serpRank ?? null,
serpUrl: lead.serpUrl || null,
country: lead.country || null,
sourceTab,
sourceTerm: sourceTerm || null,
sourceJobId: sourceJobId || null,
status: "new",
priority: "normal",
},
});
added++;
}
return { added, updated, skipped };
}

View File

@@ -0,0 +1,56 @@
-- CreateTable
CREATE TABLE "Lead" (
"id" TEXT NOT NULL PRIMARY KEY,
"domain" TEXT,
"companyName" TEXT,
"contactName" TEXT,
"contactTitle" TEXT,
"email" TEXT,
"linkedinUrl" TEXT,
"phone" TEXT,
"sourceTab" TEXT NOT NULL,
"sourceTerm" TEXT,
"sourceJobId" TEXT,
"serpTitle" TEXT,
"serpSnippet" TEXT,
"serpRank" INTEGER,
"serpUrl" TEXT,
"emailConfidence" REAL,
"status" TEXT NOT NULL DEFAULT 'new',
"priority" TEXT NOT NULL DEFAULT 'normal',
"notes" TEXT,
"tags" TEXT,
"country" TEXT,
"headcount" TEXT,
"industry" TEXT,
"capturedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"contactedAt" DATETIME,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "LeadEvent" (
"id" TEXT NOT NULL PRIMARY KEY,
"leadId" TEXT NOT NULL,
"event" TEXT NOT NULL,
"at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LeadEvent_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "Lead_domain_idx" ON "Lead"("domain");
-- CreateIndex
CREATE INDEX "Lead_status_idx" ON "Lead"("status");
-- CreateIndex
CREATE INDEX "Lead_sourceTab_idx" ON "Lead"("sourceTab");
-- CreateIndex
CREATE INDEX "Lead_capturedAt_idx" ON "Lead"("capturedAt");
-- CreateIndex
CREATE INDEX "Lead_email_idx" ON "Lead"("email");
-- CreateIndex
CREATE INDEX "LeadEvent_leadId_idx" ON "LeadEvent"("leadId");

View File

@@ -41,3 +41,57 @@ model LeadResult {
source String? source String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
} }
model Lead {
id String @id @default(cuid())
domain String?
companyName String?
contactName String?
contactTitle String?
email String?
linkedinUrl String?
phone String?
sourceTab String
sourceTerm String?
sourceJobId String?
serpTitle String?
serpSnippet String?
serpRank Int?
serpUrl String?
emailConfidence Float?
status String @default("new")
priority String @default("normal")
notes String?
tags String?
country String?
headcount String?
industry String?
capturedAt DateTime @default(now())
contactedAt DateTime?
updatedAt DateTime @updatedAt
events LeadEvent[]
@@index([domain])
@@index([status])
@@index([sourceTab])
@@index([capturedAt])
@@index([email])
}
model LeadEvent {
id String @id @default(cuid())
leadId String
lead Lead @relation(fields: [leadId], references: [id], onDelete: Cascade)
event String
at DateTime @default(now())
@@index([leadId])
}