mein-solar: full feature set
- Schema: companyType, topics, salesScore, salesReason, offerPackage, approved, approvedAt, SearchHistory table - /api/search-history: GET (by mode) + POST (save query) - /api/ai-search: stadtwerke/industrie/custom prompts with history dedup - /api/enrich-leads: website scraping + GPT-4o-mini enrichment (fire-and-forget after each job) - /api/generate-email: personalized outreach via GPT-4o - Suche page: 3 mode tabs (Stadtwerke/Industrie/Freie Suche), Alle-Bundesländer queue button, AiSearchModal gets searchMode + history - Leadspeicher: Bewertung dots column, Paket badge column, Freigeben toggle button, email generator in SidePanel, approved-only export option - Leads API: approvedOnly + companyType filters, new fields in PATCH Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,13 @@ interface Lead {
|
||||
description: string | null;
|
||||
capturedAt: string;
|
||||
contactedAt: string | null;
|
||||
companyType: string | null;
|
||||
topics: string | null;
|
||||
salesScore: number | null;
|
||||
salesReason: string | null;
|
||||
offerPackage: string | null;
|
||||
approved: boolean;
|
||||
approvedAt: string | null;
|
||||
events?: Array<{ id: string; event: string; at: string }>;
|
||||
}
|
||||
|
||||
@@ -115,12 +122,33 @@ function SidePanel({ lead, onClose, onUpdate, onDelete }: {
|
||||
}) {
|
||||
const tags: string[] = JSON.parse(lead.tags || "[]");
|
||||
const src = SOURCE_CONFIG[lead.sourceTab];
|
||||
const [emailLoading, setEmailLoading] = useState(false);
|
||||
const [generatedEmail, setGeneratedEmail] = useState<{ subject: string; body: string } | null>(null);
|
||||
|
||||
function copy(text: string, label: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success(`${label} kopiert`);
|
||||
}
|
||||
|
||||
async function generateEmail() {
|
||||
setEmailLoading(true);
|
||||
setGeneratedEmail(null);
|
||||
try {
|
||||
const res = await fetch("/api/generate-email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ leadId: lead.id }),
|
||||
});
|
||||
const data = await res.json() as { subject?: string; body?: string; error?: string };
|
||||
if (!res.ok || !data.subject) throw new Error(data.error || "Fehler");
|
||||
setGeneratedEmail({ subject: data.subject, body: data.body! });
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "E-Mail-Generierung fehlgeschlagen");
|
||||
} finally {
|
||||
setEmailLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex justify-end" onClick={onClose}>
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" />
|
||||
@@ -217,6 +245,77 @@ function SidePanel({ lead, onClose, onUpdate, onDelete }: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KI-Bewertung */}
|
||||
{(lead.salesScore || lead.offerPackage) && (
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-2">KI-Bewertung</p>
|
||||
<div className="space-y-1.5">
|
||||
{lead.salesScore && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-0.5">
|
||||
{[1,2,3,4,5].map(i => (
|
||||
<span key={i} style={{
|
||||
width: 8, height: 8, borderRadius: "50%", display: "inline-block",
|
||||
background: i <= lead.salesScore! ? (lead.salesScore! >= 4 ? "#22c55e" : lead.salesScore! >= 3 ? "#f59e0b" : "#6b7280") : "#1e1e2e",
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
{lead.salesReason && <span className="text-xs text-gray-400">{lead.salesReason}</span>}
|
||||
</div>
|
||||
)}
|
||||
{lead.offerPackage && <span className="text-xs text-gray-400">Paket: <span className="text-gray-200">{lead.offerPackage}</span></span>}
|
||||
{lead.topics && (() => {
|
||||
try { return <span className="text-xs text-gray-400">Themen: <span className="text-gray-200">{(JSON.parse(lead.topics) as string[]).join(", ")}</span></span>; }
|
||||
catch { return null; }
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* E-Mail Generator */}
|
||||
{lead.email && (
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider mb-2">Erstansprache</p>
|
||||
{!generatedEmail ? (
|
||||
<button
|
||||
onClick={generateEmail}
|
||||
disabled={emailLoading}
|
||||
style={{
|
||||
width: "100%", padding: "8px 12px", borderRadius: 8, border: "1px solid rgba(139,92,246,0.35)",
|
||||
background: "rgba(139,92,246,0.08)", color: "#a78bfa", fontSize: 12, cursor: emailLoading ? "not-allowed" : "pointer",
|
||||
display: "flex", alignItems: "center", justifyContent: "center", gap: 6,
|
||||
}}
|
||||
>
|
||||
{emailLoading ? "✨ E-Mail wird generiert…" : "✨ E-Mail generieren"}
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ background: "#0d0d18", border: "1px solid #1e1e2e", borderRadius: 8, padding: 12 }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 10, color: "#6b7280", marginBottom: 4, textTransform: "uppercase", letterSpacing: "0.05em" }}>Betreff</div>
|
||||
<div style={{ display: "flex", gap: 6, alignItems: "flex-start" }}>
|
||||
<span style={{ fontSize: 12, color: "#fff", flex: 1 }}>{generatedEmail.subject}</span>
|
||||
<button onClick={() => copy(generatedEmail.subject, "Betreff")} style={{ background: "none", border: "none", color: "#6b7280", cursor: "pointer", padding: 2 }}>
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 10, color: "#6b7280", marginBottom: 4, textTransform: "uppercase", letterSpacing: "0.05em" }}>Nachricht</div>
|
||||
<div style={{ display: "flex", gap: 6, alignItems: "flex-start" }}>
|
||||
<pre style={{ fontSize: 11, color: "#d1d5db", whiteSpace: "pre-wrap", flex: 1, fontFamily: "inherit", lineHeight: 1.6, margin: 0 }}>{generatedEmail.body}</pre>
|
||||
<button onClick={() => copy(generatedEmail.body, "E-Mail")} style={{ background: "none", border: "none", color: "#6b7280", cursor: "pointer", padding: 2, flexShrink: 0 }}>
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setGeneratedEmail(null)} style={{ marginTop: 8, fontSize: 11, color: "#6b7280", background: "none", border: "none", cursor: "pointer" }}>
|
||||
Neu generieren
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Company info */}
|
||||
{(lead.headcount || lead.country) && (
|
||||
<div>
|
||||
@@ -490,6 +589,7 @@ export default function LeadVaultPage() {
|
||||
{([
|
||||
["Aktuelle Ansicht", () => exportFile("xlsx")],
|
||||
["Nur mit E-Mail", () => exportFile("xlsx", true)],
|
||||
["✓ Freigegebene exportieren", () => { const p = new URLSearchParams({ format: "xlsx", approvedOnly: "true" }); window.open(`/api/leads/export?${p}`, "_blank"); }],
|
||||
] as [string, () => void][]).map(([label, fn]) => (
|
||||
<button key={label} onClick={() => { fn(); setExportOpen(false); }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-[#2e2e3e] rounded">
|
||||
@@ -686,6 +786,8 @@ export default function LeadVaultPage() {
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500">Bewertung</th>
|
||||
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500">Paket</th>
|
||||
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500">Tags</th>
|
||||
<th className="px-3 py-2.5 text-right text-xs font-medium text-gray-500">Aktionen</th>
|
||||
</tr>
|
||||
@@ -797,6 +899,39 @@ export default function LeadVaultPage() {
|
||||
<td className="px-3 py-2.5 whitespace-nowrap" title={new Date(lead.capturedAt).toLocaleString("de-DE")}>
|
||||
<span className="text-xs text-gray-500">{new Date(lead.capturedAt).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" })}</span>
|
||||
</td>
|
||||
{/* Bewertung */}
|
||||
<td className="px-3 py-2.5" title={lead.salesReason || ""}>
|
||||
{lead.salesScore ? (
|
||||
<div className="flex gap-0.5">
|
||||
{[1,2,3,4,5].map(i => (
|
||||
<span key={i} style={{
|
||||
width: 7, height: 7, borderRadius: "50%",
|
||||
background: i <= lead.salesScore!
|
||||
? (lead.salesScore! >= 4 ? "#22c55e" : lead.salesScore! >= 3 ? "#f59e0b" : "#6b7280")
|
||||
: "#1e1e2e",
|
||||
display: "inline-block",
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
) : <span className="text-xs text-gray-600">–</span>}
|
||||
</td>
|
||||
{/* Paket */}
|
||||
<td className="px-3 py-2.5">
|
||||
{lead.offerPackage ? (() => {
|
||||
const pkgMap: Record<string, { label: string; style: React.CSSProperties }> = {
|
||||
"solar-basis": { label: "Basis", style: { background: "#1e1e2e", color: "#9ca3af" } },
|
||||
"solar-pro": { label: "Pro", style: { background: "#1e3a5f", color: "#93c5fd" } },
|
||||
"solar-speicher": { label: "Solar+Speicher", style: { background: "#2e1065", color: "#d8b4fe" } },
|
||||
"komplett": { label: "Komplett", style: { background: "#064e3b", color: "#6ee7b7" } },
|
||||
};
|
||||
const pkg = pkgMap[lead.offerPackage] ?? { label: lead.offerPackage, style: { background: "#1e1e2e", color: "#9ca3af" } };
|
||||
return (
|
||||
<span style={{ ...pkg.style, fontSize: 10, padding: "2px 7px", borderRadius: 10, whiteSpace: "nowrap" }}>
|
||||
{pkg.label}
|
||||
</span>
|
||||
);
|
||||
})() : <span className="text-xs text-gray-600">–</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex gap-1 flex-wrap max-w-[120px]">
|
||||
{tags.slice(0, 2).map(tag => (
|
||||
@@ -837,6 +972,20 @@ export default function LeadVaultPage() {
|
||||
: "p-1 rounded text-gray-600 hover:text-amber-400 hover:bg-amber-500/10 transition-all"}>
|
||||
<Star className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
updateLead(lead.id, { approved: !lead.approved, approvedAt: !lead.approved ? new Date().toISOString() : null });
|
||||
}}
|
||||
title={lead.approved ? "Freigegeben" : "Freigeben"}
|
||||
className={lead.approved
|
||||
? "p-1 rounded text-green-400 bg-green-500/10 transition-all"
|
||||
: "p-1 rounded text-gray-600 hover:text-green-400 hover:bg-green-500/10 transition-all"}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
{lead.domain && (
|
||||
<a href={`https://${lead.domain}`} target="_blank" rel="noreferrer"
|
||||
title="Website öffnen"
|
||||
|
||||
Reference in New Issue
Block a user