Files
lead-scraper/components/leadspeicher/LeadsTable.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, useCallback } from "react";
import { StatusBadge } from "./StatusBadge";
import { StatusPopover } from "./StatusPopover";
import { LeadSidePanel } from "./LeadSidePanel";
export interface Lead {
id: string;
domain: string | null;
companyName: string | null;
contactName: string | null;
contactTitle: string | null;
email: string | null;
linkedinUrl: string | null;
phone: string | null;
address: string | null;
sourceTab: string;
sourceTerm: string | null;
sourceJobId: string | null;
serpTitle: string | null;
serpSnippet: string | null;
serpRank: number | null;
serpUrl: string | null;
status: string;
priority: string;
notes: string | null;
tags: string | null;
country: string | null;
headcount: string | null;
industry: string | null;
description: string | null;
capturedAt: string;
contactedAt: string | null;
}
interface LeadsTableProps {
leads: Lead[];
selected: Set<string>;
onToggleSelect: (id: string) => void;
onToggleAll: () => void;
allSelected: boolean;
onLeadUpdate: (id: string, updates: Partial<Lead>) => void;
}
function relativeDate(iso: string): string {
const now = new Date();
const d = new Date(iso);
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return "heute";
if (diffDays === 1) return "gestern";
if (diffDays < 7) return `vor ${diffDays} Tagen`;
const diffWeeks = Math.floor(diffDays / 7);
if (diffWeeks < 5) return `vor ${diffWeeks} Woche${diffWeeks > 1 ? "n" : ""}`;
const diffMonths = Math.floor(diffDays / 30);
return `vor ${diffMonths} Monat${diffMonths > 1 ? "en" : ""}`;
}
function fullDate(iso: string): string {
return new Date(iso).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
interface StatusCellProps {
lead: Lead;
onUpdate: (id: string, updates: Partial<Lead>) => void;
}
function StatusCell({ lead, onUpdate }: StatusCellProps) {
const [open, setOpen] = useState(false);
async function handleSelect(newStatus: string) {
// Optimistic update
onUpdate(lead.id, { status: newStatus });
try {
await fetch(`/api/leads/${lead.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
});
} catch {
// revert on error
onUpdate(lead.id, { status: lead.status });
}
}
return (
<div style={{ position: "relative", display: "inline-block" }}>
<StatusBadge status={lead.status} onClick={() => setOpen((v) => !v)} />
{open && (
<StatusPopover
currentStatus={lead.status}
onSelect={handleSelect}
onClose={() => setOpen(false)}
/>
)}
</div>
);
}
export function LeadsTable({
leads,
selected,
onToggleSelect,
onToggleAll,
allSelected,
onLeadUpdate,
}: LeadsTableProps) {
const [sidePanelLead, setSidePanelLead] = useState<Lead | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const handleCopyEmail = useCallback(async (lead: Lead, e: React.MouseEvent) => {
e.stopPropagation();
if (!lead.email) return;
await navigator.clipboard.writeText(lead.email);
setCopiedId(lead.id);
setTimeout(() => setCopiedId(null), 1500);
}, []);
async function handleMarkContacted(lead: Lead) {
onLeadUpdate(lead.id, { status: "contacted" });
try {
await fetch(`/api/leads/${lead.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "contacted" }),
});
} catch {
onLeadUpdate(lead.id, { status: lead.status });
}
}
const thStyle: React.CSSProperties = {
padding: "10px 16px",
fontSize: 11,
fontWeight: 500,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
textAlign: "left",
whiteSpace: "nowrap",
background: "#0d0d18",
borderBottom: "1px solid #1e1e2e",
};
return (
<>
<div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={{ ...thStyle, width: 40 }}>
<input
type="checkbox"
checked={allSelected && leads.length > 0}
onChange={onToggleAll}
style={{ cursor: "pointer", accentColor: "#3b82f6" }}
/>
</th>
<th style={thStyle}>Unternehmen</th>
<th style={thStyle}>E-Mail</th>
<th style={thStyle}>Branche</th>
<th style={thStyle}>Region</th>
<th style={thStyle}>Status</th>
<th style={thStyle}>Gefunden am</th>
<th style={{ ...thStyle, textAlign: "right" }}>Aktionen</th>
</tr>
</thead>
<tbody>
{leads.map((lead) => {
const isSelected = selected.has(lead.id);
const isNew = lead.status === "new";
return (
<tr
key={lead.id}
style={{
background: isSelected ? "rgba(59,130,246,0.04)" : "transparent",
borderBottom: "1px solid #1e1e2e",
transition: "background 0.1s",
}}
onMouseEnter={(e) => {
if (!isSelected) (e.currentTarget as HTMLTableRowElement).style.background = "#0d0d18";
}}
onMouseLeave={(e) => {
if (!isSelected) (e.currentTarget as HTMLTableRowElement).style.background = "transparent";
}}
>
{/* Checkbox */}
<td style={{ padding: "11px 16px", width: 40 }}>
<input
type="checkbox"
checked={isSelected}
onChange={() => onToggleSelect(lead.id)}
style={{ cursor: "pointer", accentColor: "#3b82f6" }}
/>
</td>
{/* Unternehmen */}
<td style={{ padding: "11px 16px", maxWidth: 200 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: "#ffffff", marginBottom: 1 }}>
{lead.companyName || lead.domain || "—"}
</div>
{lead.domain && lead.companyName && (
<div style={{ fontSize: 11, color: "#6b7280" }}>{lead.domain}</div>
)}
</td>
{/* Email */}
<td style={{ padding: "11px 16px", maxWidth: 220 }}>
{lead.email ? (
<div
className="email-cell"
style={{ display: "flex", alignItems: "center", gap: 6 }}
>
<span
style={{
fontFamily: "monospace",
fontSize: 11,
color: "#3b82f6",
wordBreak: "break-all",
}}
>
{lead.email}
</span>
<button
onClick={(e) => handleCopyEmail(lead, e)}
title="Kopieren"
style={{
background: "transparent",
border: "none",
cursor: "pointer",
padding: 2,
color: copiedId === lead.id ? "#22c55e" : "#6b7280",
flexShrink: 0,
fontSize: 11,
}}
>
{copiedId === lead.id ? "✓" : "⎘"}
</button>
</div>
) : (
<span style={{ fontSize: 11, color: "#6b7280" }}> nicht gefunden</span>
)}
</td>
{/* Branche */}
<td style={{ padding: "11px 16px" }}>
<span style={{ fontSize: 12, color: "#9ca3af" }}>
{lead.industry || "—"}
</span>
</td>
{/* Region */}
<td style={{ padding: "11px 16px" }}>
<span style={{ fontSize: 12, color: "#9ca3af" }}>
{lead.address ?? lead.sourceTerm ?? "—"}
</span>
</td>
{/* Status */}
<td style={{ padding: "11px 16px" }}>
<StatusCell lead={lead} onUpdate={onLeadUpdate} />
</td>
{/* Gefunden am */}
<td style={{ padding: "11px 16px" }}>
<span
style={{ fontSize: 12, color: "#9ca3af" }}
title={fullDate(lead.capturedAt)}
>
{relativeDate(lead.capturedAt)}
</span>
</td>
{/* Aktionen */}
<td style={{ padding: "11px 16px", textAlign: "right", whiteSpace: "nowrap" }}>
<div style={{ display: "flex", gap: 6, justifyContent: "flex-end" }}>
{/* Primary action */}
<button
onClick={() => (isNew ? handleMarkContacted(lead) : undefined)}
style={{
background: isNew ? "rgba(59,130,246,0.12)" : "#0d0d18",
border: `1px solid ${isNew ? "rgba(59,130,246,0.3)" : "#2e2e3e"}`,
borderRadius: 6,
padding: "4px 10px",
fontSize: 11,
color: isNew ? "#93c5fd" : "#9ca3af",
cursor: "pointer",
transition: "all 0.1s",
}}
>
{isNew ? "Kontaktiert ✓" : "Status ändern"}
</button>
{/* Notiz */}
<button
onClick={() => setSidePanelLead(lead)}
style={{
background: "#0d0d18",
border: "1px solid #2e2e3e",
borderRadius: 6,
padding: "4px 10px",
fontSize: 11,
color: "#9ca3af",
cursor: "pointer",
}}
>
Notiz
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Side panel */}
{sidePanelLead && (
<LeadSidePanel
lead={sidePanelLead}
onClose={() => setSidePanelLead(null)}
onUpdate={(id, updates) => {
onLeadUpdate(id, updates);
setSidePanelLead((prev) => (prev && prev.id === id ? { ...prev, ...updates } : prev));
}}
/>
)}
</>
);
}