From 115cdacd0813f7b2b188f7f2471ab31313f84dbc Mon Sep 17 00:00:00 2001 From: Timo Uttenweiler Date: Sat, 21 Mar 2026 18:12:31 +0100 Subject: [PATCH] UI improvements: Leadspeicher, Maps enrichment, exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/airscale/page.tsx | 12 +- app/api/export/[jobId]/route.ts | 27 +- app/api/jobs/[id]/status/route.ts | 34 +- app/api/jobs/airscale-enrich/route.ts | 2 - app/api/jobs/linkedin-enrich/route.ts | 3 - app/api/jobs/maps-enrich/route.ts | 6 +- app/api/jobs/serp-enrich/route.ts | 2 - app/api/leads/export/route.ts | 85 ++- app/api/leads/route.ts | 11 + app/leadvault/page.tsx | 582 +++++++----------- app/linkedin/page.tsx | 4 - app/maps/page.tsx | 127 +++- app/results/page.tsx | 13 - app/serp/page.tsx | 9 +- components/layout/Sidebar.tsx | 2 +- components/layout/TopBar.tsx | 2 +- components/shared/ExportButtons.tsx | 13 +- components/shared/ProgressCard.tsx | 2 +- components/shared/ResultsTable.tsx | 16 +- lib/services/anymailfinder.ts | 49 +- lib/services/apify.ts | 2 +- lib/services/googlemaps.ts | 8 + lib/services/leadVault.ts | 14 +- lib/utils/csv.ts | 4 - .../migration.sql | 2 + prisma/schema.prisma | 1 + 26 files changed, 511 insertions(+), 521 deletions(-) create mode 100644 prisma/migrations/20260320170619_add_description_to_lead/migration.sql diff --git a/app/airscale/page.tsx b/app/airscale/page.tsx index 3ab900d..d572595 100644 --- a/app/airscale/page.tsx +++ b/app/airscale/page.tsx @@ -130,16 +130,8 @@ export default function AirScalePage() { contact_name: r.contactName, contact_title: r.contactTitle, 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 (
{/* Header */} @@ -315,7 +307,7 @@ export default function AirScalePage() { title="Anreicherung abgeschlossen" current={progress.current} total={progress.total} - subtitle={`Trefferquote: ${hitRate}%`} + subtitle={`${results.filter(r => r.email).length} E-Mails gefunden`} status="complete" /> )} @@ -339,7 +331,7 @@ export default function AirScalePage() { r.email).length} E-Mails gefunden • ${hitRate}% Trefferquote`} + summary={`${results.filter(r => r.email).length} E-Mails gefunden`} />
diff --git a/app/api/export/[jobId]/route.ts b/app/api/export/[jobId]/route.ts index 9dd2558..c65b317 100644 --- a/app/api/export/[jobId]/route.ts +++ b/app/api/export/[jobId]/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/db"; -import Papa from "papaparse"; +import * as XLSX from "xlsx"; export async function GET( req: NextRequest, @@ -16,23 +16,22 @@ export async function GET( if (!job) return NextResponse.json({ error: "Job not found" }, { status: 404 }); const rows = job.results.map(r => ({ - company_name: r.companyName || "", - domain: r.domain || "", - contact_name: r.contactName || "", - contact_title: r.contactTitle || "", - email: 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(), + "Unternehmen": r.companyName || "", + "Domain": r.domain || "", + "Kontaktname": r.contactName || "", + "Position": r.contactTitle || "", + "E-Mail": r.email || "", })); - 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: { - "Content-Type": "text/csv", - "Content-Disposition": `attachment; filename="leadflow-${job.type}-${jobId.slice(0, 8)}.csv"`, + "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Content-Disposition": `attachment; filename="leadflow-${job.type}-${jobId.slice(0, 8)}.xlsx"`, }, }); } catch (err) { diff --git a/app/api/jobs/[id]/status/route.ts b/app/api/jobs/[id]/status/route.ts index 3318f19..d75d9e7 100644 --- a/app/api/jobs/[id]/status/route.ts +++ b/app/api/jobs/[id]/status/route.ts @@ -28,17 +28,29 @@ export async function GET( error: job.error, createdAt: job.createdAt, updatedAt: job.updatedAt, - results: job.results.map(r => ({ - id: r.id, - companyName: r.companyName, - domain: r.domain, - contactName: r.contactName, - contactTitle: r.contactTitle, - email: r.email, - confidence: r.confidence, - linkedinUrl: r.linkedinUrl, - createdAt: r.createdAt, - })), + 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, + companyName: r.companyName, + domain: r.domain, + contactName: r.contactName, + contactTitle: r.contactTitle, + email: r.email, + linkedinUrl: r.linkedinUrl, + address, + phone, + createdAt: r.createdAt, + }; + }), }); } catch (err) { console.error("GET /api/jobs/[id]/status error:", err); diff --git a/app/api/jobs/airscale-enrich/route.ts b/app/api/jobs/airscale-enrich/route.ts index 7284a2e..3cb9d0b 100644 --- a/app/api/jobs/airscale-enrich/route.ts +++ b/app/api/jobs/airscale-enrich/route.ts @@ -83,7 +83,6 @@ async function runEnrichment( contactName: result.person_full_name || null, contactTitle: result.person_job_title || null, email: result.email || null, - confidence: result.valid_email ? 1.0 : result.email_status === "risky" ? 0.5 : 0, linkedinUrl: result.person_linkedin_url || null, 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, email: r.email || null, linkedinUrl: r.person_linkedin_url || null, - emailConfidence: r.valid_email ? 1.0 : r.email_status === "risky" ? 0.5 : 0, })), "airscale", undefined, diff --git a/app/api/jobs/linkedin-enrich/route.ts b/app/api/jobs/linkedin-enrich/route.ts index 7dfbf28..4a5ff2d 100644 --- a/app/api/jobs/linkedin-enrich/route.ts +++ b/app/api/jobs/linkedin-enrich/route.ts @@ -110,7 +110,6 @@ async function runLinkedInEnrich( where: { id: result.id }, data: { email: email || null, - confidence: isValid ? 1.0 : emailStatus === "risky" ? 0.5 : 0, contactName: row["person_full_name"] || row["Full Name"] || result.contactName || null, contactTitle: row["person_job_title"] || row["Job Title"] || result.contactTitle || null, }, @@ -135,7 +134,6 @@ async function runLinkedInEnrich( where: { id: r.id }, data: { email: found.email || null, - confidence: isValid ? 1.0 : found.email_status === "risky" ? 0.5 : 0, contactName: found.person_full_name || r.contactName || null, contactTitle: found.person_job_title || r.contactTitle || null, }, @@ -162,7 +160,6 @@ async function runLinkedInEnrich( contactTitle: r.contactTitle, email: r.email, linkedinUrl: r.linkedinUrl, - emailConfidence: r.confidence, })), "linkedin", undefined, diff --git a/app/api/jobs/maps-enrich/route.ts b/app/api/jobs/maps-enrich/route.ts index bb498b0..6df6760 100644 --- a/app/api/jobs/maps-enrich/route.ts +++ b/app/api/jobs/maps-enrich/route.ts @@ -99,6 +99,8 @@ async function runMapsEnrich( companyName: p.name || null, phone: p.phone, address: p.address, + description: p.description, + industry: p.category, })), "maps", params.queries.join(", "), @@ -132,7 +134,7 @@ async function runMapsEnrich( ); for (const result of enrichResults) { - const hasEmail = !!result.valid_email; + const hasEmail = !!result.email; if (hasEmail) emailsFound++; const resultId = domainToResultId.get(result.domain || ""); @@ -144,7 +146,6 @@ async function runMapsEnrich( contactName: result.person_full_name || null, contactTitle: result.person_job_title || null, email: result.email || null, - confidence: result.valid_email ? 1.0 : result.email_status === "risky" ? 0.5 : 0, linkedinUrl: result.person_linkedin_url || null, }, }); @@ -167,7 +168,6 @@ async function runMapsEnrich( contactTitle: r.person_job_title || null, email: r.email || null, linkedinUrl: r.person_linkedin_url || null, - emailConfidence: r.valid_email ? 1.0 : r.email_status === "risky" ? 0.5 : 0, })), "maps", params.queries.join(", "), diff --git a/app/api/jobs/serp-enrich/route.ts b/app/api/jobs/serp-enrich/route.ts index 8a35b65..5d02151 100644 --- a/app/api/jobs/serp-enrich/route.ts +++ b/app/api/jobs/serp-enrich/route.ts @@ -122,7 +122,6 @@ async function runSerpEnrich( contactName: result.person_full_name || null, contactTitle: result.person_job_title || null, email: result.email || null, - confidence: result.valid_email ? 1.0 : result.email_status === "risky" ? 0.5 : 0, linkedinUrl: result.person_linkedin_url || null, source: JSON.stringify({ url: serpData?.url, @@ -150,7 +149,6 @@ async function runSerpEnrich( contactTitle: r.person_job_title || null, email: r.email || null, linkedinUrl: r.person_linkedin_url || null, - emailConfidence: r.valid_email ? 1.0 : r.email_status === "risky" ? 0.5 : 0, serpTitle: serpData?.title || null, serpSnippet: serpData?.description || null, serpRank: serpData?.position ?? null, diff --git a/app/api/leads/export/route.ts b/app/api/leads/export/route.ts index e777fed..34387a8 100644 --- a/app/api/leads/export/route.ts +++ b/app/api/leads/export/route.ts @@ -36,30 +36,18 @@ export async function GET(req: NextRequest) { }); const rows = leads.map(l => ({ - "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, + "Unternehmen": l.companyName || "", + "Domain": l.domain || "", + "Kontaktname": l.contactName || "", + "Position": l.contactTitle || "", + "E-Mail": l.email || "", + "Telefon": l.phone || "", + "Adresse": l.address || "", + "LinkedIn": l.linkedinUrl || "", + "Branche": l.industry || "", + "Suchbegriff": l.sourceTerm || "", + "Tags": l.tags ? (JSON.parse(l.tags) as string[]).join(", ") : "", + "Erfasst am": new Date(l.capturedAt).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }), })); const filename = `leadflow-vault-${new Date().toISOString().split("T")[0]}`; @@ -67,22 +55,36 @@ export async function GET(req: NextRequest) { if (format === "xlsx") { const ws = XLSX.utils.json_to_sheet(rows); - // Column widths + // Column widths (one per column) 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 }, + { wch: 30 }, // Unternehmen + { wch: 28 }, // Domain + { wch: 24 }, // Kontaktname + { wch: 24 }, // Position + { 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(); 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, { + return new NextResponse(new Uint8Array(arr).buffer, { headers: { "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "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 - 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": "", - }); - + // CSV — UTF-8 BOM so Excel opens it correctly + const colKeys = Object.keys(rows[0] ?? {}); 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 + "\uFEFF" + [ - 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 + colKeys.map(escape).join(","), + ...rows.map(r => colKeys.map(h => escape(String(r[h as keyof typeof r] ?? ""))).join(",")), + ].join("\r\n"); return new NextResponse(csv, { headers: { diff --git a/app/api/leads/route.ts b/app/api/leads/route.ts index 2c1c4dc..466b9f8 100644 --- a/app/api/leads/route.ts +++ b/app/api/leads/route.ts @@ -20,6 +20,8 @@ export async function GET(req: NextRequest) { const priorities = searchParams.getAll("priority"); const tags = searchParams.getAll("tags"); const searchTerms = searchParams.getAll("searchTerm"); + const contacted = searchParams.get("contacted"); + const favorite = searchParams.get("favorite"); const where: Prisma.LeadWhereInput = {}; @@ -30,6 +32,7 @@ export async function GET(req: NextRequest) { { contactName: { contains: search } }, { email: { contains: search } }, { notes: { contains: search } }, + { sourceTerm: { contains: search } }, ]; } @@ -50,6 +53,14 @@ export async function GET(req: NextRequest) { where.sourceTerm = { in: searchTerms }; } + if (contacted === "yes") { + where.tags = { contains: "kontaktiert" }; + } + + if (favorite === "yes") { + where.tags = { contains: "favorit" }; + } + if (tags.length > 0) { // SQLite JSON contains — search for each tag in the JSON string where.AND = tags.map(tag => ({ diff --git a/app/leadvault/page.tsx b/app/leadvault/page.tsx index 266589c..f3a0ef8 100644 --- a/app/leadvault/page.tsx +++ b/app/leadvault/page.tsx @@ -1,14 +1,14 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback } from "react"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { toast } from "sonner"; import { Database, Search, X, ChevronDown, ChevronUp, Trash2, ExternalLink, - Mail, Star, Tag, FileText, ArrowUpDown, ArrowUp, ArrowDown, - CheckSquare, Square, Download, Plus, RefreshCw, Phone + Mail, Star, Tag, ArrowUpDown, ArrowUp, ArrowDown, + CheckSquare, Square, Download, Phone, Copy, MapPin, Building2, Globe } from "lucide-react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -31,7 +31,6 @@ interface Lead { serpSnippet: string | null; serpRank: number | null; serpUrl: string | null; - emailConfidence: number | null; status: string; priority: string; notes: string | null; @@ -39,6 +38,7 @@ interface Lead { country: string | null; headcount: string | null; industry: string | null; + description: string | null; capturedAt: string; contactedAt: string | null; events?: Array<{ id: string; event: string; at: string }>; @@ -71,12 +71,6 @@ const SOURCE_CONFIG: Record = { maps: { label: "Maps", icon: "📍" }, }; -const PRIORITY_CONFIG: Record = { - high: { label: "Hoch", icon: "↑", color: "text-red-400" }, - normal: { label: "Normal", icon: "→", color: "text-gray-400" }, - low: { label: "Niedrig",icon: "↓", color: "text-blue-400" }, -}; - const TAG_PRESETS = [ "solar", "b2b", "deutschland", "founder", "kmu", "warmkontakt", "kaltakquise", "follow-up", "interessiert", "angebot-gesendet", @@ -97,18 +91,6 @@ function tagColor(tag: string): string { return colors[Math.abs(hash) % colors.length]; } -function relativeTime(dateStr: string): string { - const diff = Date.now() - new Date(dateStr).getTime(); - const mins = Math.floor(diff / 60000); - if (mins < 1) return "gerade eben"; - if (mins < 60) return `vor ${mins} Min.`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `vor ${hrs} Std.`; - const days = Math.floor(hrs / 24); - if (days < 30) return `vor ${days} Tagen`; - return new Date(dateStr).toLocaleDateString("de-DE"); -} - // ─── Sparkline ──────────────────────────────────────────────────────────────── function Sparkline({ data, color }: { data: number[]; color: string }) { @@ -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 ( -
- - {open && ( -
- {Object.entries(STATUS_CONFIG).map(([key, c]) => ( - - ))} -
- )} -
- ); -} - // ─── Side Panel ─────────────────────────────────────────────────────────────── -function SidePanel({ lead, onClose, onUpdate }: { +function SidePanel({ lead, onClose, onUpdate, onDelete }: { lead: Lead; onClose: () => void; onUpdate: (updated: Partial) => void; + onDelete: (id: string) => void; }) { - const [notes, setNotes] = useState(lead.notes || ""); - const [tagInput, setTagInput] = useState(""); - const [fullLead, setFullLead] = useState(lead); - const notesTimer = useRef | null>(null); + const tags: string[] = JSON.parse(lead.tags || "[]"); + const src = SOURCE_CONFIG[lead.sourceTab]; - useEffect(() => { - // Load full lead with events - fetch(`/api/leads/${lead.id}`).then(r => r.json()).then(setFullLead).catch(() => {}); - }, [lead.id]); - - const tags: string[] = JSON.parse(fullLead.tags || "[]"); - - async function patch(data: Partial) { - const res = await fetch(`/api/leads/${lead.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data), - }); - if (res.ok) { - const updated = await res.json() as Lead; - setFullLead(prev => ({ ...prev, ...updated })); - onUpdate(updated); - } + function copy(text: string, label: string) { + navigator.clipboard.writeText(text); + toast.success(`${label} kopiert`); } - function handleNotesChange(v: string) { - setNotes(v); - if (notesTimer.current) clearTimeout(notesTimer.current); - notesTimer.current = setTimeout(() => patch({ notes: v }), 500); - } - - function addTag(tag: string) { - const trimmed = tag.trim().toLowerCase(); - if (!trimmed || tags.includes(trimmed)) return; - const newTags = [...tags, trimmed]; - patch({ tags: JSON.stringify(newTags) }); - setTagInput(""); - } - - function removeTag(tag: string) { - patch({ tags: JSON.stringify(tags.filter(t => t !== tag)) }); - } - - const conf = fullLead.emailConfidence; - const confColor = conf == null ? "text-gray-500" : conf >= 0.8 ? "text-green-400" : conf >= 0.5 ? "text-yellow-400" : "text-red-400"; - return (
e.stopPropagation()} > {/* Header */} -
+
-

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

-

{fullLead.domain}

+

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

+ {lead.domain && ( + + {lead.domain} + + )} + {lead.industry &&

{lead.industry}

}
-
-
- {/* Status + Priority */} -
- patch({ status: s })} /> -
- {Object.entries(PRIORITY_CONFIG).map(([key, p]) => ( - - ))} + {/* Scrollable body */} +
+ + {/* Description */} + {lead.description && ( +
+

Beschreibung

+

{lead.description}

-
+ )} - {/* Contact Info */} -
-

Kontakt

- {fullLead.contactName &&

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

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

Quelle

-

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

- {fullLead.sourceTerm &&

Suche: {fullLead.sourceTerm}

} -

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

- {fullLead.serpRank &&

SERP Rang: #{fullLead.serpRank}

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

Tags

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

Notizen

-