From 0b24596f157fb54dadb01f128d25c5025f8831b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 20:57:18 +0000 Subject: [PATCH] feat: Lead-Pipeline sichtbar - 5-Min-Zyklusbalken + Refresh-Trigger, Schritt-Fortschritt pro Lead, Pipeline-Log & KI-E-Mail-Entwurf Co-Authored-By: Claude Fable 5 --- src/hooks/useLeads.js | 103 ++++++++++++++++--- src/lib/appwrite.js | 1 + src/pages/LeadsPage.jsx | 220 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 303 insertions(+), 21 deletions(-) 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 ( +
+
+
+
Lead-Automatik (alle 5 Minuten)
+
{label}
+
+
+
+
+
+ +
+ ) +} + export default function LeadsPage() { - const { leads, loading, error, updateLead } = useLeads() + const { leads, pipelines, loading, error, updateLead } = useLeads() const [search, setSearch] = useState('') const [status, setStatus] = useState('all') const [showSettings, setShowSettings] = useState(false) @@ -63,9 +144,10 @@ export default function LeadsPage() { return (
+ setShowSettings((s) => !s)}> Sucheinstellungen @@ -73,6 +155,8 @@ export default function LeadsPage() { } /> + + {showSettings && (
setShowSettings(false)} /> @@ -124,7 +208,7 @@ export default function LeadsPage() { }} > {filtered.map((lead) => ( - + ))}
)} @@ -134,7 +218,122 @@ export default function LeadsPage() { ) } -function LeadCard({ lead, onUpdate }) { +/** 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 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 === '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} +
+
+ ) +} + +function LeadCard({ lead, pipeline, onUpdate }) { const [open, setOpen] = useState(false) const [notiz, setNotiz] = useState(lead.notiz || '') const [busy, setBusy] = useState(false) @@ -176,6 +375,8 @@ function LeadCard({ lead, onUpdate }) {
+ +
{meta.label} {lead.googleNote && ( @@ -252,6 +453,13 @@ function LeadCard({ lead, onUpdate }) { {lead.portalPasswort && }
)} + + {lead.pipelineStatus === 'failed' && lead.pipelineError && ( +
+ {lead.pipelineError} +
+ )} +