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:
Timo Uttenweiler
2026-03-21 18:12:31 +01:00
parent f914ab6e47
commit 115cdacd08
26 changed files with 511 additions and 521 deletions

View File

@@ -59,6 +59,8 @@ export default function MapsPage() {
const [jobId, setJobId] = useState<string | null>(null);
const [progress, setProgress] = useState({ current: 0, total: 0, phase: "" });
const [results, setResults] = useState<ResultRow[]>([]);
const [enrichStage, setEnrichStage] = useState<Stage>("idle");
const [enrichProgress, setEnrichProgress] = useState({ current: 0, total: 0 });
const { addJob, updateJob, removeJob } = useAppStore();
// Build queries: every keyword × every region
@@ -162,20 +164,74 @@ export default function MapsPage() {
}, 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 => ({
company_name: r.companyName,
domain: r.domain,
contact_name: r.contactName,
contact_title: r.contactTitle,
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 hitRate = results.length > 0 ? Math.round((emailsFound / results.length) * 100) : 0;
const totalExpected = queries.length * Number(maxResults);
return (
@@ -420,7 +476,7 @@ export default function MapsPage() {
title="Fertig"
current={enrichEmails ? emailsFound : 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"
/>
)}
@@ -431,14 +487,69 @@ export default function MapsPage() {
<Card className="bg-[#111118] border-[#1e1e2e] p-6 space-y-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<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>
<ExportButtons
rows={exportRows}
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>
{/* 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
rows={results}
loading={stage === "running" && results.length === 0}