diff --git a/src/hooks/useLeads.js b/src/hooks/useLeads.js index ce819f8..ad58ca3 100644 --- a/src/hooks/useLeads.js +++ b/src/hooks/useLeads.js @@ -1,28 +1,36 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useMemo } from 'react' import { COLLECTIONS, DATABASE_ID, Query, databases } from '../lib/appwrite' const PAGE = 100 -/** Recherche-Leads aus der taeglichen 06:00-Routine (Appwrite `leads`). */ +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 +} + +/** 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 = [] - let offset = 0 - for (;;) { - const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.LEADS, [ - Query.orderDesc('leadScore'), - Query.limit(PAGE), - Query.offset(offset), - ]) - out.push(...(res.documents || [])) - if ((res.documents || []).length < PAGE) break - offset += PAGE - } + const out = await listAll(COLLECTIONS.LEADS, [Query.orderDesc('leadScore')]) setLeads(out) setError(null) } catch (err) { @@ -36,12 +44,31 @@ export function useLeads() { } 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, { @@ -65,7 +92,53 @@ export function useLeads() { } } - return { leads, loading, error, refresh: fetchLeads, updateLead, deleteLead } + 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 = { diff --git a/src/lib/appwrite.js b/src/lib/appwrite.js index df3534a..3d2a85a 100644 --- a/src/lib/appwrite.js +++ b/src/lib/appwrite.js @@ -42,6 +42,7 @@ export const COLLECTIONS = { WEBSITE_PROJECTS: 'websiteProjects', LEADS: 'leads', LEAD_SETTINGS: 'leadSettings', + LEAD_PIPELINE: 'leadPipeline', } export const WEBPAGE_TICKET_TYPE = 'Webpage' diff --git a/src/pages/LeadsPage.jsx b/src/pages/LeadsPage.jsx index 92f6f9b..ae54793 100644 --- a/src/pages/LeadsPage.jsx +++ b/src/pages/LeadsPage.jsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { FaSpinner, FaStar, @@ -13,11 +13,28 @@ import { FaChevronUp, FaGitAlt, FaFloppyDisk, + FaArrowsRotate, + FaCheck, + FaCopy, + FaTriangleExclamation, } from 'react-icons/fa6' -import { useLeads, useLeadSettings } from '../hooks/useLeads' +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 } } +` + const STATUS_OPTIONS = [ { id: 'neu', label: 'Neu', tone: 'info' }, { id: 'kontaktiert', label: 'Kontaktiert', tone: 'warn' }, @@ -34,8 +51,72 @@ function scoreColor(score) { 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 ( +