diff --git a/app/api/leads/export/route.ts b/app/api/leads/export/route.ts index f8258aa..e777fed 100644 --- a/app/api/leads/export/route.ts +++ b/app/api/leads/export/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/db"; import { Prisma } from "@prisma/client"; +import * as XLSX from "xlsx"; export async function GET(req: NextRequest) { try { @@ -11,6 +12,7 @@ export async function GET(req: NextRequest) { const priorities = searchParams.getAll("priority"); const hasEmail = searchParams.get("hasEmail"); const emailOnly = searchParams.get("emailOnly") === "true"; + const format = searchParams.get("format") || "csv"; // "csv" | "xlsx" const where: Prisma.LeadWhereInput = {}; if (search) { @@ -33,57 +35,86 @@ export async function GET(req: NextRequest) { take: 10000, }); - const columns = [ - "status", "priority", "company_name", "domain", "contact_name", - "contact_title", "email", "email_confidence", "linkedin_url", "phone", - "source_tab", "source_term", "tags", "country", "headcount", "industry", - "notes", "captured_at", "contacted_at", "serp_rank", "serp_url", - "serp_title", "serp_snippet", "lead_id", - ]; - const rows = leads.map(l => ({ - status: l.status, - priority: l.priority, - company_name: l.companyName || "", - domain: l.domain || "", - contact_name: l.contactName || "", - contact_title: l.contactTitle || "", - email: l.email || "", - email_confidence: l.emailConfidence != null ? Math.round(l.emailConfidence * 100) + "%" : "", - linkedin_url: l.linkedinUrl || "", - phone: l.phone || "", - source_tab: l.sourceTab, - source_term: l.sourceTerm || "", - tags: l.tags || "", - country: l.country || "", - headcount: l.headcount || "", - industry: l.industry || "", - notes: l.notes || "", - captured_at: l.capturedAt.toISOString(), - contacted_at: l.contactedAt?.toISOString() || "", - serp_rank: l.serpRank?.toString() || "", - serp_url: l.serpUrl || "", - serp_title: l.serpTitle || "", - serp_snippet: l.serpSnippet || "", - lead_id: l.id, + "Status": l.status, + "Priorität": l.priority, + "Unternehmen": l.companyName || "", + "Domain": l.domain || "", + "Kontaktname": l.contactName || "", + "Jobtitel": l.contactTitle || "", + "E-Mail": l.email || "", + "E-Mail Konfidenz": l.emailConfidence != null ? Math.round(l.emailConfidence * 100) + "%" : "", + "LinkedIn": l.linkedinUrl || "", + "Telefon": l.phone || "", + "Quelle": l.sourceTab, + "Suchbegriff": l.sourceTerm || "", + "Tags": l.tags ? JSON.parse(l.tags).join(", ") : "", + "Land": l.country || "", + "Mitarbeiter": l.headcount || "", + "Branche": l.industry || "", + "Notizen": l.notes || "", + "Erfasst am": new Date(l.capturedAt).toLocaleString("de-DE"), + "Kontaktiert am": l.contactedAt ? new Date(l.contactedAt).toLocaleString("de-DE") : "", + "SERP Rang": l.serpRank?.toString() || "", + "SERP URL": l.serpUrl || "", + "SERP Titel": l.serpTitle || "", + "SERP Snippet": l.serpSnippet || "", + "Lead ID": l.id, })); - const csv = [ - columns.join(","), - ...rows.map(r => - columns.map(c => { - const v = String(r[c as keyof typeof r] || ""); - return v.includes(",") || v.includes('"') || v.includes("\n") - ? `"${v.replace(/"/g, '""')}"` - : v; - }).join(",") - ), - ].join("\n"); + const filename = `leadflow-vault-${new Date().toISOString().split("T")[0]}`; + + if (format === "xlsx") { + const ws = XLSX.utils.json_to_sheet(rows); + + // Column widths + ws["!cols"] = [ + { wch: 14 }, { wch: 10 }, { wch: 28 }, { wch: 28 }, { wch: 22 }, + { wch: 22 }, { wch: 32 }, { wch: 14 }, { wch: 30 }, { wch: 16 }, + { wch: 12 }, { wch: 24 }, { wch: 20 }, { wch: 8 }, { wch: 10 }, + { wch: 16 }, { wch: 30 }, { wch: 18 }, { wch: 18 }, { wch: 8 }, + { wch: 40 }, { wch: 30 }, { wch: 40 }, { wch: 28 }, + ]; + + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "LeadVault"); + + const arr = XLSX.write(wb, { type: "array", bookType: "xlsx" }) as number[]; + const buf = new Uint8Array(arr).buffer; + + return new NextResponse(buf, { + headers: { + "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Content-Disposition": `attachment; filename="${filename}.xlsx"`, + }, + }); + } + + // CSV — UTF-8 BOM so Excel opens it correctly + \r\n line endings + const headers = 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) => + v.includes(",") || v.includes('"') || v.includes("\n") || v.includes("\r") + ? `"${v.replace(/"/g, '""')}"` + : v; + + const csv = + "\uFEFF" + // UTF-8 BOM — tells Excel to use UTF-8 + [ + headers.map(escape).join(","), + ...rows.map(r => headers.map(h => escape(String(r[h as keyof typeof r] ?? ""))).join(",")), + ].join("\r\n"); // Windows line endings for Excel return new NextResponse(csv, { headers: { "Content-Type": "text/csv; charset=utf-8", - "Content-Disposition": `attachment; filename="leadflow-vault-${new Date().toISOString().split("T")[0]}.csv"`, + "Content-Disposition": `attachment; filename="${filename}.csv"`, }, }); } catch (err) { diff --git a/app/leadvault/page.tsx b/app/leadvault/page.tsx index 23b3bd0..bf22a06 100644 --- a/app/leadvault/page.tsx +++ b/app/leadvault/page.tsx @@ -520,8 +520,8 @@ export default function LeadVaultPage() { } } - function exportCSV(emailOnly = false) { - const params = new URLSearchParams({ search: debouncedSearch, sortBy, sortDir }); + function exportFile(format: "csv" | "xlsx", emailOnly = false) { + const params = new URLSearchParams({ search: debouncedSearch, sortBy, sortDir, format }); filterStatus.forEach(s => params.append("status", s)); filterSource.forEach(s => params.append("sourceTab", s)); if (filterHasEmail) params.set("hasEmail", filterHasEmail); @@ -560,9 +560,10 @@ export default function LeadVaultPage() {
{[ - ["Aktuelle Ansicht (CSV)", () => exportCSV()], - ["Alle Leads (CSV)", () => exportCSV()], - ["Nur mit E-Mail (CSV)", () => exportCSV(true)], + ["Aktuelle Ansicht (CSV)", () => exportFile("csv")], + ["Aktuelle Ansicht (Excel)", () => exportFile("xlsx")], + ["Nur mit E-Mail (CSV)", () => exportFile("csv", true)], + ["Nur mit E-Mail (Excel)", () => exportFile("xlsx", true)], ].map(([label, fn]) => (