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:
@@ -20,6 +20,17 @@ export default function SuchePage() {
|
||||
const [onlyNew, setOnlyNew] = useState(false);
|
||||
const [saveOnlyNew, setSaveOnlyNew] = useState(false);
|
||||
const [aiOpen, setAiOpen] = useState(false);
|
||||
const [searchMode, setSearchMode] = useState<"stadtwerke" | "industrie" | "custom">("stadtwerke");
|
||||
const [queueRunning, setQueueRunning] = useState(false);
|
||||
const [queueIndex, setQueueIndex] = useState(0);
|
||||
const [queueTotal, setQueueTotal] = useState(0);
|
||||
|
||||
const BUNDESLAENDER = [
|
||||
"Bayern", "NRW", "Baden-Württemberg", "Hessen", "Niedersachsen",
|
||||
"Sachsen", "Berlin", "Hamburg", "Bremen", "Thüringen",
|
||||
"Sachsen-Anhalt", "Brandenburg", "Mecklenburg-Vorpommern",
|
||||
"Saarland", "Rheinland-Pfalz", "Schleswig-Holstein",
|
||||
];
|
||||
|
||||
function handleChange(field: "query" | "region" | "count", value: string | number) {
|
||||
if (field === "query") setQuery(value as string);
|
||||
@@ -59,12 +70,67 @@ export default function SuchePage() {
|
||||
setLoading(false);
|
||||
setLeads(result);
|
||||
setSearchDone(true);
|
||||
// Fire-and-forget KI-Anreicherung
|
||||
if (jobId) {
|
||||
fetch("/api/enrich-leads", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ jobId }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
if (warning) {
|
||||
toast.warning(`${result.length} Unternehmen gefunden — E-Mail-Anreicherung fehlgeschlagen: ${warning}`, { duration: 6000 });
|
||||
} else {
|
||||
toast.success(`✓ ${result.length} Leads gefunden und im Leadspeicher gespeichert`, { duration: 4000 });
|
||||
}
|
||||
}, []);
|
||||
}, [jobId]);
|
||||
|
||||
async function startStadtwerkeQueue() {
|
||||
if (loading || queueRunning) return;
|
||||
setQueueRunning(true);
|
||||
setQueueTotal(BUNDESLAENDER.length);
|
||||
setQueueIndex(0);
|
||||
setLeads([]);
|
||||
setSearchDone(false);
|
||||
|
||||
for (let i = 0; i < BUNDESLAENDER.length; i++) {
|
||||
setQueueIndex(i + 1);
|
||||
const bl = BUNDESLAENDER[i];
|
||||
toast.info(`Suche ${i + 1}/${BUNDESLAENDER.length}: Stadtwerke ${bl}`, { duration: 2000 });
|
||||
try {
|
||||
const res = await fetch("/api/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: "Stadtwerke", region: bl, count: 50 }),
|
||||
});
|
||||
const data = await res.json() as { jobId?: string };
|
||||
if (data.jobId) {
|
||||
// Save to history
|
||||
fetch("/api/search-history", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: "Stadtwerke", region: bl, searchMode: "stadtwerke" }),
|
||||
}).catch(() => {});
|
||||
// Poll until done
|
||||
await new Promise<void>((resolve) => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const statusRes = await fetch(`/api/jobs/${data.jobId}/status`);
|
||||
const status = await statusRes.json() as { status: string };
|
||||
if (status.status === "complete" || status.status === "failed") {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
} catch { clearInterval(interval); resolve(); }
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
} catch { /* continue with next */ }
|
||||
}
|
||||
|
||||
setQueueRunning(false);
|
||||
toast.success(`✓ Alle ${BUNDESLAENDER.length} Bundesländer durchsucht — Leads im Leadspeicher`, { duration: 5000 });
|
||||
}
|
||||
|
||||
async function handleDelete(ids: string[]) {
|
||||
if (!ids.length || deleting) return;
|
||||
@@ -162,12 +228,72 @@ export default function SuchePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode Tabs */}
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
|
||||
{([
|
||||
{ id: "stadtwerke" as const, icon: "⚡", label: "Stadtwerke", desc: "Kommunale Energieversorger" },
|
||||
{ id: "industrie" as const, icon: "🏭", label: "Industriebetriebe", desc: "Energieintensive Betriebe" },
|
||||
{ id: "custom" as const, icon: "🔍", label: "Freie Suche", desc: "Beliebige Zielgruppe" },
|
||||
]).map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setSearchMode(tab.id)}
|
||||
style={{
|
||||
flex: 1, padding: "12px 16px", borderRadius: 10, cursor: "pointer", textAlign: "left",
|
||||
border: searchMode === tab.id ? "1px solid rgba(59,130,246,0.5)" : "1px solid #1e1e2e",
|
||||
background: searchMode === tab.id ? "rgba(59,130,246,0.08)" : "#111118",
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 16, marginBottom: 4 }}>{tab.icon}</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: searchMode === tab.id ? "#93c5fd" : "#fff", marginBottom: 2 }}>{tab.label}</div>
|
||||
<div style={{ fontSize: 11, color: "#6b7280" }}>{tab.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stadtwerke Queue Button */}
|
||||
{searchMode === "stadtwerke" && !loading && !queueRunning && (
|
||||
<button
|
||||
onClick={startStadtwerkeQueue}
|
||||
style={{
|
||||
width: "100%", padding: "14px 20px", borderRadius: 10, marginBottom: 12,
|
||||
border: "1px solid rgba(59,130,246,0.4)", background: "rgba(59,130,246,0.06)",
|
||||
color: "#60a5fa", cursor: "pointer", textAlign: "left",
|
||||
display: "flex", flexDirection: "column", gap: 4,
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = "rgba(59,130,246,0.12)"; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = "rgba(59,130,246,0.06)"; }}
|
||||
>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>⚡ Alle deutschen Stadtwerke durchsuchen (16 Bundesländer)</div>
|
||||
<div style={{ fontSize: 11, color: "#6b7280" }}>Startet 16 aufeinanderfolgende Suchen · ca. 800 Ergebnisse</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Queue running indicator */}
|
||||
{queueRunning && (
|
||||
<div style={{
|
||||
padding: "14px 20px", borderRadius: 10, marginBottom: 12,
|
||||
border: "1px solid rgba(59,130,246,0.3)", background: "rgba(59,130,246,0.06)",
|
||||
display: "flex", alignItems: "center", gap: 12,
|
||||
}}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" strokeWidth="2" style={{ animation: "spin 1s linear infinite", flexShrink: 0 }}>
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||
</svg>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, color: "#93c5fd", fontWeight: 500 }}>Bundesland {queueIndex} von {queueTotal} wird durchsucht…</div>
|
||||
<div style={{ fontSize: 11, color: "#6b7280", marginTop: 2 }}>Nicht schließen — Leads werden automatisch gespeichert</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
|
||||
|
||||
{/* Search Card */}
|
||||
<SearchCard
|
||||
query={query}
|
||||
region={region}
|
||||
count={count}
|
||||
loading={loading}
|
||||
loading={loading || queueRunning}
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
@@ -188,10 +314,10 @@ export default function SuchePage() {
|
||||
{/* AI Modal */}
|
||||
{aiOpen && (
|
||||
<AiSearchModal
|
||||
searchMode={searchMode}
|
||||
onStart={(queries) => {
|
||||
setAiOpen(false);
|
||||
if (!queries.length) return;
|
||||
// Fill first query into the search fields and submit
|
||||
const first = queries[0];
|
||||
setQuery(first.query);
|
||||
setRegion(first.region);
|
||||
@@ -201,6 +327,12 @@ export default function SuchePage() {
|
||||
setLeads([]);
|
||||
setSearchDone(false);
|
||||
setSelected(new Set());
|
||||
// Save to history
|
||||
fetch("/api/search-history", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: first.query, region: first.region, searchMode }),
|
||||
}).catch(() => {});
|
||||
fetch("/api/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
Reference in New Issue
Block a user