UI improvements: Leadspeicher, Maps enrichment, exports
- Rename LeadVault → Leadspeicher throughout (sidebar, topbar, page) - SidePanel: full lead detail view with contact, source, tags (read-only), Google Maps link for address - Tags: kontaktiert stored as tag (toggleable), favorit tag toggle - Remove Status column, StatusBadge dropdown, Priority feature - Remove Aktualisieren button from Leadspeicher - Bulk actions: remove status dropdown - Export: LeadVault Excel-only, clean columns, freeze row + autofilter - Export dropdown: click-based (fix overflow-hidden clipping) - ExportButtons: remove CSV, Excel only everywhere - Maps page: post-search Anymailfinder enrichment button - ProgressCard: "Suche läuft..." instead of "Warte auf Anymailfinder-Server..." - Quick SERP renamed to "Schnell neue Suche" - Results page: Excel export, always-enabled download button - Anymailfinder: fix bulk field names, array-of-arrays format - Apify: fix countryCode lowercase - API: sourceTerm search, contacted/favorite tag filters Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -130,16 +130,8 @@ export default function AirScalePage() {
|
|||||||
contact_name: r.contactName,
|
contact_name: r.contactName,
|
||||||
contact_title: r.contactTitle,
|
contact_title: r.contactTitle,
|
||||||
email: r.email,
|
email: r.email,
|
||||||
confidence_score: r.confidence !== undefined ? Math.round(r.confidence * 100) : undefined,
|
|
||||||
source_tab: "airscale",
|
|
||||||
job_id: jobId || "",
|
|
||||||
found_at: new Date().toISOString(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const hitRate = results.length > 0
|
|
||||||
? Math.round((results.filter(r => r.email).length / results.length) * 100)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-5xl">
|
<div className="space-y-6 max-w-5xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -315,7 +307,7 @@ export default function AirScalePage() {
|
|||||||
title="Anreicherung abgeschlossen"
|
title="Anreicherung abgeschlossen"
|
||||||
current={progress.current}
|
current={progress.current}
|
||||||
total={progress.total}
|
total={progress.total}
|
||||||
subtitle={`Trefferquote: ${hitRate}%`}
|
subtitle={`${results.filter(r => r.email).length} E-Mails gefunden`}
|
||||||
status="complete"
|
status="complete"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -339,7 +331,7 @@ export default function AirScalePage() {
|
|||||||
<ExportButtons
|
<ExportButtons
|
||||||
rows={exportRows}
|
rows={exportRows}
|
||||||
filename={`airscale-leads-${jobId?.slice(0, 8) || "export"}`}
|
filename={`airscale-leads-${jobId?.slice(0, 8) || "export"}`}
|
||||||
summary={`${results.filter(r => r.email).length} E-Mails gefunden • ${hitRate}% Trefferquote`}
|
summary={`${results.filter(r => r.email).length} E-Mails gefunden`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ResultsTable rows={results} loading={running && results.length === 0} />
|
<ResultsTable rows={results} loading={running && results.length === 0} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import Papa from "papaparse";
|
import * as XLSX from "xlsx";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
@@ -16,23 +16,22 @@ export async function GET(
|
|||||||
if (!job) return NextResponse.json({ error: "Job not found" }, { status: 404 });
|
if (!job) return NextResponse.json({ error: "Job not found" }, { status: 404 });
|
||||||
|
|
||||||
const rows = job.results.map(r => ({
|
const rows = job.results.map(r => ({
|
||||||
company_name: r.companyName || "",
|
"Unternehmen": r.companyName || "",
|
||||||
domain: r.domain || "",
|
"Domain": r.domain || "",
|
||||||
contact_name: r.contactName || "",
|
"Kontaktname": r.contactName || "",
|
||||||
contact_title: r.contactTitle || "",
|
"Position": r.contactTitle || "",
|
||||||
email: r.email || "",
|
"E-Mail": r.email || "",
|
||||||
confidence_score: r.confidence !== null ? Math.round((r.confidence || 0) * 100) + "%" : "",
|
|
||||||
source_tab: job.type,
|
|
||||||
job_id: job.id,
|
|
||||||
found_at: r.createdAt.toISOString(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const csv = Papa.unparse(rows);
|
const ws = XLSX.utils.json_to_sheet(rows);
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, "Leads");
|
||||||
|
const arr = XLSX.write(wb, { type: "array", bookType: "xlsx" });
|
||||||
|
|
||||||
return new NextResponse(csv, {
|
return new NextResponse(new Uint8Array(arr), {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/csv",
|
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
"Content-Disposition": `attachment; filename="leadflow-${job.type}-${jobId.slice(0, 8)}.csv"`,
|
"Content-Disposition": `attachment; filename="leadflow-${job.type}-${jobId.slice(0, 8)}.xlsx"`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -28,17 +28,29 @@ export async function GET(
|
|||||||
error: job.error,
|
error: job.error,
|
||||||
createdAt: job.createdAt,
|
createdAt: job.createdAt,
|
||||||
updatedAt: job.updatedAt,
|
updatedAt: job.updatedAt,
|
||||||
results: job.results.map(r => ({
|
results: job.results.map(r => {
|
||||||
|
let address: string | null = null;
|
||||||
|
let phone: string | null = null;
|
||||||
|
if (r.source) {
|
||||||
|
try {
|
||||||
|
const src = JSON.parse(r.source) as { address?: string; phone?: string };
|
||||||
|
address = src.address ?? null;
|
||||||
|
phone = src.phone ?? null;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
companyName: r.companyName,
|
companyName: r.companyName,
|
||||||
domain: r.domain,
|
domain: r.domain,
|
||||||
contactName: r.contactName,
|
contactName: r.contactName,
|
||||||
contactTitle: r.contactTitle,
|
contactTitle: r.contactTitle,
|
||||||
email: r.email,
|
email: r.email,
|
||||||
confidence: r.confidence,
|
|
||||||
linkedinUrl: r.linkedinUrl,
|
linkedinUrl: r.linkedinUrl,
|
||||||
|
address,
|
||||||
|
phone,
|
||||||
createdAt: r.createdAt,
|
createdAt: r.createdAt,
|
||||||
})),
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("GET /api/jobs/[id]/status error:", err);
|
console.error("GET /api/jobs/[id]/status error:", err);
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ async function runEnrichment(
|
|||||||
contactName: result.person_full_name || null,
|
contactName: result.person_full_name || null,
|
||||||
contactTitle: result.person_job_title || null,
|
contactTitle: result.person_job_title || null,
|
||||||
email: result.email || null,
|
email: result.email || null,
|
||||||
confidence: result.valid_email ? 1.0 : result.email_status === "risky" ? 0.5 : 0,
|
|
||||||
linkedinUrl: result.person_linkedin_url || null,
|
linkedinUrl: result.person_linkedin_url || null,
|
||||||
source: JSON.stringify({ email_status: result.email_status, category: result.decision_maker_category }),
|
source: JSON.stringify({ email_status: result.email_status, category: result.decision_maker_category }),
|
||||||
},
|
},
|
||||||
@@ -104,7 +103,6 @@ async function runEnrichment(
|
|||||||
contactTitle: r.person_job_title || null,
|
contactTitle: r.person_job_title || null,
|
||||||
email: r.email || null,
|
email: r.email || null,
|
||||||
linkedinUrl: r.person_linkedin_url || null,
|
linkedinUrl: r.person_linkedin_url || null,
|
||||||
emailConfidence: r.valid_email ? 1.0 : r.email_status === "risky" ? 0.5 : 0,
|
|
||||||
})),
|
})),
|
||||||
"airscale",
|
"airscale",
|
||||||
undefined,
|
undefined,
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ async function runLinkedInEnrich(
|
|||||||
where: { id: result.id },
|
where: { id: result.id },
|
||||||
data: {
|
data: {
|
||||||
email: email || null,
|
email: email || null,
|
||||||
confidence: isValid ? 1.0 : emailStatus === "risky" ? 0.5 : 0,
|
|
||||||
contactName: row["person_full_name"] || row["Full Name"] || result.contactName || null,
|
contactName: row["person_full_name"] || row["Full Name"] || result.contactName || null,
|
||||||
contactTitle: row["person_job_title"] || row["Job Title"] || result.contactTitle || null,
|
contactTitle: row["person_job_title"] || row["Job Title"] || result.contactTitle || null,
|
||||||
},
|
},
|
||||||
@@ -135,7 +134,6 @@ async function runLinkedInEnrich(
|
|||||||
where: { id: r.id },
|
where: { id: r.id },
|
||||||
data: {
|
data: {
|
||||||
email: found.email || null,
|
email: found.email || null,
|
||||||
confidence: isValid ? 1.0 : found.email_status === "risky" ? 0.5 : 0,
|
|
||||||
contactName: found.person_full_name || r.contactName || null,
|
contactName: found.person_full_name || r.contactName || null,
|
||||||
contactTitle: found.person_job_title || r.contactTitle || null,
|
contactTitle: found.person_job_title || r.contactTitle || null,
|
||||||
},
|
},
|
||||||
@@ -162,7 +160,6 @@ async function runLinkedInEnrich(
|
|||||||
contactTitle: r.contactTitle,
|
contactTitle: r.contactTitle,
|
||||||
email: r.email,
|
email: r.email,
|
||||||
linkedinUrl: r.linkedinUrl,
|
linkedinUrl: r.linkedinUrl,
|
||||||
emailConfidence: r.confidence,
|
|
||||||
})),
|
})),
|
||||||
"linkedin",
|
"linkedin",
|
||||||
undefined,
|
undefined,
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ async function runMapsEnrich(
|
|||||||
companyName: p.name || null,
|
companyName: p.name || null,
|
||||||
phone: p.phone,
|
phone: p.phone,
|
||||||
address: p.address,
|
address: p.address,
|
||||||
|
description: p.description,
|
||||||
|
industry: p.category,
|
||||||
})),
|
})),
|
||||||
"maps",
|
"maps",
|
||||||
params.queries.join(", "),
|
params.queries.join(", "),
|
||||||
@@ -132,7 +134,7 @@ async function runMapsEnrich(
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const result of enrichResults) {
|
for (const result of enrichResults) {
|
||||||
const hasEmail = !!result.valid_email;
|
const hasEmail = !!result.email;
|
||||||
if (hasEmail) emailsFound++;
|
if (hasEmail) emailsFound++;
|
||||||
|
|
||||||
const resultId = domainToResultId.get(result.domain || "");
|
const resultId = domainToResultId.get(result.domain || "");
|
||||||
@@ -144,7 +146,6 @@ async function runMapsEnrich(
|
|||||||
contactName: result.person_full_name || null,
|
contactName: result.person_full_name || null,
|
||||||
contactTitle: result.person_job_title || null,
|
contactTitle: result.person_job_title || null,
|
||||||
email: result.email || null,
|
email: result.email || null,
|
||||||
confidence: result.valid_email ? 1.0 : result.email_status === "risky" ? 0.5 : 0,
|
|
||||||
linkedinUrl: result.person_linkedin_url || null,
|
linkedinUrl: result.person_linkedin_url || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -167,7 +168,6 @@ async function runMapsEnrich(
|
|||||||
contactTitle: r.person_job_title || null,
|
contactTitle: r.person_job_title || null,
|
||||||
email: r.email || null,
|
email: r.email || null,
|
||||||
linkedinUrl: r.person_linkedin_url || null,
|
linkedinUrl: r.person_linkedin_url || null,
|
||||||
emailConfidence: r.valid_email ? 1.0 : r.email_status === "risky" ? 0.5 : 0,
|
|
||||||
})),
|
})),
|
||||||
"maps",
|
"maps",
|
||||||
params.queries.join(", "),
|
params.queries.join(", "),
|
||||||
|
|||||||
@@ -122,7 +122,6 @@ async function runSerpEnrich(
|
|||||||
contactName: result.person_full_name || null,
|
contactName: result.person_full_name || null,
|
||||||
contactTitle: result.person_job_title || null,
|
contactTitle: result.person_job_title || null,
|
||||||
email: result.email || null,
|
email: result.email || null,
|
||||||
confidence: result.valid_email ? 1.0 : result.email_status === "risky" ? 0.5 : 0,
|
|
||||||
linkedinUrl: result.person_linkedin_url || null,
|
linkedinUrl: result.person_linkedin_url || null,
|
||||||
source: JSON.stringify({
|
source: JSON.stringify({
|
||||||
url: serpData?.url,
|
url: serpData?.url,
|
||||||
@@ -150,7 +149,6 @@ async function runSerpEnrich(
|
|||||||
contactTitle: r.person_job_title || null,
|
contactTitle: r.person_job_title || null,
|
||||||
email: r.email || null,
|
email: r.email || null,
|
||||||
linkedinUrl: r.person_linkedin_url || null,
|
linkedinUrl: r.person_linkedin_url || null,
|
||||||
emailConfidence: r.valid_email ? 1.0 : r.email_status === "risky" ? 0.5 : 0,
|
|
||||||
serpTitle: serpData?.title || null,
|
serpTitle: serpData?.title || null,
|
||||||
serpSnippet: serpData?.description || null,
|
serpSnippet: serpData?.description || null,
|
||||||
serpRank: serpData?.position ?? null,
|
serpRank: serpData?.position ?? null,
|
||||||
|
|||||||
@@ -36,30 +36,18 @@ export async function GET(req: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const rows = leads.map(l => ({
|
const rows = leads.map(l => ({
|
||||||
"Status": l.status,
|
|
||||||
"Priorität": l.priority,
|
|
||||||
"Unternehmen": l.companyName || "",
|
"Unternehmen": l.companyName || "",
|
||||||
"Domain": l.domain || "",
|
"Domain": l.domain || "",
|
||||||
"Kontaktname": l.contactName || "",
|
"Kontaktname": l.contactName || "",
|
||||||
"Jobtitel": l.contactTitle || "",
|
"Position": l.contactTitle || "",
|
||||||
"E-Mail": l.email || "",
|
"E-Mail": l.email || "",
|
||||||
"E-Mail Konfidenz": l.emailConfidence != null ? Math.round(l.emailConfidence * 100) + "%" : "",
|
|
||||||
"LinkedIn": l.linkedinUrl || "",
|
|
||||||
"Telefon": l.phone || "",
|
"Telefon": l.phone || "",
|
||||||
"Quelle": l.sourceTab,
|
"Adresse": l.address || "",
|
||||||
"Suchbegriff": l.sourceTerm || "",
|
"LinkedIn": l.linkedinUrl || "",
|
||||||
"Tags": l.tags ? JSON.parse(l.tags).join(", ") : "",
|
|
||||||
"Land": l.country || "",
|
|
||||||
"Mitarbeiter": l.headcount || "",
|
|
||||||
"Branche": l.industry || "",
|
"Branche": l.industry || "",
|
||||||
"Notizen": l.notes || "",
|
"Suchbegriff": l.sourceTerm || "",
|
||||||
"Erfasst am": new Date(l.capturedAt).toLocaleString("de-DE"),
|
"Tags": l.tags ? (JSON.parse(l.tags) as string[]).join(", ") : "",
|
||||||
"Kontaktiert am": l.contactedAt ? new Date(l.contactedAt).toLocaleString("de-DE") : "",
|
"Erfasst am": new Date(l.capturedAt).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }),
|
||||||
"SERP Rang": l.serpRank?.toString() || "",
|
|
||||||
"SERP URL": l.serpUrl || "",
|
|
||||||
"SERP Titel": l.serpTitle || "",
|
|
||||||
"SERP Snippet": l.serpSnippet || "",
|
|
||||||
"Lead ID": l.id,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const filename = `leadflow-vault-${new Date().toISOString().split("T")[0]}`;
|
const filename = `leadflow-vault-${new Date().toISOString().split("T")[0]}`;
|
||||||
@@ -67,22 +55,36 @@ export async function GET(req: NextRequest) {
|
|||||||
if (format === "xlsx") {
|
if (format === "xlsx") {
|
||||||
const ws = XLSX.utils.json_to_sheet(rows);
|
const ws = XLSX.utils.json_to_sheet(rows);
|
||||||
|
|
||||||
// Column widths
|
// Column widths (one per column)
|
||||||
ws["!cols"] = [
|
ws["!cols"] = [
|
||||||
{ wch: 14 }, { wch: 10 }, { wch: 28 }, { wch: 28 }, { wch: 22 },
|
{ wch: 30 }, // Unternehmen
|
||||||
{ wch: 22 }, { wch: 32 }, { wch: 14 }, { wch: 30 }, { wch: 16 },
|
{ wch: 28 }, // Domain
|
||||||
{ wch: 12 }, { wch: 24 }, { wch: 20 }, { wch: 8 }, { wch: 10 },
|
{ wch: 24 }, // Kontaktname
|
||||||
{ wch: 16 }, { wch: 30 }, { wch: 18 }, { wch: 18 }, { wch: 8 },
|
{ wch: 24 }, // Position
|
||||||
{ wch: 40 }, { wch: 30 }, { wch: 40 }, { wch: 28 },
|
{ wch: 34 }, // E-Mail
|
||||||
|
{ wch: 18 }, // Telefon
|
||||||
|
{ wch: 36 }, // Adresse
|
||||||
|
{ wch: 36 }, // LinkedIn
|
||||||
|
{ wch: 22 }, // Branche
|
||||||
|
{ wch: 30 }, // Suchbegriff
|
||||||
|
{ wch: 28 }, // Tags
|
||||||
|
{ wch: 14 }, // Erfasst am
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Freeze header row
|
||||||
|
ws["!freeze"] = { xSplit: 0, ySplit: 1 };
|
||||||
|
|
||||||
|
// Autofilter across all columns
|
||||||
|
const colCount = Object.keys(rows[0] ?? {}).length || 17;
|
||||||
|
const lastCol = String.fromCharCode(64 + colCount);
|
||||||
|
ws["!autofilter"] = { ref: `A1:${lastCol}1` };
|
||||||
|
|
||||||
const wb = XLSX.utils.book_new();
|
const wb = XLSX.utils.book_new();
|
||||||
XLSX.utils.book_append_sheet(wb, ws, "LeadVault");
|
XLSX.utils.book_append_sheet(wb, ws, "LeadVault");
|
||||||
|
|
||||||
const arr = XLSX.write(wb, { type: "array", bookType: "xlsx" }) as number[];
|
const arr = XLSX.write(wb, { type: "array", bookType: "xlsx" }) as number[];
|
||||||
const buf = new Uint8Array(arr).buffer;
|
|
||||||
|
|
||||||
return new NextResponse(buf, {
|
return new NextResponse(new Uint8Array(arr).buffer, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
"Content-Disposition": `attachment; filename="${filename}.xlsx"`,
|
"Content-Disposition": `attachment; filename="${filename}.xlsx"`,
|
||||||
@@ -90,26 +92,19 @@ export async function GET(req: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSV — UTF-8 BOM so Excel opens it correctly + \r\n line endings
|
// CSV — UTF-8 BOM so Excel opens it correctly
|
||||||
const headers = Object.keys(rows[0] ?? {
|
const colKeys = Object.keys(rows[0] ?? {});
|
||||||
"Status": "", "Priorität": "", "Unternehmen": "", "Domain": "", "Kontaktname": "",
|
|
||||||
"Jobtitel": "", "E-Mail": "", "E-Mail Konfidenz": "", "LinkedIn": "", "Telefon": "",
|
|
||||||
"Quelle": "", "Suchbegriff": "", "Tags": "", "Land": "", "Mitarbeiter": "",
|
|
||||||
"Branche": "", "Notizen": "", "Erfasst am": "", "Kontaktiert am": "",
|
|
||||||
"SERP Rang": "", "SERP URL": "", "SERP Titel": "", "SERP Snippet": "", "Lead ID": "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const escape = (v: string) =>
|
const escape = (v: string) =>
|
||||||
v.includes(",") || v.includes('"') || v.includes("\n") || v.includes("\r")
|
v.includes(",") || v.includes('"') || v.includes("\n") || v.includes("\r")
|
||||||
? `"${v.replace(/"/g, '""')}"`
|
? `"${v.replace(/"/g, '""')}"`
|
||||||
: v;
|
: v;
|
||||||
|
|
||||||
const csv =
|
const csv =
|
||||||
"\uFEFF" + // UTF-8 BOM — tells Excel to use UTF-8
|
"\uFEFF" +
|
||||||
[
|
[
|
||||||
headers.map(escape).join(","),
|
colKeys.map(escape).join(","),
|
||||||
...rows.map(r => headers.map(h => escape(String(r[h as keyof typeof r] ?? ""))).join(",")),
|
...rows.map(r => colKeys.map(h => escape(String(r[h as keyof typeof r] ?? ""))).join(",")),
|
||||||
].join("\r\n"); // Windows line endings for Excel
|
].join("\r\n");
|
||||||
|
|
||||||
return new NextResponse(csv, {
|
return new NextResponse(csv, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export async function GET(req: NextRequest) {
|
|||||||
const priorities = searchParams.getAll("priority");
|
const priorities = searchParams.getAll("priority");
|
||||||
const tags = searchParams.getAll("tags");
|
const tags = searchParams.getAll("tags");
|
||||||
const searchTerms = searchParams.getAll("searchTerm");
|
const searchTerms = searchParams.getAll("searchTerm");
|
||||||
|
const contacted = searchParams.get("contacted");
|
||||||
|
const favorite = searchParams.get("favorite");
|
||||||
|
|
||||||
const where: Prisma.LeadWhereInput = {};
|
const where: Prisma.LeadWhereInput = {};
|
||||||
|
|
||||||
@@ -30,6 +32,7 @@ export async function GET(req: NextRequest) {
|
|||||||
{ contactName: { contains: search } },
|
{ contactName: { contains: search } },
|
||||||
{ email: { contains: search } },
|
{ email: { contains: search } },
|
||||||
{ notes: { contains: search } },
|
{ notes: { contains: search } },
|
||||||
|
{ sourceTerm: { contains: search } },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +53,14 @@ export async function GET(req: NextRequest) {
|
|||||||
where.sourceTerm = { in: searchTerms };
|
where.sourceTerm = { in: searchTerms };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (contacted === "yes") {
|
||||||
|
where.tags = { contains: "kontaktiert" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (favorite === "yes") {
|
||||||
|
where.tags = { contains: "favorit" };
|
||||||
|
}
|
||||||
|
|
||||||
if (tags.length > 0) {
|
if (tags.length > 0) {
|
||||||
// SQLite JSON contains — search for each tag in the JSON string
|
// SQLite JSON contains — search for each tag in the JSON string
|
||||||
where.AND = tags.map(tag => ({
|
where.AND = tags.map(tag => ({
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
Database, Search, X, ChevronDown, ChevronUp, Trash2, ExternalLink,
|
Database, Search, X, ChevronDown, ChevronUp, Trash2, ExternalLink,
|
||||||
Mail, Star, Tag, FileText, ArrowUpDown, ArrowUp, ArrowDown,
|
Mail, Star, Tag, ArrowUpDown, ArrowUp, ArrowDown,
|
||||||
CheckSquare, Square, Download, Plus, RefreshCw, Phone
|
CheckSquare, Square, Download, Phone, Copy, MapPin, Building2, Globe
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
||||||
@@ -31,7 +31,6 @@ interface Lead {
|
|||||||
serpSnippet: string | null;
|
serpSnippet: string | null;
|
||||||
serpRank: number | null;
|
serpRank: number | null;
|
||||||
serpUrl: string | null;
|
serpUrl: string | null;
|
||||||
emailConfidence: number | null;
|
|
||||||
status: string;
|
status: string;
|
||||||
priority: string;
|
priority: string;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
@@ -39,6 +38,7 @@ interface Lead {
|
|||||||
country: string | null;
|
country: string | null;
|
||||||
headcount: string | null;
|
headcount: string | null;
|
||||||
industry: string | null;
|
industry: string | null;
|
||||||
|
description: string | null;
|
||||||
capturedAt: string;
|
capturedAt: string;
|
||||||
contactedAt: string | null;
|
contactedAt: string | null;
|
||||||
events?: Array<{ id: string; event: string; at: string }>;
|
events?: Array<{ id: string; event: string; at: string }>;
|
||||||
@@ -71,12 +71,6 @@ const SOURCE_CONFIG: Record<string, { label: string; icon: string }> = {
|
|||||||
maps: { label: "Maps", 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 = [
|
const TAG_PRESETS = [
|
||||||
"solar", "b2b", "deutschland", "founder", "kmu", "warmkontakt",
|
"solar", "b2b", "deutschland", "founder", "kmu", "warmkontakt",
|
||||||
"kaltakquise", "follow-up", "interessiert", "angebot-gesendet",
|
"kaltakquise", "follow-up", "interessiert", "angebot-gesendet",
|
||||||
@@ -97,18 +91,6 @@ function tagColor(tag: string): string {
|
|||||||
return colors[Math.abs(hash) % colors.length];
|
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 ────────────────────────────────────────────────────────────────
|
// ─── Sparkline ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Sparkline({ data, color }: { data: number[]; color: string }) {
|
function Sparkline({ data, color }: { data: number[]; color: string }) {
|
||||||
@@ -123,243 +105,163 @@ function Sparkline({ data, color }: { data: number[]; color: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 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 ───────────────────────────────────────────────────────────────
|
// ─── Side Panel ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SidePanel({ lead, onClose, onUpdate }: {
|
function SidePanel({ lead, onClose, onUpdate, onDelete }: {
|
||||||
lead: Lead;
|
lead: Lead;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onUpdate: (updated: Partial<Lead>) => void;
|
onUpdate: (updated: Partial<Lead>) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [notes, setNotes] = useState(lead.notes || "");
|
const tags: string[] = JSON.parse(lead.tags || "[]");
|
||||||
const [tagInput, setTagInput] = useState("");
|
const src = SOURCE_CONFIG[lead.sourceTab];
|
||||||
const [fullLead, setFullLead] = useState<Lead>(lead);
|
|
||||||
const notesTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
function copy(text: string, label: string) {
|
||||||
// Load full lead with events
|
navigator.clipboard.writeText(text);
|
||||||
fetch(`/api/leads/${lead.id}`).then(r => r.json()).then(setFullLead).catch(() => {});
|
toast.success(`${label} kopiert`);
|
||||||
}, [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 (
|
return (
|
||||||
<div className="fixed inset-0 z-40 flex justify-end" onClick={onClose}>
|
<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="absolute inset-0 bg-black/40 backdrop-blur-sm" />
|
||||||
<div
|
<div
|
||||||
className="relative z-50 w-full max-w-md h-full bg-[#0e0e1a] border-l border-[#1e1e2e] overflow-y-auto flex flex-col"
|
className="relative z-50 w-full max-w-sm h-full bg-[#0e0e1a] border-l border-[#1e1e2e] flex flex-col overflow-hidden"
|
||||||
style={{ animation: "slideIn 200ms ease-out" }}
|
style={{ animation: "slideIn 200ms ease-out" }}
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<style>{`@keyframes slideIn { from { transform: translateX(100%) } to { transform: translateX(0) } }`}</style>
|
<style>{`@keyframes slideIn { from { transform: translateX(100%) } to { transform: translateX(0) } }`}</style>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="sticky top-0 bg-[#0e0e1a] border-b border-[#1e1e2e] p-4 flex items-start gap-3">
|
<div className="border-b border-[#1e1e2e] p-4 flex items-start gap-3 flex-shrink-0">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h2 className="text-base font-bold text-white truncate">{fullLead.companyName || fullLead.domain || "Unbekannt"}</h2>
|
<h2 className="text-base font-bold text-white truncate">{lead.companyName || lead.domain || "Unbekannt"}</h2>
|
||||||
<p className="text-xs text-gray-400">{fullLead.domain}</p>
|
{lead.domain && (
|
||||||
|
<a href={`https://${lead.domain}`} target="_blank" rel="noreferrer"
|
||||||
|
className="text-xs text-blue-400 hover:underline flex items-center gap-1 mt-0.5">
|
||||||
|
<Globe className="w-3 h-3" />{lead.domain}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{lead.industry && <p className="text-xs text-purple-400 mt-0.5">{lead.industry}</p>}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="text-gray-500 hover:text-white p-1 rounded">
|
<button onClick={onClose} className="text-gray-500 hover:text-white p-1 rounded flex-shrink-0">
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 space-y-4 flex-1">
|
{/* Scrollable body */}
|
||||||
{/* Status + Priority */}
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
<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 */}
|
{/* Description */}
|
||||||
<div className="bg-[#111118] rounded-lg border border-[#1e1e2e] p-3 space-y-2">
|
{lead.description && (
|
||||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Kontakt</h3>
|
<div>
|
||||||
{fullLead.contactName && <p className="text-sm text-white">{fullLead.contactName}{fullLead.contactTitle && <span className="text-gray-400 ml-1">· {fullLead.contactTitle}</span>}</p>}
|
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-1">Beschreibung</p>
|
||||||
{fullLead.email && (
|
<p className="text-xs text-gray-300 leading-relaxed">{lead.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Contact */}
|
||||||
|
{(lead.contactName || lead.email || lead.phone || lead.address || lead.linkedinUrl) && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-2">Kontakt</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{lead.contactName && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Mail className="w-3.5 h-3.5 text-gray-500" />
|
<Building2 className="w-3.5 h-3.5 text-gray-600 flex-shrink-0" />
|
||||||
<span className={`text-sm font-mono ${confColor}`}>{fullLead.email}</span>
|
<span className="text-sm text-white">{lead.contactName}
|
||||||
<button onClick={() => { navigator.clipboard.writeText(fullLead.email!); toast.success("Kopiert"); }} className="text-gray-600 hover:text-white">
|
{lead.contactTitle && <span className="text-gray-500 ml-1 text-xs">· {lead.contactTitle}</span>}
|
||||||
<FileText className="w-3 h-3" />
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lead.email && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Mail className="w-3.5 h-3.5 text-gray-600 flex-shrink-0" />
|
||||||
|
<span className="text-xs font-mono text-green-400 truncate flex-1">{lead.email}</span>
|
||||||
|
<button onClick={() => copy(lead.email!, "E-Mail")} className="text-gray-600 hover:text-white flex-shrink-0">
|
||||||
|
<Copy className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
{conf != null && <span className={`text-xs ${confColor}`}>{Math.round(conf * 100)}%</span>}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{fullLead.phone && (
|
{lead.phone && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Phone className="w-3.5 h-3.5 text-gray-500" />
|
<Phone className="w-3.5 h-3.5 text-gray-600 flex-shrink-0" />
|
||||||
<a href={`tel:${fullLead.phone}`} className="text-sm text-gray-300 hover:text-white">{fullLead.phone}</a>
|
<a href={`tel:${lead.phone}`} className="text-xs text-gray-300 hover:text-white">{lead.phone}</a>
|
||||||
|
<button onClick={() => copy(lead.phone!, "Telefon")} className="text-gray-600 hover:text-white flex-shrink-0">
|
||||||
|
<Copy className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{fullLead.address && (
|
{lead.address && (
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<span className="text-gray-500 mt-0.5">📍</span>
|
<MapPin className="w-3.5 h-3.5 text-gray-600 flex-shrink-0 mt-0.5" />
|
||||||
<span className="text-sm text-gray-300">{fullLead.address}</span>
|
<span className="text-xs text-gray-300 flex-1">{lead.address}</span>
|
||||||
</div>
|
<a
|
||||||
)}
|
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(lead.address)}`}
|
||||||
{fullLead.linkedinUrl && (
|
target="_blank" rel="noreferrer"
|
||||||
<a href={fullLead.linkedinUrl} target="_blank" rel="noreferrer" className="flex items-center gap-1 text-xs text-blue-400 hover:underline">
|
className="text-gray-600 hover:text-green-400 flex-shrink-0"
|
||||||
<ExternalLink className="w-3 h-3" /> LinkedIn
|
title="In Google Maps öffnen"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lead.linkedinUrl && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ExternalLink className="w-3.5 h-3.5 text-gray-600 flex-shrink-0" />
|
||||||
|
<a href={lead.linkedinUrl} target="_blank" rel="noreferrer"
|
||||||
|
className="text-xs text-blue-400 hover:underline truncate">
|
||||||
|
LinkedIn Profil
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Source Info */}
|
{/* Company info */}
|
||||||
<div className="bg-[#111118] rounded-lg border border-[#1e1e2e] p-3 space-y-1.5">
|
{(lead.headcount || lead.country) && (
|
||||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Quelle</h3>
|
<div>
|
||||||
<p className="text-sm text-gray-300">
|
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-1">Unternehmen</p>
|
||||||
{SOURCE_CONFIG[fullLead.sourceTab]?.icon} {SOURCE_CONFIG[fullLead.sourceTab]?.label || fullLead.sourceTab}
|
<div className="flex gap-3 text-xs text-gray-400">
|
||||||
</p>
|
{lead.headcount && <span>👥 {lead.headcount} Mitarbeiter</span>}
|
||||||
{fullLead.sourceTerm && <p className="text-xs text-gray-500">Suche: <span className="text-gray-300">{fullLead.sourceTerm}</span></p>}
|
{lead.country && <span>🌍 {lead.country}</span>}
|
||||||
<p className="text-xs text-gray-500">Erfasst: {new Date(fullLead.capturedAt).toLocaleString("de-DE")}</p>
|
</div>
|
||||||
{fullLead.serpRank && <p className="text-xs text-gray-500">SERP Rang: #{fullLead.serpRank}</p>}
|
</div>
|
||||||
{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>}
|
)}
|
||||||
|
|
||||||
|
{/* Source */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-1">Quelle</p>
|
||||||
|
<div className="space-y-1 text-xs text-gray-400">
|
||||||
|
<p>{src?.icon} {src?.label || lead.sourceTab}</p>
|
||||||
|
{lead.sourceTerm && (
|
||||||
|
<p>Suche: <span className="text-gray-300">"{lead.sourceTerm}"</span></p>
|
||||||
|
)}
|
||||||
|
<p>Erfasst: <span className="text-gray-300">{new Date(lead.capturedAt).toLocaleString("de-DE")}</span></p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags (read-only) */}
|
||||||
<div className="bg-[#111118] rounded-lg border border-[#1e1e2e] p-3 space-y-2">
|
{tags.length > 0 && (
|
||||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Tags</h3>
|
<div>
|
||||||
|
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-2">Tags</p>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{tags.map(tag => (
|
{tags.map(tag => (
|
||||||
<span key={tag} className={`flex items-center gap-1 px-2 py-0.5 rounded-full text-xs border ${tagColor(tag)}`}>
|
<span key={tag} className={`px-2 py-0.5 rounded-full text-xs border ${tagColor(tag)}`}>{tag}</span>
|
||||||
{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>
|
</div>
|
||||||
|
|
||||||
|
{/* Delete */}
|
||||||
|
<div className="p-4 border-t border-[#1e1e2e] flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => { onDelete(lead.id); onClose(); }}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 text-sm transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" /> Lead löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -385,8 +287,9 @@ export default function LeadVaultPage() {
|
|||||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
const [filterStatus, setFilterStatus] = useState<string[]>([]);
|
const [filterStatus, setFilterStatus] = useState<string[]>([]);
|
||||||
const [filterSource, setFilterSource] = useState<string[]>([]);
|
const [filterSource, setFilterSource] = useState<string[]>([]);
|
||||||
const [filterPriority, setFilterPriority] = useState<string[]>([]);
|
|
||||||
const [filterHasEmail, setFilterHasEmail] = useState("");
|
const [filterHasEmail, setFilterHasEmail] = useState("");
|
||||||
|
const [filterContacted, setFilterContacted] = useState(false);
|
||||||
|
const [filterFavorite, setFilterFavorite] = useState(false);
|
||||||
const [filterSearchTerms, setFilterSearchTerms] = useState<string[]>([]);
|
const [filterSearchTerms, setFilterSearchTerms] = useState<string[]>([]);
|
||||||
const [availableSearchTerms, setAvailableSearchTerms] = useState<string[]>([]);
|
const [availableSearchTerms, setAvailableSearchTerms] = useState<string[]>([]);
|
||||||
|
|
||||||
@@ -404,8 +307,10 @@ export default function LeadVaultPage() {
|
|||||||
// Side panel
|
// Side panel
|
||||||
const [panelLead, setPanelLead] = useState<Lead | null>(null);
|
const [panelLead, setPanelLead] = useState<Lead | null>(null);
|
||||||
|
|
||||||
|
// Export dropdown
|
||||||
|
const [exportOpen, setExportOpen] = useState(false);
|
||||||
|
|
||||||
// Bulk
|
// Bulk
|
||||||
const [bulkStatus, setBulkStatus] = useState("");
|
|
||||||
const [bulkTag, setBulkTag] = useState("");
|
const [bulkTag, setBulkTag] = useState("");
|
||||||
|
|
||||||
// Debounce search
|
// Debounce search
|
||||||
@@ -428,8 +333,9 @@ export default function LeadVaultPage() {
|
|||||||
});
|
});
|
||||||
filterStatus.forEach(s => params.append("status", s));
|
filterStatus.forEach(s => params.append("status", s));
|
||||||
filterSource.forEach(s => params.append("sourceTab", s));
|
filterSource.forEach(s => params.append("sourceTab", s));
|
||||||
filterPriority.forEach(p => params.append("priority", p));
|
|
||||||
if (filterHasEmail) params.set("hasEmail", filterHasEmail);
|
if (filterHasEmail) params.set("hasEmail", filterHasEmail);
|
||||||
|
if (filterContacted) params.set("contacted", "yes");
|
||||||
|
if (filterFavorite) params.set("favorite", "yes");
|
||||||
filterSearchTerms.forEach(t => params.append("searchTerm", t));
|
filterSearchTerms.forEach(t => params.append("searchTerm", t));
|
||||||
|
|
||||||
const res = await fetch(`/api/leads?${params}`);
|
const res = await fetch(`/api/leads?${params}`);
|
||||||
@@ -442,7 +348,7 @@ export default function LeadVaultPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, perPage, debouncedSearch, sortBy, sortDir, filterStatus, filterSource, filterPriority, filterHasEmail, filterSearchTerms]);
|
}, [page, perPage, debouncedSearch, sortBy, sortDir, filterStatus, filterSource, filterHasEmail, filterContacted, filterFavorite, filterSearchTerms]);
|
||||||
|
|
||||||
const loadSearchTerms = useCallback(async () => {
|
const loadSearchTerms = useCallback(async () => {
|
||||||
const res = await fetch("/api/leads/search-terms");
|
const res = await fetch("/api/leads/search-terms");
|
||||||
@@ -466,7 +372,8 @@ export default function LeadVaultPage() {
|
|||||||
|
|
||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
setSearch(""); setFilterStatus([]); setFilterSource([]);
|
setSearch(""); setFilterStatus([]); setFilterSource([]);
|
||||||
setFilterPriority([]); setFilterHasEmail(""); setFilterSearchTerms([]);
|
setFilterHasEmail(""); setFilterContacted(false); setFilterFavorite(false);
|
||||||
|
setFilterSearchTerms([]);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,7 +458,7 @@ export default function LeadVaultPage() {
|
|||||||
const allSelected = leads.length > 0 && leads.every(l => selected.has(l.id));
|
const allSelected = leads.length > 0 && leads.every(l => selected.has(l.id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5 max-w-[1400px]">
|
<div className="space-y-5 max-w-full">
|
||||||
{/* Header */}
|
{/* 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="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="absolute inset-0 bg-gradient-to-br from-purple-500/5 to-transparent" />
|
||||||
@@ -561,34 +468,37 @@ export default function LeadVaultPage() {
|
|||||||
<Database className="w-4 h-4" />
|
<Database className="w-4 h-4" />
|
||||||
<span>Zentrale Datenbank</span>
|
<span>Zentrale Datenbank</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-white">🗄️ LeadVault</h1>
|
<h1 className="text-2xl font-bold text-white">🗄️ Leadspeicher</h1>
|
||||||
<p className="text-gray-400 mt-1 text-sm">Alle Leads aus allen Pipelines an einem Ort.</p>
|
<p className="text-gray-400 mt-1 text-sm">Alle Leads aus allen Pipelines an einem Ort.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<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">
|
<button
|
||||||
<RefreshCw className="w-3.5 h-3.5" /> Aktualisieren
|
onClick={() => setExportOpen(v => !v)}
|
||||||
</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"
|
||||||
<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
|
<Download className="w-3.5 h-3.5" /> Export
|
||||||
</button>
|
</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]">
|
</div>
|
||||||
{[
|
</div>
|
||||||
["Aktuelle Ansicht (CSV)", () => exportFile("csv")],
|
</div>
|
||||||
["Aktuelle Ansicht (Excel)", () => exportFile("xlsx")],
|
|
||||||
["Nur mit E-Mail (CSV)", () => exportFile("csv", true)],
|
{/* Export dropdown — outside overflow-hidden header */}
|
||||||
["Nur mit E-Mail (Excel)", () => exportFile("xlsx", true)],
|
{exportOpen && (
|
||||||
].map(([label, fn]) => (
|
<>
|
||||||
<button key={label as string} onClick={fn as () => void}
|
<div className="fixed inset-0 z-40" onClick={() => setExportOpen(false)} />
|
||||||
|
<div className="fixed top-20 right-6 z-50 bg-[#1a1a28] border border-[#2e2e3e] rounded-lg shadow-2xl p-1 min-w-[230px]">
|
||||||
|
{([
|
||||||
|
["Aktuelle Ansicht", () => exportFile("xlsx")],
|
||||||
|
["Nur mit E-Mail", () => exportFile("xlsx", true)],
|
||||||
|
] as [string, () => void][]).map(([label, fn]) => (
|
||||||
|
<button key={label} onClick={() => { fn(); setExportOpen(false); }}
|
||||||
className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-[#2e2e3e] rounded">
|
className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-[#2e2e3e] rounded">
|
||||||
{label as string}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
</div>
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
{stats && (
|
{stats && (
|
||||||
@@ -616,7 +526,7 @@ export default function LeadVaultPage() {
|
|||||||
onClick={() => setSerpOpen(!serpOpen)}
|
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"
|
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>
|
<span className="flex items-center gap-2"><Search className="w-4 h-4 text-purple-400" />⚡ Schnell neue Suche</span>
|
||||||
{serpOpen ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
{serpOpen ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
{serpOpen && (
|
{serpOpen && (
|
||||||
@@ -705,9 +615,33 @@ export default function LeadVaultPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Kontaktiert toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => { setFilterContacted(v => !v); setPage(1); }}
|
||||||
|
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-all ${
|
||||||
|
filterContacted
|
||||||
|
? "bg-teal-500/20 text-teal-300 border-teal-500/30"
|
||||||
|
: "bg-[#0d0d18] text-gray-500 border-[#2e2e3e] hover:text-gray-300 hover:border-[#4e4e6e]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Kontaktiert
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Favoriten toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => { setFilterFavorite(v => !v); setPage(1); }}
|
||||||
|
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-all ${
|
||||||
|
filterFavorite
|
||||||
|
? "bg-amber-500/20 text-amber-300 border-amber-500/30"
|
||||||
|
: "bg-[#0d0d18] text-gray-500 border-[#2e2e3e] hover:text-gray-300 hover:border-[#4e4e6e]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
★ Favoriten
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Clear + count */}
|
{/* Clear + count */}
|
||||||
<div className="flex items-center gap-2 ml-auto">
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
{(search || filterStatus.length || filterSource.length || filterPriority.length || filterHasEmail || filterSearchTerms.length) && (
|
{(search || filterHasEmail || filterContacted || filterFavorite || filterSearchTerms.length) && (
|
||||||
<button onClick={clearFilters} className="text-xs text-gray-500 hover:text-white flex items-center gap-1">
|
<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
|
<X className="w-3 h-3" /> Filter zurücksetzen
|
||||||
</button>
|
</button>
|
||||||
@@ -716,61 +650,6 @@ export default function LeadVaultPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* Search term filter chips */}
|
|
||||||
{availableSearchTerms.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="w-px bg-[#2e2e3e] mx-1" />
|
|
||||||
<div className="flex gap-1 flex-wrap items-center">
|
|
||||||
<span className="text-[10px] text-gray-600 uppercase tracking-wider mr-1">Suchbegriff</span>
|
|
||||||
{availableSearchTerms.map(term => (
|
|
||||||
<button key={term} onClick={() => toggleFilter(filterSearchTerms, setFilterSearchTerms, term)}
|
|
||||||
className={`px-2.5 py-1 rounded-full text-xs border transition-all ${
|
|
||||||
filterSearchTerms.includes(term)
|
|
||||||
? "bg-amber-500/20 text-amber-300 border-amber-500/30"
|
|
||||||
: "border-[#2e2e3e] text-gray-600 hover:text-gray-400"
|
|
||||||
}`}>
|
|
||||||
🔎 {term}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Bulk Actions */}
|
{/* Bulk Actions */}
|
||||||
@@ -778,14 +657,6 @@ export default function LeadVaultPage() {
|
|||||||
<div className="flex items-center gap-3 bg-purple-500/10 border border-purple-500/20 rounded-xl px-4 py-2.5">
|
<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>
|
<span className="text-sm text-purple-300 font-medium">{selected.size} ausgewählt</span>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-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">
|
<div className="flex gap-1">
|
||||||
<input
|
<input
|
||||||
value={bulkTag}
|
value={bulkTag}
|
||||||
@@ -824,8 +695,6 @@ export default function LeadVaultPage() {
|
|||||||
</button>
|
</button>
|
||||||
</th>
|
</th>
|
||||||
{[
|
{[
|
||||||
["status", "Status"],
|
|
||||||
["priority", "Priorität"],
|
|
||||||
["companyName", "Unternehmen"],
|
["companyName", "Unternehmen"],
|
||||||
["contactName", "Kontakt"],
|
["contactName", "Kontakt"],
|
||||||
["phone", "Telefon"],
|
["phone", "Telefon"],
|
||||||
@@ -846,17 +715,17 @@ export default function LeadVaultPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{loading && !leads.length ? (
|
{loading && !leads.length ? (
|
||||||
<tr><td colSpan={10} className="px-4 py-8 text-center text-gray-500 text-sm">Lädt...</td></tr>
|
<tr><td colSpan={8} className="px-4 py-8 text-center text-gray-500 text-sm">Lädt...</td></tr>
|
||||||
) : leads.length === 0 ? (
|
) : leads.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={10} className="px-4 py-16 text-center">
|
<td colSpan={8} className="px-4 py-16 text-center">
|
||||||
<Database className="w-8 h-8 text-gray-700 mx-auto mb-3" />
|
<Database className="w-8 h-8 text-gray-700 mx-auto mb-3" />
|
||||||
<p className="text-gray-500 text-sm">
|
<p className="text-gray-500 text-sm">
|
||||||
{(search || filterStatus.length || filterSource.length)
|
{(search || filterSource.length || filterContacted || filterFavorite)
|
||||||
? "Keine Leads für diese Filter."
|
? "Keine Leads für diese Filter."
|
||||||
: "Noch keine Leads. Pipeline ausführen oder Quick SERP nutzen."}
|
: "Noch keine Leads. Pipeline ausführen oder Quick SERP nutzen."}
|
||||||
</p>
|
</p>
|
||||||
{(search || filterStatus.length) && (
|
{search && (
|
||||||
<button onClick={clearFilters} className="mt-2 text-xs text-purple-400 hover:underline">Filter zurücksetzen</button>
|
<button onClick={clearFilters} className="mt-2 text-xs text-purple-400 hover:underline">Filter zurücksetzen</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
@@ -864,10 +733,10 @@ export default function LeadVaultPage() {
|
|||||||
) : leads.map((lead, i) => {
|
) : leads.map((lead, i) => {
|
||||||
const tags: string[] = JSON.parse(lead.tags || "[]");
|
const tags: string[] = JSON.parse(lead.tags || "[]");
|
||||||
const isSelected = selected.has(lead.id);
|
const isSelected = selected.has(lead.id);
|
||||||
const isHigh = lead.priority === "high";
|
|
||||||
const cfg = STATUS_CONFIG[lead.status] || STATUS_CONFIG.new;
|
const cfg = STATUS_CONFIG[lead.status] || STATUS_CONFIG.new;
|
||||||
const prio = PRIORITY_CONFIG[lead.priority] || PRIORITY_CONFIG.normal;
|
|
||||||
const src = SOURCE_CONFIG[lead.sourceTab];
|
const src = SOURCE_CONFIG[lead.sourceTab];
|
||||||
|
const isFavorite = tags.includes("favorit");
|
||||||
|
const isContacted = tags.includes("kontaktiert");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
@@ -875,7 +744,7 @@ export default function LeadVaultPage() {
|
|||||||
onClick={() => setPanelLead(lead)}
|
onClick={() => setPanelLead(lead)}
|
||||||
className={`border-b border-[#1a1a28] cursor-pointer transition-colors hover:bg-[#1a1a28] ${
|
className={`border-b border-[#1a1a28] cursor-pointer transition-colors hover:bg-[#1a1a28] ${
|
||||||
isSelected ? "bg-[#1a1a2e]" : i % 2 === 0 ? "bg-[#111118]" : "bg-[#0f0f16]"
|
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()}>
|
<td className="px-3 py-2.5" onClick={e => e.stopPropagation()}>
|
||||||
<button onClick={() => {
|
<button onClick={() => {
|
||||||
@@ -890,15 +759,14 @@ export default function LeadVaultPage() {
|
|||||||
: <Square className="w-4 h-4 text-gray-600" />}
|
: <Square className="w-4 h-4 text-gray-600" />}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2.5" onClick={e => e.stopPropagation()}>
|
<td className="px-3 py-2.5 max-w-[220px]">
|
||||||
<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-sm text-white truncate font-medium">{lead.companyName || "–"}</p>
|
||||||
<p className="text-xs text-gray-500 truncate">{lead.domain}</p>
|
<p className="text-xs text-gray-500 truncate">{lead.domain}</p>
|
||||||
|
{(lead.description || lead.industry) && (
|
||||||
|
<p className="text-[10px] text-gray-600 truncate mt-0.5" title={lead.description || lead.industry || ""}>
|
||||||
|
{lead.industry ? `${lead.industry}${lead.description ? " · " + lead.description : ""}` : lead.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2.5 max-w-[160px]">
|
<td className="px-3 py-2.5 max-w-[160px]">
|
||||||
<p className="text-sm text-gray-300 truncate">{lead.contactName || "–"}</p>
|
<p className="text-sm text-gray-300 truncate">{lead.contactName || "–"}</p>
|
||||||
@@ -931,14 +799,6 @@ export default function LeadVaultPage() {
|
|||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-gray-600">–</span>
|
<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>
|
||||||
<td className="px-3 py-2.5 max-w-[160px]">
|
<td className="px-3 py-2.5 max-w-[160px]">
|
||||||
{lead.sourceTerm ? (
|
{lead.sourceTerm ? (
|
||||||
@@ -961,7 +821,7 @@ export default function LeadVaultPage() {
|
|||||||
<span className="text-xs text-gray-400">{src?.icon} {src?.label || lead.sourceTab}</span>
|
<span className="text-xs text-gray-400">{src?.icon} {src?.label || lead.sourceTab}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2.5 whitespace-nowrap" title={new Date(lead.capturedAt).toLocaleString("de-DE")}>
|
<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>
|
<span className="text-xs text-gray-500">{new Date(lead.capturedAt).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" })}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2.5">
|
<td className="px-3 py-2.5">
|
||||||
<div className="flex gap-1 flex-wrap max-w-[120px]">
|
<div className="flex gap-1 flex-wrap max-w-[120px]">
|
||||||
@@ -973,21 +833,39 @@ export default function LeadVaultPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2.5" onClick={e => e.stopPropagation()}>
|
<td className="px-3 py-2.5" onClick={e => e.stopPropagation()}>
|
||||||
<div className="flex items-center gap-1 justify-end">
|
<div className="flex items-center gap-1 justify-end">
|
||||||
<button onClick={() => updateLead(lead.id, { status: "contacted" })}
|
<button
|
||||||
title="Als kontaktiert markieren"
|
onClick={e => {
|
||||||
className="p-1 rounded text-gray-600 hover:text-teal-400 hover:bg-teal-500/10 transition-all">
|
e.stopPropagation();
|
||||||
|
if (isContacted) {
|
||||||
|
updateLead(lead.id, { tags: JSON.stringify(tags.filter(t => t !== "kontaktiert")) });
|
||||||
|
} else {
|
||||||
|
updateLead(lead.id, { tags: JSON.stringify([...tags, "kontaktiert"]) });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Kontaktiert"
|
||||||
|
className={isContacted
|
||||||
|
? "p-1 rounded text-teal-400 bg-teal-500/10 transition-all"
|
||||||
|
: "p-1 rounded text-gray-600 hover:text-teal-400 hover:bg-teal-500/10 transition-all"}>
|
||||||
<Mail className="w-3.5 h-3.5" />
|
<Mail className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => {
|
<button
|
||||||
const cycle: Record<string, string> = { low: "normal", normal: "high", high: "low" };
|
onClick={e => {
|
||||||
updateLead(lead.id, { priority: cycle[lead.priority] || "normal" });
|
e.stopPropagation();
|
||||||
|
if (isFavorite) {
|
||||||
|
updateLead(lead.id, { tags: JSON.stringify(tags.filter(t => t !== "favorit")) });
|
||||||
|
} else {
|
||||||
|
updateLead(lead.id, { tags: JSON.stringify([...tags, "favorit"]) });
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
title="Priorität wechseln"
|
title="Favorit"
|
||||||
className="p-1 rounded text-gray-600 hover:text-amber-400 hover:bg-amber-500/10 transition-all">
|
className={isFavorite
|
||||||
|
? "p-1 rounded text-amber-400 bg-amber-500/10 transition-all"
|
||||||
|
: "p-1 rounded text-gray-600 hover:text-amber-400 hover:bg-amber-500/10 transition-all"}>
|
||||||
<Star className="w-3.5 h-3.5" />
|
<Star className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
{lead.domain && (
|
{lead.domain && (
|
||||||
<a href={`https://${lead.domain}`} target="_blank" rel="noreferrer"
|
<a href={`https://${lead.domain}`} target="_blank" rel="noreferrer"
|
||||||
|
title="Website öffnen"
|
||||||
className="p-1 rounded text-gray-600 hover:text-blue-400 hover:bg-blue-500/10 transition-all">
|
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" />
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
</a>
|
</a>
|
||||||
@@ -1053,8 +931,10 @@ export default function LeadVaultPage() {
|
|||||||
lead={panelLead}
|
lead={panelLead}
|
||||||
onClose={() => setPanelLead(null)}
|
onClose={() => setPanelLead(null)}
|
||||||
onUpdate={updated => {
|
onUpdate={updated => {
|
||||||
|
setPanelLead(prev => prev ? { ...prev, ...updated } : prev);
|
||||||
setLeads(prev => prev.map(l => l.id === panelLead.id ? { ...l, ...updated } : l));
|
setLeads(prev => prev.map(l => l.id === panelLead.id ? { ...l, ...updated } : l));
|
||||||
}}
|
}}
|
||||||
|
onDelete={id => { deleteLead(id); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -176,10 +176,6 @@ export default function LinkedInPage() {
|
|||||||
contact_name: r.contactName,
|
contact_name: r.contactName,
|
||||||
contact_title: r.contactTitle,
|
contact_title: r.contactTitle,
|
||||||
email: r.email,
|
email: r.email,
|
||||||
confidence_score: r.confidence !== undefined ? Math.round(r.confidence * 100) : undefined,
|
|
||||||
source_tab: "linkedin",
|
|
||||||
job_id: scrapeJobId || "",
|
|
||||||
found_at: new Date().toISOString(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const emailsFound = results.filter(r => r.email).length;
|
const emailsFound = results.filter(r => r.email).length;
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ export default function MapsPage() {
|
|||||||
const [jobId, setJobId] = useState<string | null>(null);
|
const [jobId, setJobId] = useState<string | null>(null);
|
||||||
const [progress, setProgress] = useState({ current: 0, total: 0, phase: "" });
|
const [progress, setProgress] = useState({ current: 0, total: 0, phase: "" });
|
||||||
const [results, setResults] = useState<ResultRow[]>([]);
|
const [results, setResults] = useState<ResultRow[]>([]);
|
||||||
|
const [enrichStage, setEnrichStage] = useState<Stage>("idle");
|
||||||
|
const [enrichProgress, setEnrichProgress] = useState({ current: 0, total: 0 });
|
||||||
const { addJob, updateJob, removeJob } = useAppStore();
|
const { addJob, updateJob, removeJob } = useAppStore();
|
||||||
|
|
||||||
// Build queries: every keyword × every region
|
// Build queries: every keyword × every region
|
||||||
@@ -162,20 +164,74 @@ export default function MapsPage() {
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startEnrichment = async () => {
|
||||||
|
const companies = results
|
||||||
|
.filter(r => r.domain)
|
||||||
|
.map(r => ({ name: r.companyName || "", domain: r.domain || "" }));
|
||||||
|
if (!companies.length) return toast.error("Keine Domains in den Ergebnissen");
|
||||||
|
|
||||||
|
setEnrichStage("running");
|
||||||
|
setEnrichProgress({ current: 0, total: companies.length });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/jobs/airscale-enrich", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ companies, categories: [category] }),
|
||||||
|
});
|
||||||
|
const data = await res.json() as { jobId?: string; error?: string };
|
||||||
|
if (!res.ok || !data.jobId) throw new Error(data.error || "Failed");
|
||||||
|
addJob({ id: data.jobId, type: "airscale", status: "running", progress: 0, total: companies.length });
|
||||||
|
pollEnrichJob(data.jobId);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Anreicherung konnte nicht gestartet werden");
|
||||||
|
setEnrichStage("failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pollEnrichJob = (id: string) => {
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/jobs/${id}/status`);
|
||||||
|
const data = await res.json() as {
|
||||||
|
status: string; totalLeads: number; emailsFound: number; results: ResultRow[]; error?: string;
|
||||||
|
};
|
||||||
|
setEnrichProgress({ current: data.emailsFound, total: data.totalLeads });
|
||||||
|
updateJob(id, { status: data.status, progress: data.emailsFound, total: data.totalLeads });
|
||||||
|
|
||||||
|
if (data.status === "complete" || data.status === "failed") {
|
||||||
|
clearInterval(interval);
|
||||||
|
removeJob(id);
|
||||||
|
setEnrichStage(data.status === "complete" ? "done" : "failed");
|
||||||
|
if (data.status === "complete") {
|
||||||
|
const emailMap = new Map(
|
||||||
|
(data.results || []).filter(r => r.email).map(r => [r.domain, r])
|
||||||
|
);
|
||||||
|
setResults(prev => prev.map(r => {
|
||||||
|
const enriched = r.domain ? emailMap.get(r.domain) : undefined;
|
||||||
|
return enriched ? { ...r, email: enriched.email, contactName: enriched.contactName, contactTitle: enriched.contactTitle } : r;
|
||||||
|
}));
|
||||||
|
toast.success(`${data.emailsFound} Entscheider-Emails gefunden`);
|
||||||
|
} else {
|
||||||
|
toast.error(`Anreicherung fehlgeschlagen: ${data.error || "Unbekannter Fehler"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
clearInterval(interval);
|
||||||
|
setEnrichStage("failed");
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
const exportRows: ExportRow[] = results.map(r => ({
|
const exportRows: ExportRow[] = results.map(r => ({
|
||||||
company_name: r.companyName,
|
company_name: r.companyName,
|
||||||
domain: r.domain,
|
domain: r.domain,
|
||||||
contact_name: r.contactName,
|
contact_name: r.contactName,
|
||||||
contact_title: r.contactTitle,
|
contact_title: r.contactTitle,
|
||||||
email: r.email,
|
email: r.email,
|
||||||
confidence_score: r.confidence !== undefined ? Math.round(r.confidence * 100) : undefined,
|
|
||||||
source_tab: "maps",
|
|
||||||
job_id: jobId || "",
|
|
||||||
found_at: new Date().toISOString(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const emailsFound = results.filter(r => r.email).length;
|
const emailsFound = results.filter(r => r.email).length;
|
||||||
const hitRate = results.length > 0 ? Math.round((emailsFound / results.length) * 100) : 0;
|
|
||||||
const totalExpected = queries.length * Number(maxResults);
|
const totalExpected = queries.length * Number(maxResults);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -420,7 +476,7 @@ export default function MapsPage() {
|
|||||||
title="Fertig"
|
title="Fertig"
|
||||||
current={enrichEmails ? emailsFound : results.length}
|
current={enrichEmails ? emailsFound : results.length}
|
||||||
total={results.length}
|
total={results.length}
|
||||||
subtitle={enrichEmails ? `Hit Rate: ${hitRate}%` : `${results.length} Unternehmen gefunden`}
|
subtitle={enrichEmails ? `${emailsFound} E-Mails gefunden` : `${results.length} Unternehmen gefunden`}
|
||||||
status="complete"
|
status="complete"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -431,14 +487,69 @@ export default function MapsPage() {
|
|||||||
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4">
|
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
<h2 className="text-base font-semibold text-white">
|
<h2 className="text-base font-semibold text-white">
|
||||||
Ergebnisse ({results.length} Unternehmen{enrichEmails && emailsFound > 0 ? `, ${emailsFound} Emails` : ""})
|
Ergebnisse ({results.length} Unternehmen{emailsFound > 0 ? `, ${emailsFound} Emails` : ""})
|
||||||
</h2>
|
</h2>
|
||||||
<ExportButtons
|
<ExportButtons
|
||||||
rows={exportRows}
|
rows={exportRows}
|
||||||
filename={`maps-leads-${jobId?.slice(0, 8) || "export"}`}
|
filename={`maps-leads-${jobId?.slice(0, 8) || "export"}`}
|
||||||
summary={enrichEmails ? `${emailsFound} Emails · ${hitRate}% Hit Rate` : `${results.length} Unternehmen`}
|
summary={emailsFound > 0 ? `${emailsFound} E-Mails gefunden` : `${results.length} Unternehmen`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Post-enrichment CTA */}
|
||||||
|
{stage === "done" && !enrichEmails && enrichStage === "idle" && (
|
||||||
|
<div className="flex items-center justify-between gap-4 bg-green-500/5 border border-green-500/20 rounded-xl px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-white font-medium">{results.length} Unternehmen gefunden — jetzt Entscheider-Emails suchen?</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">Über Anymailfinder · 2 Credits pro gefundener Email</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 flex-shrink-0">
|
||||||
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
|
{CATEGORY_OPTIONS.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => setCategory(opt.value)}
|
||||||
|
className={`px-2.5 py-1 rounded-lg text-xs font-medium border transition-all ${
|
||||||
|
category === opt.value
|
||||||
|
? "bg-green-500/20 text-green-300 border-green-500/40"
|
||||||
|
: "bg-[#0d0d18] text-gray-500 border-[#2e2e3e] hover:border-green-500/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label.split(" / ")[0]}
|
||||||
|
{opt.recommended && <span className="ml-1 text-[9px] text-green-400">★</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={startEnrichment}
|
||||||
|
className="bg-gradient-to-r from-green-500 to-teal-600 hover:from-green-600 hover:to-teal-700 text-white font-medium px-5 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Entscheider-Emails finden
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{enrichStage === "running" && (
|
||||||
|
<ProgressCard
|
||||||
|
title="Entscheider-Emails werden gesucht..."
|
||||||
|
current={enrichProgress.current}
|
||||||
|
total={enrichProgress.total}
|
||||||
|
subtitle="Anymailfinder Bulk Enrichment"
|
||||||
|
status="running"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{enrichStage === "done" && (
|
||||||
|
<ProgressCard
|
||||||
|
title="Anreicherung abgeschlossen"
|
||||||
|
current={emailsFound}
|
||||||
|
total={results.length}
|
||||||
|
subtitle={`${emailsFound} Entscheider-Emails gefunden`}
|
||||||
|
status="complete"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<ResultsTable
|
<ResultsTable
|
||||||
rows={results}
|
rows={results}
|
||||||
loading={stage === "running" && results.length === 0}
|
loading={stage === "running" && results.length === 0}
|
||||||
|
|||||||
@@ -72,9 +72,6 @@ export default function ResultsPage() {
|
|||||||
window.open(`/api/export/${id}`, "_blank");
|
window.open(`/api/export/${id}`, "_blank");
|
||||||
};
|
};
|
||||||
|
|
||||||
const hitRate = (job: Job) =>
|
|
||||||
job.totalLeads > 0 ? Math.round((job.emailsFound / job.totalLeads) * 100) : 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-6xl">
|
<div className="space-y-6 max-w-6xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -138,7 +135,6 @@ export default function ResultsPage() {
|
|||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Status</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Status</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Leads</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Leads</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">E-Mails</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">E-Mails</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Trefferquote</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Aktionen</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -172,21 +168,12 @@ export default function ResultsPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-white font-medium">{job.totalLeads.toLocaleString()}</td>
|
<td className="px-4 py-3 text-white font-medium">{job.totalLeads.toLocaleString()}</td>
|
||||||
<td className="px-4 py-3 text-green-400 font-medium">{job.emailsFound.toLocaleString()}</td>
|
<td className="px-4 py-3 text-green-400 font-medium">{job.emailsFound.toLocaleString()}</td>
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className={cn(
|
|
||||||
"text-xs font-medium",
|
|
||||||
hitRate(job) >= 50 ? "text-green-400" : hitRate(job) >= 20 ? "text-yellow-400" : "text-red-400"
|
|
||||||
)}>
|
|
||||||
{hitRate(job)}%
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => downloadJob(job.id)}
|
onClick={() => downloadJob(job.id)}
|
||||||
disabled={job.status !== "complete"}
|
|
||||||
className="h-7 px-2 text-gray-400 hover:text-white hover:bg-[#1a1a28]"
|
className="h-7 px-2 text-gray-400 hover:text-white hover:bg-[#1a1a28]"
|
||||||
>
|
>
|
||||||
<Download className="w-3.5 h-3.5" />
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
|||||||
@@ -122,14 +122,9 @@ export default function SerpPage() {
|
|||||||
contact_name: r.contactName,
|
contact_name: r.contactName,
|
||||||
contact_title: r.contactTitle,
|
contact_title: r.contactTitle,
|
||||||
email: r.email,
|
email: r.email,
|
||||||
confidence_score: r.confidence !== undefined ? Math.round(r.confidence * 100) : undefined,
|
|
||||||
source_tab: "serp",
|
|
||||||
job_id: jobId || "",
|
|
||||||
found_at: new Date().toISOString(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const emailsFound = results.filter(r => r.email).length;
|
const emailsFound = results.filter(r => r.email).length;
|
||||||
const hitRate = results.length > 0 ? Math.round((emailsFound / results.length) * 100) : 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-5xl">
|
<div className="space-y-6 max-w-5xl">
|
||||||
@@ -292,7 +287,7 @@ export default function SerpPage() {
|
|||||||
title="Pipeline abgeschlossen"
|
title="Pipeline abgeschlossen"
|
||||||
current={emailsFound}
|
current={emailsFound}
|
||||||
total={results.length}
|
total={results.length}
|
||||||
subtitle={`Trefferquote: ${hitRate}% · ${results.length} Domains`}
|
subtitle={`${emailsFound} E-Mails gefunden · ${results.length} Domains`}
|
||||||
status="complete"
|
status="complete"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -306,7 +301,7 @@ export default function SerpPage() {
|
|||||||
<ExportButtons
|
<ExportButtons
|
||||||
rows={exportRows}
|
rows={exportRows}
|
||||||
filename={`serp-leads-${jobId?.slice(0, 8) || "export"}`}
|
filename={`serp-leads-${jobId?.slice(0, 8) || "export"}`}
|
||||||
summary={`${emailsFound} E-Mails gefunden • ${hitRate}% Trefferquote`}
|
summary={`${emailsFound} E-Mails gefunden`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ResultsTable rows={results} loading={stage === "running" && results.length === 0} />
|
<ResultsTable rows={results} loading={stage === "running" && results.length === 0} />
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ 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: "/leadvault", icon: Database, label: "LeadVault", color: "text-violet-400", badge: true },
|
{ href: "/leadvault", icon: Database, label: "Leadspeicher", color: "text-violet-400", badge: true },
|
||||||
{ href: "/results", icon: BarChart3, label: "Ergebnisse & Verlauf", color: "text-yellow-400" },
|
{ href: "/results", icon: BarChart3, label: "Ergebnisse & Verlauf", color: "text-yellow-400" },
|
||||||
{ href: "/settings", icon: Settings, label: "Einstellungen", color: "text-gray-400" },
|
{ href: "/settings", icon: Settings, label: "Einstellungen", color: "text-gray-400" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -9,7 +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",
|
"/leadvault": "🗄️ Leadspeicher",
|
||||||
"/results": "Ergebnisse & Verlauf",
|
"/results": "Ergebnisse & Verlauf",
|
||||||
"/settings": "Einstellungen",
|
"/settings": "Einstellungen",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Download, FileSpreadsheet } from "lucide-react";
|
import { FileSpreadsheet } from "lucide-react";
|
||||||
import { exportToCSV, exportToExcel, type ExportRow } from "@/lib/utils/csv";
|
import { exportToExcel, type ExportRow } from "@/lib/utils/csv";
|
||||||
|
|
||||||
interface ExportButtonsProps {
|
interface ExportButtonsProps {
|
||||||
rows: ExportRow[];
|
rows: ExportRow[];
|
||||||
@@ -15,15 +15,6 @@ export function ExportButtons({ rows, filename, disabled, summary }: ExportButto
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
{summary && <span className="text-sm text-gray-400">{summary}</span>}
|
{summary && <span className="text-sm text-gray-400">{summary}</span>}
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={disabled || rows.length === 0}
|
|
||||||
onClick={() => exportToCSV(rows, `${filename}.csv`)}
|
|
||||||
className="border-[#2e2e3e] hover:border-blue-500/50 hover:bg-blue-500/5 text-gray-300"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4 mr-1.5" /> CSV herunterladen
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function ProgressCard({ title, current, total, subtitle, status = "runnin
|
|||||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: "0ms" }} />
|
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: "0ms" }} />
|
||||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: "150ms" }} />
|
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: "150ms" }} />
|
||||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: "300ms" }} />
|
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: "300ms" }} />
|
||||||
<span className="text-blue-300 ml-1">Warte auf Anymailfinder-Server...</span>
|
<span className="text-blue-300 ml-1">Suche läuft...</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-600">{total} Domains</span>
|
<span className="text-gray-600">{total} Domains</span>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { CheckCircle2, XCircle, AlertCircle, ChevronUp, ChevronDown } from "lucide-react";
|
import { CheckCircle2, XCircle, ChevronUp, ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
export interface ResultRow {
|
export interface ResultRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -12,7 +12,6 @@ export interface ResultRow {
|
|||||||
contactName?: string;
|
contactName?: string;
|
||||||
contactTitle?: string;
|
contactTitle?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
confidence?: number;
|
|
||||||
linkedinUrl?: string;
|
linkedinUrl?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
@@ -26,9 +25,8 @@ interface ResultsTableProps {
|
|||||||
extraColumns?: Array<{ key: string; label: string }>;
|
extraColumns?: Array<{ key: string; label: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmailStatusIcon({ email, confidence }: { email?: string; confidence?: number }) {
|
function EmailStatusIcon({ email }: { email?: string }) {
|
||||||
if (!email) return <XCircle className="w-4 h-4 text-red-400" />;
|
if (!email) return <XCircle className="w-4 h-4 text-red-400" />;
|
||||||
if (confidence && confidence < 0.7) return <AlertCircle className="w-4 h-4 text-yellow-400" />;
|
|
||||||
return <CheckCircle2 className="w-4 h-4 text-green-400" />;
|
return <CheckCircle2 className="w-4 h-4 text-green-400" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +84,6 @@ export function ResultsTable({ rows, loading, selectable, onSelectionChange, ext
|
|||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Kontakt</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Kontakt</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Position</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Position</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">E-Mail</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">E-Mail</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Konfidenz</th>
|
|
||||||
{extraColumns?.map(col => (
|
{extraColumns?.map(col => (
|
||||||
<th key={col.key} className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">{col.label}</th>
|
<th key={col.key} className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">{col.label}</th>
|
||||||
))}
|
))}
|
||||||
@@ -120,17 +117,10 @@ export function ResultsTable({ rows, loading, selectable, onSelectionChange, ext
|
|||||||
<td className="px-4 py-2.5 text-gray-400 text-xs">{row.contactTitle || "—"}</td>
|
<td className="px-4 py-2.5 text-gray-400 text-xs">{row.contactTitle || "—"}</td>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<EmailStatusIcon email={row.email} confidence={row.confidence} />
|
<EmailStatusIcon email={row.email} />
|
||||||
<span className="text-gray-300 font-mono text-xs">{row.email || "—"}</span>
|
<span className="text-gray-300 font-mono text-xs">{row.email || "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5">
|
|
||||||
{row.confidence !== undefined ? (
|
|
||||||
<span className={cn("text-xs font-medium", row.confidence >= 0.8 ? "text-green-400" : row.confidence >= 0.6 ? "text-yellow-400" : "text-red-400")}>
|
|
||||||
{Math.round(row.confidence * 100)}%
|
|
||||||
</span>
|
|
||||||
) : "—"}
|
|
||||||
</td>
|
|
||||||
{extraColumns?.map(col => (
|
{extraColumns?.map(col => (
|
||||||
<td key={col.key} className="px-4 py-2.5 text-gray-400 text-xs">
|
<td key={col.key} className="px-4 py-2.5 text-gray-400 text-xs">
|
||||||
{((row as unknown) as Record<string, string>)[col.key] || "—"}
|
{((row as unknown) as Record<string, string>)[col.key] || "—"}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
// Bulk API processes ~1,000 rows per 5 minutes asynchronously.
|
// Bulk API processes ~1,000 rows per 5 minutes asynchronously.
|
||||||
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { extractDomainFromUrl } from "@/lib/utils/domains";
|
||||||
|
|
||||||
const BASE_URL = "https://api.anymailfinder.com/v5.1";
|
const BASE_URL = "https://api.anymailfinder.com/v5.1";
|
||||||
|
|
||||||
@@ -141,6 +142,7 @@ export async function getBulkSearchStatus(
|
|||||||
/**
|
/**
|
||||||
* Download bulk search results as JSON array.
|
* Download bulk search results as JSON array.
|
||||||
* IMPORTANT: Credits are charged on first download.
|
* IMPORTANT: Credits are charged on first download.
|
||||||
|
* Handles both array-of-objects and array-of-arrays (with header row) formats.
|
||||||
*/
|
*/
|
||||||
export async function downloadBulkResults(
|
export async function downloadBulkResults(
|
||||||
searchId: string,
|
searchId: string,
|
||||||
@@ -151,7 +153,21 @@ export async function downloadBulkResults(
|
|||||||
headers: { Authorization: apiKey },
|
headers: { Authorization: apiKey },
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
});
|
});
|
||||||
return response.data as Array<Record<string, string>>;
|
|
||||||
|
const raw = response.data;
|
||||||
|
|
||||||
|
// Handle array-of-arrays format (first row = headers)
|
||||||
|
if (Array.isArray(raw) && Array.isArray(raw[0])) {
|
||||||
|
const headers = (raw[0] as string[]).map(h => h.toLowerCase().trim());
|
||||||
|
return (raw as string[][]).slice(1).map(row => {
|
||||||
|
const obj: Record<string, string> = {};
|
||||||
|
headers.forEach((h, i) => { obj[h] = row[i] ?? ""; });
|
||||||
|
return obj;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard array-of-objects format
|
||||||
|
return raw as Array<Record<string, string>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -196,20 +212,35 @@ export async function bulkSearchDomains(
|
|||||||
const rows = await downloadBulkResults(searchId, apiKey);
|
const rows = await downloadBulkResults(searchId, apiKey);
|
||||||
|
|
||||||
// 4. Normalize to DecisionMakerResult[]
|
// 4. Normalize to DecisionMakerResult[]
|
||||||
|
// Log first row to help debug field names
|
||||||
|
if (rows.length > 0) {
|
||||||
|
console.log("[Anymailfinder] Raw response sample row keys:", Object.keys(rows[0]));
|
||||||
|
console.log("[Anymailfinder] Sample row:", JSON.stringify(rows[0]));
|
||||||
|
}
|
||||||
|
|
||||||
return rows.map(row => {
|
return rows.map(row => {
|
||||||
|
const rawDomain = row["domain"] || row["Domain"] || "";
|
||||||
|
const domain = rawDomain ? extractDomainFromUrl(rawDomain.includes(".") ? rawDomain : `https://${rawDomain}`) : "";
|
||||||
|
|
||||||
|
// Anymailfinder bulk JSON uses different field names than the single-search API:
|
||||||
|
// amf_status (not email_status), valid_email_only (not valid_email),
|
||||||
|
// person_name (not person_full_name), result_title (not person_job_title),
|
||||||
|
// linkedin_url (not person_linkedin_url)
|
||||||
const email = row["email"] || row["Email"] || null;
|
const email = row["email"] || row["Email"] || null;
|
||||||
const emailStatus = (row["email_status"] || row["Email Status"] || "not_found").toLowerCase();
|
const rawStatus = row["amf_status"] || row["email_status"] || row["Email Status"] || "not_found";
|
||||||
const validEmail = emailStatus === "valid" ? email : null;
|
// amf_status uses "not found" (space) — normalize to underscore variant
|
||||||
|
const emailStatus = rawStatus.toLowerCase().replace(/\s+/g, "_") as DecisionMakerResult["email_status"];
|
||||||
|
const validEmail = row["valid_email_only"] || row["valid_email"] || (emailStatus === "valid" ? email : null) || null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
domain: row["domain"] || row["Domain"] || "",
|
domain,
|
||||||
decision_maker_category: primaryCategory,
|
decision_maker_category: primaryCategory,
|
||||||
email,
|
email: email || validEmail,
|
||||||
email_status: emailStatus as DecisionMakerResult["email_status"],
|
email_status: emailStatus,
|
||||||
valid_email: validEmail,
|
valid_email: validEmail,
|
||||||
person_full_name: row["person_full_name"] || row["Full Name"] || null,
|
person_full_name: row["person_name"] || row["person_full_name"] || row["Full Name"] || null,
|
||||||
person_job_title: row["person_job_title"] || row["Job Title"] || null,
|
person_job_title: row["result_title"] || row["person_job_title"] || row["Job Title"] || null,
|
||||||
person_linkedin_url: row["person_linkedin_url"] || row["LinkedIn URL"] || null,
|
person_linkedin_url: row["linkedin_url"] || row["person_linkedin_url"] || row["LinkedIn URL"] || null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export async function runGoogleSerpScraper(
|
|||||||
{
|
{
|
||||||
queries: query,
|
queries: query,
|
||||||
maxPagesPerQuery: maxPages,
|
maxPagesPerQuery: maxPages,
|
||||||
countryCode: countryCode.toUpperCase(),
|
countryCode: countryCode.toLowerCase(),
|
||||||
languageCode: languageCode.toLowerCase(),
|
languageCode: languageCode.toLowerCase(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const FIELD_MASK = [
|
|||||||
"places.formattedAddress",
|
"places.formattedAddress",
|
||||||
"places.nationalPhoneNumber",
|
"places.nationalPhoneNumber",
|
||||||
"places.businessStatus",
|
"places.businessStatus",
|
||||||
|
"places.editorialSummary",
|
||||||
|
"places.primaryTypeDisplayName",
|
||||||
"nextPageToken",
|
"nextPageToken",
|
||||||
].join(",");
|
].join(",");
|
||||||
|
|
||||||
@@ -29,6 +31,8 @@ export interface PlaceResult {
|
|||||||
domain: string | null;
|
domain: string | null;
|
||||||
address: string;
|
address: string;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
|
description: string | null;
|
||||||
|
category: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PlacesApiResponse {
|
interface PlacesApiResponse {
|
||||||
@@ -39,6 +43,8 @@ interface PlacesApiResponse {
|
|||||||
formattedAddress?: string;
|
formattedAddress?: string;
|
||||||
nationalPhoneNumber?: string;
|
nationalPhoneNumber?: string;
|
||||||
businessStatus?: string;
|
businessStatus?: string;
|
||||||
|
editorialSummary?: { text: string };
|
||||||
|
primaryTypeDisplayName?: { text: string };
|
||||||
}>;
|
}>;
|
||||||
nextPageToken?: string;
|
nextPageToken?: string;
|
||||||
}
|
}
|
||||||
@@ -85,6 +91,8 @@ export async function searchPlaces(
|
|||||||
domain,
|
domain,
|
||||||
address: place.formattedAddress || "",
|
address: place.formattedAddress || "",
|
||||||
phone: place.nationalPhoneNumber || null,
|
phone: place.nationalPhoneNumber || null,
|
||||||
|
description: place.editorialSummary?.text || null,
|
||||||
|
category: place.primaryTypeDisplayName?.text || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export interface VaultLead {
|
|||||||
linkedinUrl?: string | null;
|
linkedinUrl?: string | null;
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
address?: string | null;
|
address?: string | null;
|
||||||
emailConfidence?: number | null;
|
|
||||||
serpTitle?: string | null;
|
serpTitle?: string | null;
|
||||||
serpSnippet?: string | null;
|
serpSnippet?: string | null;
|
||||||
serpRank?: number | null;
|
serpRank?: number | null;
|
||||||
@@ -17,6 +16,7 @@ export interface VaultLead {
|
|||||||
country?: string | null;
|
country?: string | null;
|
||||||
headcount?: string | null;
|
headcount?: string | null;
|
||||||
industry?: string | null;
|
industry?: string | null;
|
||||||
|
description?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,7 +54,6 @@ export async function sinkLeadToVault(
|
|||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: {
|
data: {
|
||||||
email,
|
email,
|
||||||
emailConfidence: lead.emailConfidence ?? existing.emailConfidence,
|
|
||||||
contactName: lead.contactName || existing.contactName,
|
contactName: lead.contactName || existing.contactName,
|
||||||
contactTitle: lead.contactTitle || existing.contactTitle,
|
contactTitle: lead.contactTitle || existing.contactTitle,
|
||||||
linkedinUrl: lead.linkedinUrl || existing.linkedinUrl,
|
linkedinUrl: lead.linkedinUrl || existing.linkedinUrl,
|
||||||
@@ -76,7 +75,6 @@ export async function sinkLeadToVault(
|
|||||||
linkedinUrl: lead.linkedinUrl || null,
|
linkedinUrl: lead.linkedinUrl || null,
|
||||||
phone: lead.phone || null,
|
phone: lead.phone || null,
|
||||||
address: lead.address || null,
|
address: lead.address || null,
|
||||||
emailConfidence: lead.emailConfidence ?? null,
|
|
||||||
serpTitle: lead.serpTitle || null,
|
serpTitle: lead.serpTitle || null,
|
||||||
serpSnippet: lead.serpSnippet || null,
|
serpSnippet: lead.serpSnippet || null,
|
||||||
serpRank: lead.serpRank ?? null,
|
serpRank: lead.serpRank ?? null,
|
||||||
@@ -84,6 +82,7 @@ export async function sinkLeadToVault(
|
|||||||
country: lead.country || null,
|
country: lead.country || null,
|
||||||
headcount: lead.headcount || null,
|
headcount: lead.headcount || null,
|
||||||
industry: lead.industry || null,
|
industry: lead.industry || null,
|
||||||
|
description: lead.description || null,
|
||||||
sourceTab,
|
sourceTab,
|
||||||
sourceTerm: sourceTerm || null,
|
sourceTerm: sourceTerm || null,
|
||||||
sourceJobId: sourceJobId || null,
|
sourceJobId: sourceJobId || null,
|
||||||
@@ -119,7 +118,6 @@ export async function sinkLeadsToVault(
|
|||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: {
|
data: {
|
||||||
email,
|
email,
|
||||||
emailConfidence: lead.emailConfidence ?? existing.emailConfidence,
|
|
||||||
contactName: lead.contactName || existing.contactName,
|
contactName: lead.contactName || existing.contactName,
|
||||||
contactTitle: lead.contactTitle || existing.contactTitle,
|
contactTitle: lead.contactTitle || existing.contactTitle,
|
||||||
linkedinUrl: lead.linkedinUrl || existing.linkedinUrl,
|
linkedinUrl: lead.linkedinUrl || existing.linkedinUrl,
|
||||||
@@ -142,12 +140,14 @@ export async function sinkLeadsToVault(
|
|||||||
linkedinUrl: lead.linkedinUrl || null,
|
linkedinUrl: lead.linkedinUrl || null,
|
||||||
phone: lead.phone || null,
|
phone: lead.phone || null,
|
||||||
address: lead.address || null,
|
address: lead.address || null,
|
||||||
emailConfidence: lead.emailConfidence ?? null,
|
|
||||||
serpTitle: lead.serpTitle || null,
|
serpTitle: lead.serpTitle || null,
|
||||||
serpSnippet: lead.serpSnippet || null,
|
serpSnippet: lead.serpSnippet || null,
|
||||||
serpRank: lead.serpRank ?? null,
|
serpRank: lead.serpRank ?? null,
|
||||||
serpUrl: lead.serpUrl || null,
|
serpUrl: lead.serpUrl || null,
|
||||||
country: lead.country || null,
|
country: lead.country || null,
|
||||||
|
headcount: lead.headcount || null,
|
||||||
|
industry: lead.industry || null,
|
||||||
|
description: lead.description || null,
|
||||||
sourceTab,
|
sourceTab,
|
||||||
sourceTerm: sourceTerm || null,
|
sourceTerm: sourceTerm || null,
|
||||||
sourceJobId: sourceJobId || null,
|
sourceJobId: sourceJobId || null,
|
||||||
|
|||||||
@@ -28,10 +28,6 @@ export interface ExportRow {
|
|||||||
contact_name?: string;
|
contact_name?: string;
|
||||||
contact_title?: string;
|
contact_title?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
confidence_score?: number | string;
|
|
||||||
source_tab?: string;
|
|
||||||
job_id?: string;
|
|
||||||
found_at?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exportToCSV(rows: ExportRow[], filename: string): void {
|
export function exportToCSV(rows: ExportRow[], filename: string): void {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Lead" ADD COLUMN "description" TEXT;
|
||||||
@@ -73,6 +73,7 @@ model Lead {
|
|||||||
country String?
|
country String?
|
||||||
headcount String?
|
headcount String?
|
||||||
industry String?
|
industry String?
|
||||||
|
description String?
|
||||||
|
|
||||||
capturedAt DateTime @default(now())
|
capturedAt DateTime @default(now())
|
||||||
contactedAt DateTime?
|
contactedAt DateTime?
|
||||||
|
|||||||
Reference in New Issue
Block a user