Files
lead-scraper/components/leadspeicher/LeadSidePanel.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

413 lines
11 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { STATUS_OPTIONS, STATUS_MAP } from "./StatusBadge";
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;
industry: string | null;
sourceTerm: string | null;
address: string | null;
status: string;
notes: string | null;
capturedAt: string;
}
interface LeadSidePanelProps {
lead: Lead;
onClose: () => void;
onUpdate: (id: string, updates: Partial<Lead>) => void;
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
export function LeadSidePanel({ lead, onClose, onUpdate }: LeadSidePanelProps) {
const [notes, setNotes] = useState(lead.notes || "");
const [status, setStatus] = useState(lead.status);
const [saved, setSaved] = useState(false);
const saveTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
setNotes(lead.notes || "");
setStatus(lead.status);
}, [lead.id, lead.notes, lead.status]);
const saveNotes = useCallback(
async (value: string) => {
try {
await fetch(`/api/leads/${lead.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notes: value }),
});
onUpdate(lead.id, { notes: value });
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch {
// silently fail
}
},
[lead.id, onUpdate]
);
function handleNotesChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
const val = e.target.value;
setNotes(val);
if (saveTimeout.current) clearTimeout(saveTimeout.current);
saveTimeout.current = setTimeout(() => saveNotes(val), 500);
}
async function handleStatusChange(e: React.ChangeEvent<HTMLSelectElement>) {
const newStatus = e.target.value;
setStatus(newStatus);
try {
await fetch(`/api/leads/${lead.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
});
onUpdate(lead.id, { status: newStatus });
} catch {
// silently fail
}
}
async function copyEmail() {
if (lead.email) {
await navigator.clipboard.writeText(lead.email);
}
}
return (
<>
{/* Backdrop */}
<div
onClick={onClose}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.4)",
zIndex: 300,
}}
/>
{/* Panel */}
<div
style={{
position: "fixed",
top: 0,
right: 0,
bottom: 0,
width: 380,
background: "#111118",
borderLeft: "1px solid #1e1e2e",
zIndex: 400,
overflowY: "auto",
padding: 24,
animation: "slideInRight 0.2s ease",
}}
>
{/* Header */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 20,
}}
>
<div>
<div style={{ fontSize: 15, fontWeight: 500, color: "#ffffff", marginBottom: 2 }}>
{lead.companyName || lead.domain || "Unbekannt"}
</div>
{lead.domain && (
<div style={{ fontSize: 11, color: "#6b7280" }}>{lead.domain}</div>
)}
</div>
<button
onClick={onClose}
style={{
background: "transparent",
border: "none",
color: "#6b7280",
cursor: "pointer",
fontSize: 18,
lineHeight: 1,
padding: 4,
}}
>
&times;
</button>
</div>
{/* Status */}
<div style={{ marginBottom: 16 }}>
<label
style={{
display: "block",
fontSize: 11,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 6,
}}
>
Status
</label>
<select
value={status}
onChange={handleStatusChange}
style={{
width: "100%",
background: "#0d0d18",
border: "1px solid #1e1e2e",
borderRadius: 8,
padding: "8px 12px",
fontSize: 13,
color: STATUS_MAP[status]?.color ?? "#ffffff",
cursor: "pointer",
outline: "none",
}}
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div style={{ height: 1, background: "#1e1e2e", marginBottom: 16 }} />
{/* Email */}
<div style={{ marginBottom: 14 }}>
<label
style={{
display: "block",
fontSize: 11,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 6,
}}
>
E-Mail
</label>
{lead.email ? (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span
style={{
fontFamily: "monospace",
fontSize: 13,
color: "#3b82f6",
flex: 1,
wordBreak: "break-all",
}}
>
{lead.email}
</span>
<button
onClick={copyEmail}
title="Kopieren"
style={{
background: "#0d0d18",
border: "1px solid #1e1e2e",
borderRadius: 6,
padding: "4px 8px",
fontSize: 11,
color: "#9ca3af",
cursor: "pointer",
flexShrink: 0,
}}
>
Kopieren
</button>
</div>
) : (
<span style={{ fontSize: 13, color: "#6b7280" }}> nicht gefunden</span>
)}
</div>
{/* LinkedIn */}
{lead.linkedinUrl && (
<div style={{ marginBottom: 14 }}>
<label
style={{
display: "block",
fontSize: 11,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 6,
}}
>
LinkedIn
</label>
<a
href={lead.linkedinUrl}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: 13, color: "#3b82f6", wordBreak: "break-all" }}
>
{lead.linkedinUrl}
</a>
</div>
)}
{/* Phone */}
{lead.phone && (
<div style={{ marginBottom: 14 }}>
<label
style={{
display: "block",
fontSize: 11,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 6,
}}
>
Telefon
</label>
<span style={{ fontSize: 13, color: "#ffffff" }}>{lead.phone}</span>
</div>
)}
{/* Industry */}
{lead.industry && (
<div style={{ marginBottom: 14 }}>
<label
style={{
display: "block",
fontSize: 11,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 6,
}}
>
Branche
</label>
<span style={{ fontSize: 13, color: "#9ca3af" }}>{lead.industry}</span>
</div>
)}
{/* Region */}
{(lead.sourceTerm || lead.address) && (
<div style={{ marginBottom: 14 }}>
<label
style={{
display: "block",
fontSize: 11,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: 6,
}}
>
Region
</label>
<span style={{ fontSize: 13, color: "#9ca3af" }}>
{lead.address ?? lead.sourceTerm}
</span>
</div>
)}
<div style={{ height: 1, background: "#1e1e2e", marginBottom: 16 }} />
{/* Notes */}
<div style={{ marginBottom: 14 }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 6,
}}
>
<label
style={{
fontSize: 11,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
Notiz
</label>
{saved && (
<span style={{ fontSize: 11, color: "#22c55e" }}>Gespeichert </span>
)}
</div>
<textarea
value={notes}
onChange={handleNotesChange}
placeholder="Notizen zu diesem Lead…"
rows={4}
style={{
width: "100%",
background: "#0d0d18",
border: "1px solid #1e1e2e",
borderRadius: 8,
padding: "8px 12px",
fontSize: 13,
color: "#ffffff",
resize: "vertical",
outline: "none",
fontFamily: "inherit",
boxSizing: "border-box",
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = "#3b82f6";
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = "#1e1e2e";
}}
/>
</div>
{/* Meta */}
<div
style={{
borderTop: "1px solid #1e1e2e",
paddingTop: 16,
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={{ fontSize: 11, color: "#6b7280" }}>Gefunden am</span>
<span style={{ fontSize: 11, color: "#9ca3af" }}>{formatDate(lead.capturedAt)}</span>
</div>
{lead.sourceTerm && (
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={{ fontSize: 11, color: "#6b7280" }}>Suchbegriff</span>
<span style={{ fontSize: 11, color: "#9ca3af" }}>{lead.sourceTerm}</span>
</div>
)}
</div>
</div>
<style>{`
@keyframes slideInRight {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
`}</style>
</>
);
}