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

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
const SYSTEM_PROMPT = `Du bist ein spezialisierter Assistent für B2B-Lead-Generierung im deutschsprachigen Raum.
const SYSTEM_PROMPT_DEFAULT = `Du bist ein spezialisierter Assistent für B2B-Lead-Generierung im deutschsprachigen Raum.
## Was passiert mit deiner Ausgabe
@@ -36,45 +36,110 @@ Nutze mehrere Queries wenn:
Maximal 4 Queries. Keine Duplikate (gleiche query + gleiche region).
## Beispiele
Eingabe: "Dachdecker in Bayern"
Ausgabe:
[
{ "query": "Dachdecker", "region": "München" },
{ "query": "Dachdecker", "region": "Nürnberg" }
]
Eingabe: "Steuerberater in ganz Deutschland"
Ausgabe:
[
{ "query": "Steuerberater", "region": "Bayern" },
{ "query": "Steuerberater", "region": "NRW" },
{ "query": "Steuerberater", "region": "Hamburg" },
{ "query": "Steuerberater", "region": "Berlin" }
]
Eingabe: "Solaranlagen Installateure und Elektriker in Stuttgart"
Ausgabe:
[
{ "query": "Solaranlage", "region": "Stuttgart" },
{ "query": "Elektriker", "region": "Stuttgart" }
]
Eingabe: "Metallbaubetriebe in Süddeutschland"
Ausgabe:
[
{ "query": "Metallbau", "region": "Bayern" },
{ "query": "Metallbau", "region": "Baden-Württemberg" }
]
## Ausgabeformat
Antworte ausschließlich mit einem JSON-Array. Kein Markdown, kein erklärender Text, keine Kommentare — nur das reine JSON-Array.`;
const SYSTEM_PROMPT_STADTWERKE = `Du bist ein spezialisierter Assistent für B2B-Lead-Generierung im deutschen Energiesektor.
## Kontext
Das Tool sucht deutsche Stadtwerke und kommunale Energieversorger als potenzielle Kunden für ein Solarunternehmen (mein-solar.com). Stadtwerke betreiben lokale Energie-, Wasser- und Verkehrsinfrastruktur und sind starke Abnehmer für Photovoltaik-Großanlagen, Batteriespeicher und Ladeinfrastruktur.
## Was passiert mit deiner Ausgabe
Die Queries werden an die Google Maps Places API und Google SERP übergeben. Gesucht werden Stadtwerke-Websites, aus denen dann Entscheidungsträger-E-Mails extrahiert werden.
## Deine Aufgabe
Generiere 24 Suchanfragen die Stadtwerke, Gemeindewerke und kommunale Energieversorger in Deutschland finden. Decke verschiedene Bundesländer oder Regionen ab.
## Suchbegriffe die funktionieren
- "Stadtwerke" — findet kommunale Versorger
- "Gemeindewerk" — kleinere Gemeindebetriebe
- "Stadtwerk" — Variante
- "kommunaler Energieversorger" — für SERP
- Niemals: LinkedIn, XING, Verzeichnisse, Social Media
## Priorisierung
"Stadtwerke" schlägt immer "Gemeindewerk". Große Bundesländer (NRW, Bayern, BW) zuerst wenn keine Region angegeben.
## Bereits verwendete Queries (diese NICHT nochmal verwenden — generiere neue Regionen/Varianten):
[HISTORY_PLACEHOLDER]
## Beispiel
Eingabe: "Stadtwerke in Norddeutschland"
Ausgabe:
[
{ "query": "Stadtwerke", "region": "Hamburg" },
{ "query": "Stadtwerke", "region": "Schleswig-Holstein" },
{ "query": "Gemeindewerk", "region": "Niedersachsen" }
]
## Ausgabeformat
Nur reines JSON-Array, kein Markdown, keine Erklärungen.`;
const SYSTEM_PROMPT_INDUSTRIE = `Du bist ein spezialisierter Assistent für B2B-Lead-Generierung im deutschen Industriesektor.
## Kontext
Das Tool sucht deutsche Industriebetriebe als Kunden für ein Solarunternehmen (mein-solar.com), das PV-Großanlagen (5 kW bis 10+ MW), Batteriespeicher und Ladeinfrastruktur anbietet. Zielkunden sind Firmen mit hohem Energieverbrauch, großen Dachflächen oder Freiflächen, und Interesse an Kostensenkung durch Eigenerzeugung.
## Was passiert mit deiner Ausgabe
Die Queries werden an Google Maps und SERP übergeben um Unternehmenswebsites zu finden, aus denen Entscheidungsträger-E-Mails extrahiert werden.
## Deine Aufgabe
Generiere 24 Suchanfragen die energieintensive Industriebetriebe, Produktionsunternehmen, Logistiker oder Gewerbeparks finden — ideale Kandidaten für Solar-Großanlagen.
## Gut geeignete Branchen
Produktion/Fertigung, Logistik/Lager, Lebensmittelindustrie, Metallverarbeitung, Automobilzulieferer, Landwirtschaft (Agri-PV), Gewerbeparks, Einzelhandel (große Flächen)
## Suchbegriffe die funktionieren
Kurze branchenbezogene Begriffe die bei Google Maps echte Betriebe finden. Keine Adjektive.
## Priorisierung
Wähle immer den geläufigsten Begriff (z.B. "Logistikzentrum" vor "Lagerhaus"). Branchen mit hohem Energieverbrauch und Dachfläche zuerst.
## Bereits verwendete Queries (diese NICHT nochmal verwenden):
[HISTORY_PLACEHOLDER]
## Beispiel
Eingabe: "Industriebetriebe in Bayern mit großen Dachflächen"
Ausgabe:
[
{ "query": "Produktionsbetrieb", "region": "München" },
{ "query": "Logistikzentrum", "region": "Augsburg" },
{ "query": "Lebensmittelproduktion", "region": "Bayern" }
]
## Ausgabeformat
Nur reines JSON-Array, kein Markdown, keine Erklärungen.`;
function buildSystemPrompt(
searchMode: string,
history: Array<{ query: string; region: string }>
): string {
const historyText =
history.length > 0
? history
.slice(0, 20)
.map(h => `- "${h.query}" in "${h.region}"`)
.join("\n")
: "Keine bisherigen Suchen.";
if (searchMode === "stadtwerke") {
return SYSTEM_PROMPT_STADTWERKE.replace("[HISTORY_PLACEHOLDER]", historyText);
}
if (searchMode === "industrie") {
return SYSTEM_PROMPT_INDUSTRIE.replace("[HISTORY_PLACEHOLDER]", historyText);
}
return SYSTEM_PROMPT_DEFAULT;
}
export async function POST(req: NextRequest) {
try {
const { description } = await req.json() as { description: string };
const body = await req.json() as {
description: string;
searchMode?: string;
history?: Array<{ query: string; region: string }>;
};
const { description, searchMode = "custom", history = [] } = body;
if (!description?.trim()) {
return NextResponse.json({ error: "Beschreibung fehlt" }, { status: 400 });
@@ -85,20 +150,22 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "OpenRouter API Key nicht konfiguriert" }, { status: 500 });
}
const systemPrompt = buildSystemPrompt(searchMode, history);
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://onvyaleads.app",
"X-Title": "OnyvaLeads",
"HTTP-Referer": "https://mein-solar.com",
"X-Title": "MeinSolar Leads",
},
body: JSON.stringify({
model: "openai/gpt-4o-mini",
temperature: 0.4,
max_tokens: 512,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "system", content: systemPrompt },
{ role: "user", content: description.trim() },
],
}),
@@ -134,6 +201,18 @@ export async function POST(req: NextRequest) {
count: 50,
}));
// Save to SearchHistory (fire and forget)
if (searchMode && searchMode !== "custom" && queries.length > 0) {
const baseUrl = req.url.replace(/\/api\/ai-search.*/, "");
for (const q of queries) {
fetch(`${baseUrl}/api/search-history`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: q.query, region: q.region, searchMode }),
}).catch(err => console.error("[ai-search] search-history save error:", err));
}
}
return NextResponse.json({ queries });
} catch (err) {
console.error("[ai-search] error:", err);

View File

@@ -0,0 +1,173 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
const ENRICHMENT_PROMPT = `Du analysierst eine Unternehmenswebsite für ein Solarunternehmen und extrahierst strukturierte Informationen.
Antworte ausschließlich mit JSON:
{
"companyType": "stadtwerke" | "industrie" | "sonstiges",
"topics": ["photovoltaik"|"speicher"|"emobilitaet"|"waerme"|"netz"|"wasserstoff"],
"salesScore": 1-5,
"salesReason": "Ein Satz warum dieser Lead gut/schlecht passt",
"offerPackage": "solar-basis" | "solar-pro" | "solar-speicher" | "komplett"
}
Bewertungskriterien salesScore:
5 = Stadtwerk oder Industriebetrieb mit explizitem Energiethema, perfekter Fit
4 = Energierelevante Branche, wahrscheinlich guter Fit
3 = Mittelständischer Betrieb, möglicher Fit
2 = Kleiner Betrieb oder unklare Relevanz
1 = Offensichtlich nicht relevant (Privatpersonen, Behörden ohne Energie-Relevanz)
Angebotspaket-Logik:
- solar-basis: Kleine Betriebe <50 Mitarbeiter, residentiell
- solar-pro: Mittelstand, Gewerbe
- solar-speicher: Energieintensive Betriebe, Stadtwerke
- komplett: Stadtwerke, Großindustrie, wenn topics viele Themen enthält
Website-Text:
[TEXT]`;
async function fetchWebsiteText(domain: string): Promise<string> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
try {
const url = domain.startsWith("http") ? domain : `https://${domain}`;
const res = await fetch(url, {
signal: controller.signal,
headers: { "User-Agent": "Mozilla/5.0 (compatible; LeadBot/1.0)" },
});
clearTimeout(timeout);
const html = await res.text();
// Strip tags, extract text
const text = html
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 2000);
return text;
} catch {
clearTimeout(timeout);
return "";
}
}
interface EnrichmentResult {
companyType: string;
topics: string[];
salesScore: number;
salesReason: string;
offerPackage: string;
}
async function enrichWithAI(text: string, apiKey: string): Promise<EnrichmentResult | null> {
if (!text.trim()) return null;
const prompt = ENRICHMENT_PROMPT.replace("[TEXT]", text);
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://mein-solar.com",
"X-Title": "MeinSolar Leads",
},
body: JSON.stringify({
model: "openai/gpt-4o-mini",
temperature: 0.2,
max_tokens: 256,
messages: [
{ role: "user", content: prompt },
],
}),
});
if (!res.ok) return null;
const data = await res.json() as { choices: Array<{ message: { content: string } }> };
const raw = data.choices[0]?.message?.content?.trim() ?? "";
try {
const parsed = JSON.parse(raw) as EnrichmentResult;
return parsed;
} catch {
const match = raw.match(/\{[\s\S]*\}/);
if (!match) return null;
try {
return JSON.parse(match[0]) as EnrichmentResult;
} catch {
return null;
}
}
}
export async function POST(req: NextRequest) {
try {
const { jobId } = await req.json() as { jobId: string };
if (!jobId) {
return NextResponse.json({ error: "jobId fehlt" }, { status: 400 });
}
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: "OpenRouter API Key nicht konfiguriert" }, { status: 500 });
}
// Load all LeadResults for this job
const results = await prisma.leadResult.findMany({
where: { jobId },
});
// Find corresponding leads with domains
const domainsToEnrich = results
.filter(r => r.domain)
.map(r => r.domain as string);
if (domainsToEnrich.length === 0) {
return NextResponse.json({ enriched: 0, skipped: 0 });
}
// Find leads matching these domains
const leads = await prisma.lead.findMany({
where: {
domain: { in: domainsToEnrich },
salesScore: null, // only enrich if not already enriched
},
});
let enriched = 0;
let skipped = 0;
for (const lead of leads) {
if (!lead.domain) { skipped++; continue; }
const text = await fetchWebsiteText(lead.domain);
if (!text) { skipped++; continue; }
const result = await enrichWithAI(text, apiKey);
if (!result) { skipped++; continue; }
await prisma.lead.update({
where: { id: lead.id },
data: {
companyType: result.companyType || null,
topics: result.topics ? JSON.stringify(result.topics) : null,
salesScore: typeof result.salesScore === "number" ? result.salesScore : null,
salesReason: result.salesReason || null,
offerPackage: result.offerPackage || null,
},
});
enriched++;
}
return NextResponse.json({ enriched, skipped, total: leads.length });
} catch (err) {
console.error("POST /api/enrich-leads error:", err);
return NextResponse.json({ error: "Enrichment failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,108 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
const EMAIL_PROMPT_TEMPLATE = `Du schreibst eine professionelle Erstansprache-E-Mail für mein-solar.com an einen potenziellen Kunden.
mein-solar.com ist ein Full-Service EPC-Anbieter für Photovoltaik (5 kW bis 10+ MW), Batteriespeicher und Ladeinfrastruktur.
Firmendaten des Empfängers:
- Unternehmen: [companyName]
- Ansprechpartner: [contactName], [contactTitle]
- Unternehmenstyp: [companyType]
- Themen des Unternehmens: [topics]
- Empfohlenes Paket: [offerPackage]
Schreibe eine E-Mail auf Deutsch:
- Betreff: prägnant, bezieht sich auf konkretes Thema des Unternehmens
- Anrede: personalisiert wenn Name bekannt
- 2-3 kurze Absätze: Bezug auf das Unternehmen → Mehrwert von mein-solar → konkreter nächster Schritt (Erstgespräch)
- Ton: professionell, direkt, kein Marketing-Sprech
- Keine Buzzwords wie "innovativ" oder "nachhaltig"
- Signatur: [Vorname] von mein-solar.com
Antworte mit JSON: { "subject": "...", "body": "..." }`;
export async function POST(req: NextRequest) {
try {
const { leadId } = await req.json() as { leadId: string };
if (!leadId) {
return NextResponse.json({ error: "leadId fehlt" }, { status: 400 });
}
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: "OpenRouter API Key nicht konfiguriert" }, { status: 500 });
}
const lead = await prisma.lead.findUnique({ where: { id: leadId } });
if (!lead) {
return NextResponse.json({ error: "Lead nicht gefunden" }, { status: 404 });
}
if (!lead.email) {
return NextResponse.json({ error: "Lead hat keine E-Mail-Adresse" }, { status: 400 });
}
let topicsText = "";
if (lead.topics) {
try {
const topicsArr = JSON.parse(lead.topics) as string[];
topicsText = topicsArr.join(", ");
} catch {
topicsText = lead.topics;
}
}
const prompt = EMAIL_PROMPT_TEMPLATE
.replace("[companyName]", lead.companyName || "Unbekannt")
.replace("[contactName]", lead.contactName || "")
.replace("[contactTitle]", lead.contactTitle || "")
.replace("[companyType]", lead.companyType || "")
.replace("[topics]", topicsText || "")
.replace("[offerPackage]", lead.offerPackage || "");
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://mein-solar.com",
"X-Title": "MeinSolar Leads",
},
body: JSON.stringify({
model: "openai/gpt-4o",
temperature: 0.5,
max_tokens: 600,
messages: [
{ role: "user", content: prompt },
],
}),
});
if (!res.ok) {
const err = await res.text();
console.error("[generate-email] OpenRouter error:", err);
return NextResponse.json({ error: "KI-Anfrage fehlgeschlagen" }, { status: 500 });
}
const data = await res.json() as { choices: Array<{ message: { content: string } }> };
const raw = data.choices[0]?.message?.content?.trim() ?? "";
let email: { subject: string; body: string };
try {
email = JSON.parse(raw) as { subject: string; body: string };
} catch {
const match = raw.match(/\{[\s\S]*\}/);
if (!match) {
return NextResponse.json({ error: "KI-Antwort konnte nicht geparst werden" }, { status: 500 });
}
email = JSON.parse(match[0]) as { subject: string; body: string };
}
return NextResponse.json({ subject: email.subject, body: email.body });
} catch (err) {
console.error("POST /api/generate-email error:", err);
return NextResponse.json({ error: "E-Mail-Generierung fehlgeschlagen" }, { status: 500 });
}
}

