Files
lead-scraper/app/results/page.tsx
Timo Uttenweiler f6bdc65b1e feat: übersetze gesamte UI auf Deutsch
- Alle Seiten (AirScale, LinkedIn, SERP, Ergebnisse, Einstellungen) auf Deutsch
- Gemeinsame Komponenten übersetzt: StatusBadge, ResultsTable-Spalten, FileDropZone, ExportButtons
- Sidebar API-Status-Label und TopBar-Breadcrumbs auf Deutsch
- Alle Toast-Nachrichten und Fehlermeldungen auf Deutsch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 12:40:05 +01:00

215 lines
9.2 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/shared/EmptyState";
import { StatusBadge } from "@/components/shared/ProgressCard";
import { toast } from "sonner";
import { BarChart3, Building2, Linkedin, Search, MapPin, Download, Trash2, RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils";
interface Job {
id: string;
type: string;
status: string;
totalLeads: number;
emailsFound: number;
createdAt: string;
error?: string;
}
interface Stats {
totalLeads: number;
totalEmails: number;
avgHitRate: number;
totalJobs: number;
}
const TYPE_CONFIG: Record<string, { icon: typeof Building2; label: string; color: string }> = {
airscale: { icon: Building2, label: "AirScale", color: "text-blue-400" },
linkedin: { icon: Linkedin, label: "LinkedIn", color: "text-blue-500" },
"linkedin-enrich": { icon: Linkedin, label: "LinkedIn Enrich", color: "text-blue-400" },
serp: { icon: Search, label: "SERP", color: "text-purple-400" },
maps: { icon: MapPin, label: "Google Maps", color: "text-green-400" },
};
export default function ResultsPage() {
const [jobs, setJobs] = useState<Job[]>([]);
const [stats, setStats] = useState<Stats>({ totalLeads: 0, totalEmails: 0, avgHitRate: 0, totalJobs: 0 });
const [loading, setLoading] = useState(true);
useEffect(() => {
loadJobs();
}, []);
const loadJobs = async () => {
setLoading(true);
try {
const res = await fetch("/api/jobs");
const data = await res.json() as { jobs: Job[]; stats: Stats };
setJobs(data.jobs || []);
setStats(data.stats || { totalLeads: 0, totalEmails: 0, avgHitRate: 0, totalJobs: 0 });
} catch {
toast.error("Job-Verlauf konnte nicht geladen werden");
} finally {
setLoading(false);
}
};
const deleteJob = async (id: string) => {
try {
await fetch(`/api/jobs/${id}`, { method: "DELETE" });
setJobs(prev => prev.filter(j => j.id !== id));
toast.success("Job gelöscht");
} catch {
toast.error("Job konnte nicht gelöscht werden");
}
};
const downloadJob = (id: string) => {
window.open(`/api/export/${id}`, "_blank");
};
const hitRate = (job: Job) =>
job.totalLeads > 0 ? Math.round((job.emailsFound / job.totalLeads) * 100) : 0;
return (
<div className="space-y-6 max-w-6xl">
{/* Header */}
<div className="relative rounded-2xl bg-gradient-to-r from-green-600/10 to-blue-600/10 border border-[#1e1e2e] p-6 overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-green-500/5 to-transparent" />
<div className="relative">
<h1 className="text-2xl font-bold text-white">Ergebnisse & Verlauf</h1>
<p className="text-gray-400 mt-1 text-sm">Alle vergangenen Anreicherungsjobs und ihre Ergebnisse.</p>
</div>
</div>
{/* Stats cards */}
<div className="grid grid-cols-4 gap-4">
{[
{ label: "Jobs gesamt", value: stats.totalJobs, color: "text-white" },
{ label: "Leads gesamt", value: stats.totalLeads.toLocaleString(), color: "text-blue-400" },
{ label: "E-Mails gefunden", value: stats.totalEmails.toLocaleString(), color: "text-green-400" },
{ label: "Ø Trefferquote", value: `${stats.avgHitRate}%`, color: "text-purple-400" },
].map(stat => (
<Card key={stat.label} className="bg-[#111118] border-[#1e1e2e] p-5">
<p className={`text-2xl font-bold ${stat.color}`}>{stat.value}</p>
<p className="text-xs text-gray-500 mt-1">{stat.label}</p>
</Card>
))}
</div>
{/* Jobs table */}
<Card className="bg-[#111118] border-[#1e1e2e] overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-[#1e1e2e]">
<h2 className="font-semibold text-white">Job-Verlauf</h2>
<Button
variant="outline"
size="sm"
onClick={loadJobs}
className="border-[#2e2e3e] text-gray-300 hover:bg-[#1a1a28]"
>
<RefreshCw className="w-3.5 h-3.5 mr-1.5" /> Aktualisieren
</Button>
</div>
{loading ? (
<div className="p-6 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full bg-[#1e1e2e]" />
))}
</div>
) : jobs.length === 0 ? (
<EmptyState
icon={BarChart3}
title="Noch keine Jobs"
description="Starte einen Anreicherungsjob über einen der Pipeline-Tabs, um Ergebnisse zu sehen."
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#1e1e2e] bg-[#0d0d18]">
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Typ</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Job-ID</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Gestartet</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">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>
</tr>
</thead>
<tbody>
{jobs.map((job, idx) => {
const cfg = TYPE_CONFIG[job.type] || { icon: BarChart3, label: job.type, color: "text-gray-400" };
const Icon = cfg.icon;
return (
<tr
key={job.id}
className={cn(
"border-b border-[#1e1e2e]/50",
idx % 2 === 0 ? "bg-[#111118]" : "bg-[#0f0f1a]"
)}
>
<td className="px-4 py-3">
<div className={`flex items-center gap-2 ${cfg.color}`}>
<Icon className="w-4 h-4" />
<span className="text-xs font-medium">{cfg.label}</span>
</div>
</td>
<td className="px-4 py-3 font-mono text-xs text-gray-500">{job.id.slice(0, 12)}...</td>
<td className="px-4 py-3 text-xs text-gray-400">
{new Date(job.createdAt).toLocaleDateString("de-DE", {
day: "2-digit", month: "2-digit", year: "numeric",
hour: "2-digit", minute: "2-digit",
})}
</td>
<td className="px-4 py-3">
<StatusBadge status={job.status === "complete" ? "complete" : job.status} />
</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">
<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">
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="sm"
onClick={() => downloadJob(job.id)}
disabled={job.status !== "complete"}
className="h-7 px-2 text-gray-400 hover:text-white hover:bg-[#1a1a28]"
>
<Download className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => deleteJob(job.id)}
className="h-7 px-2 text-gray-400 hover:text-red-400 hover:bg-red-500/5"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Card>
</div>
);
}