From 18e8f3bda5bd3613dd37cd4d4a496b5c1b375389 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 06:10:32 +0000 Subject: [PATCH] LeadsPage und useLeads entfernt (seit Lead-Pause unbenutzt) Ausgangswerte: 312 Tests, JS 680818 B, CSS 64433 B Co-Authored-By: Claude Opus 5.5 --- src/hooks/useLeads.js | 282 ------------------ src/pages/LeadsPage.jsx | 614 ---------------------------------------- 2 files changed, 896 deletions(-) delete mode 100644 src/hooks/useLeads.js delete mode 100644 src/pages/LeadsPage.jsx diff --git a/src/hooks/useLeads.js b/src/hooks/useLeads.js deleted file mode 100644 index 23e1f1b..0000000 --- a/src/hooks/useLeads.js +++ /dev/null @@ -1,282 +0,0 @@ -import { useState, useEffect, useCallback, useMemo } from 'react' -import { COLLECTIONS, DATABASE_ID, Query, databases } from '../lib/appwrite' - -const PAGE = 100 - -async function listAll(collection, queries = []) { - const out = [] - let offset = 0 - for (;;) { - const res = await databases.listDocuments(DATABASE_ID, collection, [ - ...queries, - Query.limit(PAGE), - Query.offset(offset), - ]) - out.push(...(res.documents || [])) - if ((res.documents || []).length < PAGE) break - offset += PAGE - } - return out -} - -/** Haengt an jeden Lead Verweise, die nicht am Lead selbst gespeichert sind, - * sondern ueber den Kunden verknuepft werden — via Lead -> Kunde (per E-Mail): - * - woid: WOID des Akquise-Tickets (workorders type=Akquise) - * - previewUrl: URL der erstellten, deployten Website (websiteProjects) - * Best effort; Fehler brechen die Lead-Liste nicht. */ -async function attachLeadLinks(leads) { - const norm = (l) => (l.email || l.portalLogin || '').trim().toLowerCase() - const emails = [...new Set(leads.map(norm).filter(Boolean))] - if (!emails.length) return leads - - // 1) Kunden per E-Mail -> customerId - const emailToCustomer = {} - const customerIds = [] - for (let i = 0; i < emails.length; i += 90) { - const docs = await listAll(COLLECTIONS.CUSTOMERS, [Query.equal('email', emails.slice(i, i + 90))]) - for (const c of docs) { - const e = (c.email || '').trim().toLowerCase() - if (e && !emailToCustomer[e]) { - emailToCustomer[e] = c.$id - customerIds.push(c.$id) - } - } - } - if (!customerIds.length) return leads - - // 2) Akquise-Tickets per customerId -> kleinste (urspruengliche) WOID je Kunde - const customerToWoid = {} - for (let i = 0; i < customerIds.length; i += 90) { - const docs = await listAll(COLLECTIONS.WORKORDERS, [ - Query.equal('customerId', customerIds.slice(i, i + 90)), - Query.equal('type', ['Akquise']), - ]) - for (const w of docs) { - const n = parseInt(w.woid, 10) - if (!w.customerId || isNaN(n)) continue - const cur = customerToWoid[w.customerId] - if (!cur || n < cur.n) customerToWoid[w.customerId] = { n, woid: w.woid, ticketId: w.$id } - } - } - - // 3) deployte Preview je Kunde -> URL der erstellten Website. - // Die websiteProjects-Collection ist klein -> komplett laden + lokal zuordnen - // (die Preview haengt am selben Kunden, aber an einem anderen Repo als lead.repoUrl). - const customerToPreview = {} - const idSet = new Set(customerIds) - for (const p of await listAll(COLLECTIONS.WEBSITE_PROJECTS)) { - if (!idSet.has(p.customerId) || customerToPreview[p.customerId]) continue - const ready = p.status === 'deployed' || p.status === 'ready' || p.provisioningStatus === 'ready' - const url = p.previewUrl || (p.subdomain ? `https://${p.subdomain}.project.webklar.com` : '') - if (ready && url) customerToPreview[p.customerId] = url - } - - // 4) an die Leads haengen - return leads.map((l) => { - const cid = emailToCustomer[norm(l)] - const t = customerToWoid[cid] - const previewUrl = customerToPreview[cid] - if (!t && !previewUrl) return l - return { - ...l, - ...(t ? { woid: t.woid, ticketId: t.ticketId } : {}), - ...(previewUrl ? { previewUrl } : {}), - } - }) -} - -/** Recherche-Leads aus der taeglichen 06:00-Routine (Appwrite `leads`). - * Pollt automatisch nach — schneller, solange die 5-Minuten-Pipeline - * (processor.py auf dem Server) gerade Leads verarbeitet. */ -export function useLeads() { - const [leads, setLeads] = useState([]) - const [pipelines, setPipelines] = useState({}) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - const fetchLeads = useCallback(async () => { - try { - const out = await listAll(COLLECTIONS.LEADS, [Query.orderDesc('leadScore')]) - let enriched = out - try { enriched = await attachLeadLinks(out) } catch { /* Live-Join best effort */ } - setLeads(enriched) - setError(null) - } catch (err) { - if (err.code === 404 || err.message?.includes('not found')) { - setLeads([]) - setError(null) - } else { - setError(err.message) - setLeads([]) - } - } finally { - setLoading(false) - } - // Schritt-Log + E-Mail-Entwurf pro Lead (Zusatz-Collection, best effort) - try { - const docs = await listAll(COLLECTIONS.LEAD_PIPELINE) - const map = {} - for (const d of docs) if (d.leadId) map[d.leadId] = d - setPipelines(map) - } catch { - setPipelines({}) - } - }, []) - - useEffect(() => { - fetchLeads() - }, [fetchLeads]) - - // Live-Polling: 5s waehrend die Pipeline arbeitet, sonst alle 20s - const pipelineActive = useMemo( - () => leads.some((l) => l.pipelineStatus === 'running' || l.pipelineStatus === 'pending'), - [leads] - ) - useEffect(() => { - const t = setInterval(fetchLeads, pipelineActive ? 5000 : 20000) - return () => clearInterval(t) - }, [fetchLeads, pipelineActive]) - - const updateLead = async (id, data) => { - try { - const doc = await databases.updateDocument(DATABASE_ID, COLLECTIONS.LEADS, id, { - ...data, - updatedAt: new Date().toISOString(), - }) - setLeads((prev) => prev.map((l) => (l.$id === id ? doc : l))) - return { success: true, data: doc } - } catch (err) { - return { success: false, error: err.message } - } - } - - const deleteLead = async (id) => { - try { - await databases.deleteDocument(DATABASE_ID, COLLECTIONS.LEADS, id) - setLeads((prev) => prev.filter((l) => l.$id !== id)) - return { success: true } - } catch (err) { - return { success: false, error: err.message } - } - } - - return { leads, pipelines, loading, error, refresh: fetchLeads, updateLead, deleteLead } -} - -/** 5-Minuten-Zyklus des Server-Processors (leadSettings: lastRunAt/nextRunAt/ - * workerBusy) + manueller Sofort-Trigger (runNow=true, der Processor pollt - * das Flag alle 5 Sekunden). */ -export function useLeadCycle() { - const [cycle, setCycle] = useState(null) - const [triggering, setTriggering] = useState(false) - - const fetchCycle = useCallback(async () => { - try { - const doc = await databases.getDocument(DATABASE_ID, COLLECTIONS.LEAD_SETTINGS, 'main') - setCycle({ - lastRunAt: doc.lastRunAt || null, - nextRunAt: doc.nextRunAt || null, - runNow: Boolean(doc.runNow), - workerBusy: Boolean(doc.workerBusy), - }) - } catch { - setCycle(null) - } - }, []) - - useEffect(() => { - fetchCycle() - const t = setInterval(fetchCycle, 10000) - return () => clearInterval(t) - }, [fetchCycle]) - - const triggerNow = async () => { - setTriggering(true) - try { - await databases.updateDocument(DATABASE_ID, COLLECTIONS.LEAD_SETTINGS, 'main', { - runNow: true, - updatedAt: new Date().toISOString(), - }) - await fetchCycle() - return { success: true } - } catch (err) { - return { success: false, error: err.message } - } finally { - setTriggering(false) - } - } - - return { cycle, triggering, triggerNow, refresh: fetchCycle } -} - -export const DEFAULT_LEAD_SETTINGS = { - branche: 'Autolackiererei', - staedte: [], - ganzDeutschland: false, - maxLeadsProStadt: 5, - minBewertung: 4.0, - emailPflicht: true, - nurSchlechteWebsite: true, - zusatzHinweise: '', -} - -/** Sucheinstellungen der Routine (Appwrite `leadSettings`, Dokument `main`). - * Die Cloud-Routine liest sie vor jedem Lauf ueber den Server-Hook (/config). */ -export function useLeadSettings() { - const [settings, setSettings] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - const fetchSettings = useCallback(async () => { - try { - const doc = await databases.getDocument(DATABASE_ID, COLLECTIONS.LEAD_SETTINGS, 'main') - setSettings(doc) - setError(null) - } catch (err) { - if (err.code === 404 || err.message?.includes('not found')) { - setSettings({ ...DEFAULT_LEAD_SETTINGS }) - setError(null) - } else { - setError(err.message) - } - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { - fetchSettings() - }, [fetchSettings]) - - const saveSettings = async (data) => { - const payload = { - branche: data.branche || '', - staedte: data.staedte || [], - ganzDeutschland: Boolean(data.ganzDeutschland), - maxLeadsProStadt: Number(data.maxLeadsProStadt) || 5, - minBewertung: Number(data.minBewertung) || 0, - emailPflicht: Boolean(data.emailPflicht), - nurSchlechteWebsite: Boolean(data.nurSchlechteWebsite), - zusatzHinweise: data.zusatzHinweise || '', - updatedAt: new Date().toISOString(), - } - try { - let doc - try { - doc = await databases.updateDocument(DATABASE_ID, COLLECTIONS.LEAD_SETTINGS, 'main', payload) - } catch (err) { - if (err.code === 404) { - doc = await databases.createDocument(DATABASE_ID, COLLECTIONS.LEAD_SETTINGS, 'main', payload) - } else { - throw err - } - } - setSettings(doc) - return { success: true, data: doc } - } catch (err) { - return { success: false, error: err.message } - } - } - - return { settings, loading, error, refresh: fetchSettings, saveSettings } -} diff --git a/src/pages/LeadsPage.jsx b/src/pages/LeadsPage.jsx deleted file mode 100644 index 83d6f01..0000000 --- a/src/pages/LeadsPage.jsx +++ /dev/null @@ -1,614 +0,0 @@ -import { useEffect, useMemo, useState } from 'react' -import { Link } from 'react-router-dom' -import { - FaSpinner, - FaStar, - FaGear, - FaPlus, - FaXmark, - FaMapLocationDot, - FaGlobe, - FaPhone, - FaEnvelope, - FaChevronDown, - FaChevronUp, - FaGitAlt, - FaFloppyDisk, - FaArrowsRotate, - FaCheck, - FaCopy, - FaEye, - FaHashtag, - FaTriangleExclamation, -} from 'react-icons/fa6' -import { useLeads, useLeadSettings, useLeadCycle } from '../hooks/useLeads' -import { PageHeader, Card, Button, Badge, EmptyState, Field, Kpi } from '../components/ui' -import CopyableCredential from '../components/CopyableCredential' - -// Schritte der Server-Pipeline (processor.py) — Reihenfolge = pipelineStep 1..5 -const PIPELINE_STEPS = [ - 'Lead erstellt', - 'Kunde & Repo', - 'Daten eingesetzt', - 'Personalisiert', - 'E-Mail erstellt', -] - -const pulseStyle = ` -@keyframes leadPulse { 0% { opacity: .35 } 50% { opacity: 1 } 100% { opacity: .35 } } -` - -function scoreColor(score) { - if (score >= 85) return '#34d399' - if (score >= 70) return '#fbbf24' - return '#f87171' -} - -/** Balken ganz oben: laeuft von Minute 0 bis 5 (lastRunAt -> nextRunAt) und - * zeigt, wann der Server das naechste Mal nach neuen Leads schaut. Rechts - * ein Refresh-Icon, das den Lauf sofort ausloest (leadSettings.runNow). */ -function CycleBar() { - const { cycle, triggering, triggerNow } = useLeadCycle() - const [now, setNow] = useState(Date.now()) - - useEffect(() => { - const t = setInterval(() => setNow(Date.now()), 1000) - return () => clearInterval(t) - }, []) - - const last = cycle?.lastRunAt ? new Date(cycle.lastRunAt).getTime() : null - const next = cycle?.nextRunAt ? new Date(cycle.nextRunAt).getTime() : null - const active = Boolean(cycle?.workerBusy || cycle?.runNow || triggering) - - let pct = 0 - let label = 'Automatik-Status unbekannt' - if (active) { - pct = 100 - label = 'Pipeline läuft — Leads werden verarbeitet…' - } else if (last && next && next > last) { - pct = Math.min(100, Math.max(0, ((now - last) / (next - last)) * 100)) - const rest = Math.max(0, next - now) - const m = Math.floor(rest / 60000) - const s = Math.floor((rest % 60000) / 1000) - label = rest > 0 - ? `Nächste Prüfung auf neue Leads in ${m}:${String(s).padStart(2, '0')} Min.` - : 'Prüfung startet gleich…' - } - - return ( -
-
-
-
Lead-Automatik (alle 5 Minuten)
-
{label}
-
-
-
-
-
- -
- ) -} - -export default function LeadsPage() { - const { leads, pipelines, loading, error } = useLeads() - const [search, setSearch] = useState('') - const [showSettings, setShowSettings] = useState(false) - - const filtered = useMemo(() => { - return (leads || []) - .filter((l) => { - if (!search) return true - const s = search.toLowerCase() - return `${l.firma} ${l.ort || ''} ${l.email || ''} ${l.branche || ''}`.toLowerCase().includes(s) - }) - }, [leads, search]) - - const kpis = useMemo(() => { - const all = leads || [] - const avg = all.length - ? Math.round(all.reduce((sum, l) => sum + (l.leadScore || 0), 0) / all.length) - : 0 - const orte = new Set(all.map((l) => (l.ort || '').trim().toLowerCase()).filter(Boolean)) - return { total: all.length, avg, orte: orte.size } - }, [leads]) - - return ( -
- - setShowSettings((s) => !s)}> - Sucheinstellungen - - } - /> - - - - {showSettings && ( -
- setShowSettings(false)} /> -
- )} - -
- - - -
- - -
- setSearch(e.target.value)} - style={{ maxWidth: 320 }} - /> -
-
- {loading ? ( -
- ) : error ? ( - - ) : filtered.length === 0 ? ( - - ) : ( -
- {filtered.map((lead) => ( - - ))} -
- )} -
-
-
- ) -} - -/** Verfolgbarer Pipeline-Balken (5 Segmente) — zeigt pro Lead, welcher - * Automatik-Schritt erledigt ist, gerade laeuft oder fehlgeschlagen ist. */ -function PipelineBar({ lead }) { - const step = Number.isInteger(lead.pipelineStep) ? lead.pipelineStep : null - if (step === null) return null - const status = lead.pipelineStatus || 'idle' - - let label - if (status === 'done') label = 'Automatisch verarbeitet ✓' - else if (status === 'running') label = `${PIPELINE_STEPS[step] || 'Verarbeitung'} läuft…` - else if (status === 'pending') label = 'Wartet auf den nächsten Lauf' - else if (status === 'failed') label = `Fehler: ${PIPELINE_STEPS[step] || '?'}` - else if (status === 'waiting_enrichment') label = 'Wartet auf Anreicherung (Bilder & Logo)' - else label = step > 0 ? `Stand: ${PIPELINE_STEPS[step - 1]} (manuell)` : 'Keine Automatik' - - return ( -
-
- {PIPELINE_STEPS.map((name, i) => { - const n = i + 1 - let background = 'var(--surface-2)' - let animation = 'none' - if (n <= step) background = '#34d399' - if (status === 'running' && n === step + 1) { - background = '#3b82f6' - animation = 'leadPulse 1.2s ease-in-out infinite' - } - if (status === 'failed' && n === step + 1) background = '#f87171' - if (status === 'waiting_enrichment' && n === step + 1) background = '#fbbf24' - if (status === 'idle' && n <= step) background = '#9ca3af' - return ( -
- ) - })} -
-
- {status === 'failed' && } - {label} · {Math.min(step, PIPELINE_STEPS.length)}/{PIPELINE_STEPS.length} -
-
- ) -} - -/** Schritt-Log aus leadPipeline.steps (JSON) — im aufgeklappten Bereich. */ -function PipelineLog({ pipeline }) { - const steps = useMemo(() => { - try { - const parsed = JSON.parse(pipeline?.steps || '[]') - return Array.isArray(parsed) ? parsed : [] - } catch { - return [] - } - }, [pipeline]) - if (steps.length === 0) return null - return ( -
- Pipeline-Verlauf: -
- {steps.map((s) => ( -
- - {s.ok ? : } - - {s.n}. {s.name} - - {s.at ? new Date(s.at).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''} - - {s.hint && — {s.hint}} -
- ))} -
-
- ) -} - -/** Der von der Pipeline geschriebene E-Mail-Entwurf, mit Kopieren-Button. */ -function EmailDraft({ pipeline }) { - const [copied, setCopied] = useState(false) - if (!pipeline?.emailText) return null - const copy = async () => { - try { - await navigator.clipboard.writeText( - `Betreff: ${pipeline.emailBetreff || ''}\n\n${pipeline.emailText}` - ) - setCopied(true) - setTimeout(() => setCopied(false), 1600) - } catch { /* Clipboard nicht verfuegbar */ } - } - return ( -
-
- E-Mail-Entwurf: - -
- {pipeline.emailBetreff && ( -
{pipeline.emailBetreff}
- )} -
- {pipeline.emailText} -
-
- ) -} - -/** Reine Info-Anzeige — Status/Notizen werden NICHT hier gepflegt, - * sondern im Akquise-Ticket des Leads (WOID-Chip). */ -function LeadCard({ lead, pipeline }) { - const [open, setOpen] = useState(false) - const score = lead.leadScore || 0 - - const websiteUrl = /^https?:\/\//.test(lead.website || '') ? lead.website : null - - return ( -
-
-
-
- {lead.firma} - {lead.kategorie && Kat. {lead.kategorie}} -
-
- {[lead.ort, lead.branche].filter(Boolean).join(' · ')} -
-
-
-
{score}
-
Score
-
-
- -
-
-
- - - -
- {lead.googleNote && ( - - {lead.googleNote} ({lead.anzahlBewertungen || 0}) - - )} - {lead.processed && Kunde angelegt} -
- - {lead.websiteStatus && ( -
- Website: {lead.websiteStatus.length > 120 && !open - ? lead.websiteStatus.slice(0, 120) + '…' - : lead.websiteStatus} -
- )} - -
- {lead.email && ( - - {lead.email} - - )} - {lead.telefon && ( - - {lead.telefon} - - )} -
- -
- {lead.googleMapsLink && ( - - )} - {websiteUrl && ( - - )} - {lead.repoUrl && ( - - )} - {lead.previewUrl && ( - - )} - {lead.woid && ( - - )} - -
- - {open && ( -
- {lead.ansprechpartner &&
Ansprechpartner: {lead.ansprechpartner}
} - {lead.adresse &&
Adresse: {lead.adresse}
} - {lead.kernleistungen &&
Leistungen: {lead.kernleistungen}
} - {lead.hauptmaengel &&
Hauptmängel: {lead.hauptmaengel}
} - {lead.sympathie &&
Eindruck: {lead.sympathie}
} - {(lead.portalLogin || lead.portalPasswort) && ( -
- {lead.portalLogin && } - {lead.portalPasswort && } -
- )} - - {lead.pipelineStatus === 'failed' && lead.pipelineError && ( -
- {lead.pipelineError} -
- )} - - {lead.notiz &&
Notiz (Alt-Bestand): {lead.notiz}
} -
- Aufgenommen: {lead.addedAt ? new Date(lead.addedAt).toLocaleDateString('de-DE') : '-'} - {lead.leadId ? ` · ${lead.leadId}` : ''} -
-
- )} -
- ) -} - -function LeadSettingsCard({ onClose }) { - const { settings, loading, saveSettings } = useLeadSettings() - const [form, setForm] = useState(null) - const [newStadt, setNewStadt] = useState('') - const [busy, setBusy] = useState(false) - const [msg, setMsg] = useState(null) - - // Formular einmalig aus den geladenen Settings befuellen - if (!form && !loading && settings) { - setForm({ - branche: settings.branche || '', - staedte: settings.staedte || [], - ganzDeutschland: Boolean(settings.ganzDeutschland), - maxLeadsProStadt: settings.maxLeadsProStadt ?? 5, - minBewertung: settings.minBewertung ?? 4.0, - emailPflicht: settings.emailPflicht !== false, - nurSchlechteWebsite: settings.nurSchlechteWebsite !== false, - zusatzHinweise: settings.zusatzHinweise || '', - }) - } - - const set = (key, value) => setForm((f) => ({ ...f, [key]: value })) - - const addStadt = () => { - const s = newStadt.trim() - if (!s) return - if (!form.staedte.some((x) => x.toLowerCase() === s.toLowerCase())) { - set('staedte', [...form.staedte, s]) - } - setNewStadt('') - } - - const save = async () => { - setBusy(true) - setMsg(null) - const r = await saveSettings(form) - setBusy(false) - setMsg(r.success ? 'Gespeichert — gilt ab dem nächsten 06:00-Lauf.' : `Fehler: ${r.error}`) - } - - return ( - } - > - {loading || !form ? ( -
- ) : ( - <> -

- Diese Einstellungen liest die tägliche Recherche-Routine vor jedem Lauf. - Bereits vorhandene Leads und Kunden werden automatisch übersprungen. -

-
- - set('branche', e.target.value)} - /> - - - set('maxLeadsProStadt', e.target.value)} - style={{ width: 120 }} - /> - - - set('minBewertung', e.target.value)} - style={{ width: 120 }} - /> - -
- - -
- {form.staedte.map((s) => ( - - {s} - - - ))} - setNewStadt(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addStadt() } }} - disabled={form.ganzDeutschland} - style={{ width: 200 }} - /> - -
-
- -
- - - -
- - -