View File

@@ -13,6 +13,8 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
"status", "priority", "notes", "tags", "country", "headcount",
"industry", "contactedAt", "companyName", "contactName", "contactTitle",
"email", "phone", "linkedinUrl", "domain",
"companyType", "topics", "salesScore", "salesReason", "offerPackage",
"approved", "approvedAt",
];
const data: Record<string, unknown> = {};

View File

@@ -23,11 +23,13 @@ export async function GET(req: NextRequest) {
{ email: { contains: search } },
];
}
const approvedOnly = searchParams.get("approvedOnly") === "true";
if (statuses.length > 0) where.status = { in: statuses };
if (sourceTabs.length > 0) where.sourceTab = { in: sourceTabs };
if (priorities.length > 0) where.priority = { in: priorities };
if (hasEmail === "yes" || emailOnly) where.email = { not: null };
else if (hasEmail === "no") where.email = null;
if (approvedOnly) where.approved = true;
const leads = await prisma.lead.findMany({
where,
@@ -46,8 +48,14 @@ export async function GET(req: NextRequest) {
"LinkedIn": l.linkedinUrl || "",
"Branche": l.industry || "",
"Suchbegriff": l.sourceTerm || "",
"Tags": l.tags ? (JSON.parse(l.tags) as string[]).join(", ") : "",
"Erfasst am": new Date(l.capturedAt).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }),
"Tags": l.tags ? (JSON.parse(l.tags) as string[]).join(", ") : "",
"Unternehmenstyp": l.companyType || "",
"Themen": l.topics ? (JSON.parse(l.topics) as string[]).join(", ") : "",
"Vertriebsrelevanz": l.salesScore?.toString() || "",
"Begründung": l.salesReason || "",
"Angebotspaket": l.offerPackage || "",
"Freigegeben": l.approved ? "Ja" : "Nein",
"Erfasst am": new Date(l.capturedAt).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }),
}));
const filename = `onyva-leads-vault-${new Date().toISOString().split("T")[0]}`;

