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 ( -
- Diese Einstellungen liest die tägliche Recherche-Routine vor jedem Lauf. - Bereits vorhandene Leads und Kunden werden automatisch übersprungen. -
-