- 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>
413 lines
11 KiB
TypeScript
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,
|
|
}}
|
|
>
|
|
×
|
|
</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>
|
|
</>
|
|
);
|
|
}
|