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:
@@ -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({
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
70
app/api/leads/[id]/route.ts
Normal file
70
app/api/leads/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
64
app/api/leads/bulk/route.ts
Normal file
64
app/api/leads/bulk/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
93
app/api/leads/export/route.ts
Normal file
93
app/api/leads/export/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
71
app/api/leads/quick-serp/route.ts
Normal file
71
app/api/leads/quick-serp/route.ts
Normal 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
87
app/api/leads/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/api/leads/stats/route.ts
Normal file
42
app/api/leads/stats/route.ts
Normal 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
993
app/leadvault/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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
159
lib/services/leadVault.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user