Files
lead-scraper/app/leadspeicher/page.tsx
Timo Uttenweiler 60073b97c9 feat: OnyvaLeads customer UI — Suche + Leadspeicher
- New Topbar: logo, 2-tab pill switcher, live "Neu" badge
- /suche page: SearchCard, LoadingCard, ExamplePills
- /leadspeicher page: full leads table with filters, pagination
- StatusBadge, StatusPopover, LeadSidePanel, BulkActionBar
- POST /api/search: unified search entry point → serp-enrich
- Remove Sidebar + old TopBar from layout
- Title: OnyvaLeads, redirect / → /suche

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:48:05 +01:00

336 lines
11 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { LeadsToolbar } from "@/components/leadspeicher/LeadsToolbar";
import { LeadsTable } from "@/components/leadspeicher/LeadsTable";
import type { Lead } from "@/components/leadspeicher/LeadsTable";
import { BulkActionBar } from "@/components/leadspeicher/BulkActionBar";
interface LeadsResponse {
leads: Lead[];
total: number;
page: number;
pages: number;
perPage: number;
}
interface StatsResponse {
total: number;
new: number;
withEmail: number;
}
export default function LeadspeicherPage() {
const router = useRouter();
const [leads, setLeads] = useState<Lead[]>([]);
const [total, setTotal] = useState(0);
const [pages, setPages] = useState(1);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(50);
const [search, setSearch] = useState("");
const [status, setStatus] = useState("");
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [stats, setStats] = useState<StatsResponse>({ total: 0, new: 0, withEmail: 0 });
const fetchRef = useRef(0);
const fetchLeads = useCallback(async () => {
const id = ++fetchRef.current;
setLoading(true);
try {
const params = new URLSearchParams({
page: String(page),
perPage: String(perPage),
});
if (search) params.set("search", search);
if (status) params.append("status", status);
const res = await fetch(`/api/leads?${params.toString()}`);
if (!res.ok) throw new Error("fetch failed");
const data = await res.json() as LeadsResponse;
if (id === fetchRef.current) {
setLeads(data.leads);
setTotal(data.total);
setPages(data.pages);
}
} catch {
// silent
} finally {
if (id === fetchRef.current) setLoading(false);
}
}, [page, perPage, search, status]);
const fetchStats = useCallback(async () => {
try {
const res = await fetch("/api/leads/stats");
const data = await res.json() as StatsResponse;
setStats(data);
} catch {
// silent
}
}, []);
useEffect(() => {
fetchLeads();
fetchStats();
}, [fetchLeads, fetchStats]);
function handleLeadUpdate(id: string, updates: Partial<Lead>) {
setLeads((prev) =>
prev.map((l) => (l.id === id ? { ...l, ...updates } : l))
);
}
function handleToggleSelect(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function handleToggleAll() {
if (selected.size === leads.length && leads.length > 0) {
setSelected(new Set());
} else {
setSelected(new Set(leads.map((l) => l.id)));
}
}
async function handleBulkStatus(newStatus: string) {
const ids = Array.from(selected);
// optimistic
setLeads((prev) =>
prev.map((l) => (selected.has(l.id) ? { ...l, status: newStatus } : l))
);
setSelected(new Set());
try {
await fetch("/api/leads/bulk", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids, status: newStatus }),
});
} catch {
toast.error("Status-Update fehlgeschlagen");
fetchLeads();
}
}
async function handleBulkDelete() {
if (!confirm(`${selected.size} Lead(s) wirklich löschen?`)) return;
const ids = Array.from(selected);
setLeads((prev) => prev.filter((l) => !selected.has(l.id)));
setSelected(new Set());
try {
await Promise.all(
ids.map((id) =>
fetch(`/api/leads/${id}`, { method: "DELETE" })
)
);
fetchStats();
} catch {
toast.error("Löschen fehlgeschlagen");
fetchLeads();
}
}
const hasFilter = search !== "" || status !== "";
const withEmailCount = stats.withEmail;
const withEmailPct = stats.total > 0 ? Math.round((withEmailCount / stats.total) * 100) : 0;
const exportParams: Record<string, string> = {};
if (search) exportParams.search = search;
if (status) exportParams.status = status;
return (
<div style={{ display: "flex", flexDirection: "column", minHeight: "calc(100vh - 52px)" }}>
{/* Toolbar */}
<LeadsToolbar
search={search}
status={status}
onSearchChange={(v) => { setSearch(v); setPage(1); }}
onStatusChange={(v) => { setStatus(v); setPage(1); }}
exportParams={exportParams}
/>
{/* Table area */}
<div style={{ flex: 1 }}>
{loading && leads.length === 0 ? (
// Loading skeleton
<div style={{ padding: "40px 20px", textAlign: "center", color: "#6b7280", fontSize: 13 }}>
Wird geladen
</div>
) : leads.length === 0 ? (
// Empty state
hasFilter ? (
<div style={{ padding: "60px 20px", textAlign: "center" }}>
<div style={{ fontSize: 32, marginBottom: 12 }}>🔍</div>
<div style={{ fontSize: 15, fontWeight: 500, color: "#ffffff", marginBottom: 8 }}>
Keine Leads gefunden
</div>
<div style={{ fontSize: 13, color: "#9ca3af", marginBottom: 20 }}>
Versuche andere Filtereinstellungen.
</div>
<button
onClick={() => { setSearch(""); setStatus(""); setPage(1); }}
style={{
background: "transparent",
border: "1px solid #2e2e3e",
borderRadius: 8,
padding: "8px 16px",
fontSize: 13,
color: "#3b82f6",
cursor: "pointer",
}}
>
Filter zurücksetzen
</button>
</div>
) : (
<div style={{ padding: "60px 20px", textAlign: "center" }}>
<div style={{ marginBottom: 16 }}>
<svg
width="48"
height="48"
viewBox="0 0 48 48"
fill="none"
style={{ margin: "0 auto", display: "block" }}
>
<rect x="6" y="12" width="36" height="28" rx="3" stroke="#2e2e3e" strokeWidth="2" />
<path d="M6 20h36" stroke="#2e2e3e" strokeWidth="2" />
<path d="M16 8h16" stroke="#2e2e3e" strokeWidth="2" strokeLinecap="round" />
<circle cx="24" cy="32" r="6" stroke="#3b3b4e" strokeWidth="2" />
<path d="M18 32h12" stroke="#3b3b4e" strokeWidth="1.5" />
<path d="M24 26v12" stroke="#3b3b4e" strokeWidth="1.5" />
</svg>
</div>
<div style={{ fontSize: 15, fontWeight: 500, color: "#ffffff", marginBottom: 8 }}>
Noch keine Leads gespeichert
</div>
<div style={{ fontSize: 13, color: "#9ca3af", marginBottom: 20 }}>
Starte eine Suche die Ergebnisse erscheinen hier automatisch.
</div>
<button
onClick={() => router.push("/suche")}
style={{
background: "linear-gradient(135deg, #3b82f6, #8b5cf6)",
border: "none",
borderRadius: 8,
padding: "10px 20px",
fontSize: 13,
fontWeight: 500,
color: "#ffffff",
cursor: "pointer",
}}
>
Zur Suche
</button>
</div>
)
) : (
<LeadsTable
leads={leads}
selected={selected}
onToggleSelect={handleToggleSelect}
onToggleAll={handleToggleAll}
allSelected={selected.size === leads.length && leads.length > 0}
onLeadUpdate={handleLeadUpdate}
/>
)}
</div>
{/* Footer */}
{(total > 0 || leads.length > 0) && (
<div
style={{
borderTop: "1px solid #1e1e2e",
background: "#111118",
padding: "12px 20px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexShrink: 0,
}}
>
{/* Stats */}
<div style={{ fontSize: 12, color: "#6b7280" }}>
{total} Leads gesamt
{withEmailCount > 0 && (
<> · {withEmailCount} mit E-Mail ({withEmailPct}%)</>
)}
</div>
{/* Pagination */}
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<select
value={perPage}
onChange={(e) => { setPerPage(Number(e.target.value)); setPage(1); }}
style={{
background: "#0d0d18",
border: "1px solid #1e1e2e",
borderRadius: 6,
padding: "4px 8px",
fontSize: 12,
color: "#9ca3af",
cursor: "pointer",
outline: "none",
}}
>
<option value={25}>25 / Seite</option>
<option value={50}>50 / Seite</option>
<option value={100}>100 / Seite</option>
</select>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
style={{
background: "transparent",
border: "1px solid #2e2e3e",
borderRadius: 6,
padding: "4px 10px",
fontSize: 13,
color: page <= 1 ? "#3b3b4e" : "#9ca3af",
cursor: page <= 1 ? "not-allowed" : "pointer",
}}
>
</button>
<span style={{ fontSize: 12, color: "#9ca3af" }}>
Seite {page} von {pages}
</span>
<button
onClick={() => setPage((p) => Math.min(pages, p + 1))}
disabled={page >= pages}
style={{
background: "transparent",
border: "1px solid #2e2e3e",
borderRadius: 6,
padding: "4px 10px",
fontSize: 13,
color: page >= pages ? "#3b3b4e" : "#9ca3af",
cursor: page >= pages ? "not-allowed" : "pointer",
}}
>
</button>
</div>
</div>
</div>
)}
{/* Bulk action bar */}
<BulkActionBar
selectedCount={selected.size}
onSetStatus={handleBulkStatus}
onDelete={handleBulkDelete}
/>
</div>
);
}