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:
Timo Uttenweiler
2026-04-08 21:06:07 +02:00
parent e5172cbdc5
commit 54e0d22f9c
14 changed files with 866 additions and 47 deletions

View File

@@ -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"