View File

@@ -22,6 +22,8 @@ export async function GET(req: NextRequest) {
const searchTerms = searchParams.getAll("searchTerm");
const contacted = searchParams.get("contacted");
const favorite = searchParams.get("favorite");
const approvedOnly = searchParams.get("approvedOnly");
const companyType = searchParams.get("companyType");
const where: Prisma.LeadWhereInput = {};
@@ -61,6 +63,9 @@ export async function GET(req: NextRequest) {
where.tags = { contains: "favorit" };
}
if (approvedOnly === "true") where.approved = true;
if (companyType) where.companyType = companyType;
if (tags.length > 0) {
// SQLite JSON contains — search for each tag in the JSON string
where.AND = tags.map(tag => ({

View File

@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const mode = searchParams.get("mode") || "";
const where = mode ? { searchMode: mode } : {};
const history = await prisma.searchHistory.findMany({
where,
orderBy: { executedAt: "desc" },
take: 50,
select: { query: true, region: true, searchMode: true, executedAt: true },
});
return NextResponse.json(history);
} catch (err) {
console.error("GET /api/search-history error:", err);
return NextResponse.json({ error: "Failed to fetch search history" }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json() as { query: string; region: string; searchMode: string };
const { query, region, searchMode } = body;
if (!query?.trim() || !searchMode?.trim()) {
return NextResponse.json({ error: "query und searchMode sind erforderlich" }, { status: 400 });
}
const entry = await prisma.searchHistory.create({
data: {
query: query.trim(),
region: (region || "").trim(),
searchMode: searchMode.trim(),
},
});
return NextResponse.json(entry);
} catch (err) {
console.error("POST /api/search-history error:", err);
return NextResponse.json({ error: "Failed to save search history" }, { status: 500 });
}
}

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"

View File

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

View File

@@ -11,9 +11,10 @@ interface Query {
interface AiSearchModalProps {
onStart: (queries: Query[]) => void;
onClose: () => void;
searchMode?: string;
}
export function AiSearchModal({ onStart, onClose }: AiSearchModalProps) {
export function AiSearchModal({ onStart, onClose, searchMode = "custom" }: AiSearchModalProps) {
const [description, setDescription] = useState("");
const [loading, setLoading] = useState(false);
const [queries, setQueries] = useState<Query[]>([]);
@@ -26,10 +27,19 @@ export function AiSearchModal({ onStart, onClose }: AiSearchModalProps) {
setError("");
setQueries([]);
try {
// Load history for this mode
let history: Array<{ query: string; region: string }> = [];
if (searchMode !== "custom") {
try {
const hRes = await fetch(`/api/search-history?mode=${searchMode}`);
if (hRes.ok) history = await hRes.json() as Array<{ query: string; region: string }>;
} catch { /* ignore */ }
}
const res = await fetch("/api/ai-search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description }),
body: JSON.stringify({ description, searchMode, history }),
});
const data = await res.json() as { queries?: Query[]; error?: string };
if (!res.ok || !data.queries) throw new Error(data.error || "Fehler");

22
package-lock.json generated
View File

@@ -26,7 +26,6 @@
"lucide-react": "^0.577.0",
"next": "16.1.7",
"next-themes": "^0.4.6",
"openai": "^6.33.0",
"papaparse": "^5.5.3",
"prisma": "^7.5.0",
"react": "19.2.3",
@@ -45,6 +44,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.7",
"openai": "^6.33.0",
"tailwindcss": "^4",
"typescript": "^5"
}
@@ -116,6 +116,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -758,6 +759,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -813,7 +815,8 @@
"version": "0.3.15",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz",
"integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==",
"license": "Apache-2.0"
"license": "Apache-2.0",
"peer": true
},
"node_modules/@electric-sql/pglite-socket": {
"version": "0.0.20",
@@ -2130,6 +2133,7 @@
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
"peer": true,
"engines": {
"node": "^14.21.3 || >=16"
},
@@ -2892,6 +2896,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2978,6 +2983,7 @@
"integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.57.1",
"@typescript-eslint/types": "8.57.1",
@@ -3541,6 +3547,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4142,6 +4149,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -5468,6 +5476,7 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5653,6 +5662,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -6856,6 +6866,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz",
"integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -9020,6 +9031,7 @@
"version": "6.33.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.33.0.tgz",
"integrity": "sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
@@ -9476,6 +9488,7 @@
"integrity": "sha512-n30qZpWehaYQzigLjmuPisyEsvOzHt7bZeRyg8gZ5DvJo9FGjD+gNaY59Ns3hlLD5/jZH5GBeftIss0jDbUoLg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@prisma/config": "7.5.0",
"@prisma/dev": "0.20.0",
@@ -9741,6 +9754,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -9750,6 +9764,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -11102,6 +11117,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -11393,6 +11409,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -12031,6 +12048,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -27,7 +27,6 @@
"lucide-react": "^0.577.0",
"next": "16.1.7",
"next-themes": "^0.4.6",
"openai": "^6.33.0",
"papaparse": "^5.5.3",
"prisma": "^7.5.0",
"react": "19.2.3",
@@ -46,6 +45,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.7",
"openai": "^6.33.0",
"tailwindcss": "^4",
"typescript": "^5"
}

View File

@@ -0,0 +1,67 @@
-- CreateTable
CREATE TABLE "SearchHistory" (
"id" TEXT NOT NULL PRIMARY KEY,
"query" TEXT NOT NULL,
"region" TEXT NOT NULL,
"searchMode" TEXT NOT NULL,
"executedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Lead" (
"id" TEXT NOT NULL PRIMARY KEY,
"domain" TEXT,
"companyName" TEXT,
"contactName" TEXT,
"contactTitle" TEXT,
"email" TEXT,
"linkedinUrl" TEXT,
"phone" TEXT,
"address" TEXT,
"sourceTab" TEXT NOT NULL,
"sourceTerm" TEXT,
"sourceJobId" TEXT,
"serpTitle" TEXT,
"serpSnippet" TEXT,
"serpRank" INTEGER,
"serpUrl" TEXT,
"emailConfidence" REAL,
"status" TEXT NOT NULL DEFAULT 'new',
"priority" TEXT NOT NULL DEFAULT 'normal',
"notes" TEXT,
"tags" TEXT,
"country" TEXT,
"headcount" TEXT,
"industry" TEXT,
"description" TEXT,
"companyType" TEXT,
"topics" TEXT,
"salesScore" INTEGER,
"salesReason" TEXT,
"offerPackage" TEXT,
"approved" BOOLEAN NOT NULL DEFAULT false,
"approvedAt" DATETIME,
"capturedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"contactedAt" DATETIME,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Lead" ("address", "capturedAt", "companyName", "contactName", "contactTitle", "contactedAt", "country", "description", "domain", "email", "emailConfidence", "headcount", "id", "industry", "linkedinUrl", "notes", "phone", "priority", "serpRank", "serpSnippet", "serpTitle", "serpUrl", "sourceJobId", "sourceTab", "sourceTerm", "status", "tags", "updatedAt") SELECT "address", "capturedAt", "companyName", "contactName", "contactTitle", "contactedAt", "country", "description", "domain", "email", "emailConfidence", "headcount", "id", "industry", "linkedinUrl", "notes", "phone", "priority", "serpRank", "serpSnippet", "serpTitle", "serpUrl", "sourceJobId", "sourceTab", "sourceTerm", "status", "tags", "updatedAt" FROM "Lead";
DROP TABLE "Lead";
ALTER TABLE "new_Lead" RENAME TO "Lead";
CREATE INDEX "Lead_domain_idx" ON "Lead"("domain");
CREATE INDEX "Lead_status_idx" ON "Lead"("status");
CREATE INDEX "Lead_sourceTab_idx" ON "Lead"("sourceTab");
CREATE INDEX "Lead_capturedAt_idx" ON "Lead"("capturedAt");
CREATE INDEX "Lead_email_idx" ON "Lead"("email");
CREATE INDEX "Lead_approved_idx" ON "Lead"("approved");
CREATE INDEX "Lead_companyType_idx" ON "Lead"("companyType");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE INDEX "SearchHistory_searchMode_idx" ON "SearchHistory"("searchMode");
-- CreateIndex
CREATE INDEX "SearchHistory_executedAt_idx" ON "SearchHistory"("executedAt");

View File

@@ -75,6 +75,14 @@ model Lead {
industry String?
description String?
companyType String?
topics String?
salesScore Int?
salesReason String?
offerPackage String?
approved Boolean @default(false)
approvedAt DateTime?
capturedAt DateTime @default(now())
contactedAt DateTime?
updatedAt DateTime @updatedAt
@@ -86,6 +94,19 @@ model Lead {
@@index([sourceTab])
@@index([capturedAt])
@@index([email])
@@index([approved])
@@index([companyType])
}
model SearchHistory {
id String @id @default(cuid())
query String
region String
searchMode String
executedAt DateTime @default(now())
@@index([searchMode])
@@index([executedAt])
}
model LeadEvent {