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>
This commit is contained in:
125
components/leadspeicher/BulkActionBar.tsx
Normal file
125
components/leadspeicher/BulkActionBar.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { STATUS_OPTIONS } from "./StatusBadge";
|
||||
|
||||
interface BulkActionBarProps {
|
||||
selectedCount: number;
|
||||
onSetStatus: (status: string) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function BulkActionBar({ selectedCount, onSetStatus, onDelete }: BulkActionBarProps) {
|
||||
const [showStatusMenu, setShowStatusMenu] = useState(false);
|
||||
|
||||
if (selectedCount === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 24,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
background: "#111118",
|
||||
border: "1px solid #1e1e2e",
|
||||
borderRadius: 8,
|
||||
padding: "8px 16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
zIndex: 200,
|
||||
boxShadow: "0 4px 24px rgba(0,0,0,0.5)",
|
||||
animation: "slideUp 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, color: "#9ca3af", whiteSpace: "nowrap" }}>
|
||||
{selectedCount} ausgewählt
|
||||
</span>
|
||||
<span style={{ color: "#1e1e2e" }}>·</span>
|
||||
|
||||
{/* Status dropdown */}
|
||||
<div style={{ position: "relative" }}>
|
||||
<button
|
||||
onClick={() => setShowStatusMenu((v) => !v)}
|
||||
style={{
|
||||
background: "#0d0d18",
|
||||
border: "1px solid #2e2e3e",
|
||||
borderRadius: 6,
|
||||
padding: "5px 12px",
|
||||
fontSize: 13,
|
||||
color: "#ffffff",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
Status setzen ▾
|
||||
</button>
|
||||
{showStatusMenu && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "calc(100% + 4px)",
|
||||
left: 0,
|
||||
background: "#111118",
|
||||
border: "1px solid #1e1e2e",
|
||||
borderRadius: 8,
|
||||
padding: 4,
|
||||
minWidth: 160,
|
||||
boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
|
||||
zIndex: 300,
|
||||
}}
|
||||
>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => { onSetStatus(opt.value); setShowStatusMenu(false); }}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "block",
|
||||
padding: "7px 10px",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
color: "#9ca3af",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = "#1e1e2e"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = "transparent"; }}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
onClick={onDelete}
|
||||
style={{
|
||||
background: "rgba(239,68,68,0.1)",
|
||||
border: "1px solid rgba(239,68,68,0.3)",
|
||||
borderRadius: 6,
|
||||
padding: "5px 12px",
|
||||
fontSize: 13,
|
||||
color: "#ef4444",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
|
||||
<style>{`
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
412
components/leadspeicher/LeadSidePanel.tsx
Normal file
412
components/leadspeicher/LeadSidePanel.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
335
components/leadspeicher/LeadsTable.tsx
Normal file
335
components/leadspeicher/LeadsTable.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
"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));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
136
components/leadspeicher/LeadsToolbar.tsx
Normal file
136
components/leadspeicher/LeadsToolbar.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
|
||||
interface LeadsToolbarProps {
|
||||
search: string;
|
||||
status: string;
|
||||
onSearchChange: (v: string) => void;
|
||||
onStatusChange: (v: string) => void;
|
||||
exportParams: Record<string, string>;
|
||||
}
|
||||
|
||||
export function LeadsToolbar({
|
||||
search,
|
||||
status,
|
||||
onSearchChange,
|
||||
onStatusChange,
|
||||
exportParams,
|
||||
}: LeadsToolbarProps) {
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
function handleSearchInput(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const val = e.target.value;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => onSearchChange(val), 300);
|
||||
// immediate update of input
|
||||
e.currentTarget.value = val;
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
const params = new URLSearchParams({ format: "xlsx", ...exportParams });
|
||||
window.location.href = `/api/leads/export?${params.toString()}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: 52,
|
||||
zIndex: 40,
|
||||
background: "#111118",
|
||||
borderBottom: "1px solid #1e1e2e",
|
||||
padding: "14px 20px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div style={{ flex: 1, position: "relative" }}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 10,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
pointerEvents: "none",
|
||||
color: "#6b7280",
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<circle cx="6" cy="6" r="4.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<line x1="9.5" y1="9.5" x2="12.5" y2="12.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={search}
|
||||
onChange={handleSearchInput}
|
||||
placeholder="Unternehmen, E-Mail, Branche…"
|
||||
style={{
|
||||
width: "100%",
|
||||
background: "#0d0d18",
|
||||
border: "1px solid #1e1e2e",
|
||||
borderRadius: 8,
|
||||
padding: "8px 12px 8px 32px",
|
||||
fontSize: 13,
|
||||
color: "#ffffff",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
onFocus={(e) => { e.currentTarget.style.borderColor = "#3b82f6"; }}
|
||||
onBlur={(e) => { e.currentTarget.style.borderColor = "#1e1e2e"; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status filter */}
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => onStatusChange(e.target.value)}
|
||||
style={{
|
||||
width: 150,
|
||||
background: "#0d0d18",
|
||||
border: "1px solid #1e1e2e",
|
||||
borderRadius: 8,
|
||||
padding: "8px 12px",
|
||||
fontSize: 13,
|
||||
color: "#ffffff",
|
||||
cursor: "pointer",
|
||||
outline: "none",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<option value="">Alle Status</option>
|
||||
<option value="new">Neu</option>
|
||||
<option value="contacted">Kontaktiert</option>
|
||||
<option value="in_progress">In Bearbeitung</option>
|
||||
<option value="not_relevant">Nicht relevant</option>
|
||||
<option value="converted">Konvertiert</option>
|
||||
</select>
|
||||
|
||||
{/* Export button */}
|
||||
<button
|
||||
onClick={handleExport}
|
||||
style={{
|
||||
background: "#0d0d18",
|
||||
border: "1px solid #2e2e3e",
|
||||
borderRadius: 8,
|
||||
padding: "8px 14px",
|
||||
fontSize: 13,
|
||||
color: "#9ca3af",
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
transition: "color 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.color = "#ffffff"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.color = "#9ca3af"; }}
|
||||
>
|
||||
↓ Excel Export
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
components/leadspeicher/StatusBadge.tsx
Normal file
54
components/leadspeicher/StatusBadge.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
export type LeadStatus = "new" | "contacted" | "in_progress" | "not_relevant" | "converted";
|
||||
|
||||
interface StatusConfig {
|
||||
label: string;
|
||||
background: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const STATUS_MAP: Record<string, StatusConfig> = {
|
||||
new: { label: "Neu", background: "#1e3a5f", color: "#93c5fd" },
|
||||
contacted: { label: "Kontaktiert", background: "#064e3b", color: "#6ee7b7" },
|
||||
in_progress: { label: "In Bearbeitung", background: "#451a03", color: "#fcd34d" },
|
||||
not_relevant: { label: "Nicht relevant", background: "#1e1e2e", color: "#6b7280" },
|
||||
converted: { label: "Konvertiert", background: "#2e1065", color: "#d8b4fe" },
|
||||
};
|
||||
|
||||
export const STATUS_OPTIONS = [
|
||||
{ value: "new", label: "Neu" },
|
||||
{ value: "contacted", label: "Kontaktiert" },
|
||||
{ value: "in_progress", label: "In Bearbeitung" },
|
||||
{ value: "not_relevant", label: "Nicht relevant" },
|
||||
{ value: "converted", label: "Konvertiert" },
|
||||
];
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: string;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, onClick }: StatusBadgeProps) {
|
||||
const cfg = STATUS_MAP[status] ?? STATUS_MAP.new;
|
||||
return (
|
||||
<span
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
padding: "3px 10px",
|
||||
borderRadius: 20,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
background: cfg.background,
|
||||
color: cfg.color,
|
||||
cursor: onClick ? "pointer" : "default",
|
||||
userSelect: "none",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{cfg.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
82
components/leadspeicher/StatusPopover.tsx
Normal file
82
components/leadspeicher/StatusPopover.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { STATUS_OPTIONS, STATUS_MAP } from "./StatusBadge";
|
||||
|
||||
interface StatusPopoverProps {
|
||||
currentStatus: string;
|
||||
onSelect: (status: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function StatusPopover({ currentStatus, onSelect, onClose }: StatusPopoverProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
position: "absolute",
|
||||
zIndex: 100,
|
||||
top: "calc(100% + 4px)",
|
||||
left: 0,
|
||||
background: "#111118",
|
||||
border: "1px solid #1e1e2e",
|
||||
borderRadius: 8,
|
||||
padding: 4,
|
||||
minWidth: 160,
|
||||
boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
|
||||
}}
|
||||
>
|
||||
{STATUS_OPTIONS.map((opt) => {
|
||||
const cfg = STATUS_MAP[opt.value];
|
||||
const isActive = opt.value === currentStatus;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => { onSelect(opt.value); onClose(); }}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "7px 10px",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
color: isActive ? "#ffffff" : "#9ca3af",
|
||||
background: isActive ? "#1e1e2e" : "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
transition: "background 0.1s",
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isActive) (e.currentTarget as HTMLButtonElement).style.background = "#0d0d18"; }}
|
||||
onMouseLeave={(e) => { if (!isActive) (e.currentTarget as HTMLButtonElement).style.background = "transparent"; }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
background: cfg.color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user