- 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>
336 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|