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 } } ` const STATUS_OPTIONS = [ { id: 'neu', label: 'Neu', tone: 'info' }, { id: 'kontaktiert', label: 'Kontaktiert', tone: 'warn' }, { id: 'interessiert', label: 'Interessiert', tone: 'warn' }, { id: 'gewonnen', label: 'Gewonnen', tone: 'ok' }, { id: 'abgelehnt', label: 'Abgelehnt', tone: 'danger' }, ] const statusMeta = (id) => STATUS_OPTIONS.find((s) => s.id === id) || STATUS_OPTIONS[0] 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 [status, setStatus] = useState('all') const [showSettings, setShowSettings] = useState(false) const filtered = useMemo(() => { return (leads || []) .filter((l) => (status === 'all' ? true : (l.crmStatus || 'neu') === status)) .filter((l) => { if (!search) return true const s = search.toLowerCase() return `${l.firma} ${l.ort || ''} ${l.email || ''} ${l.branche || ''}`.toLowerCase().includes(s) }) }, [leads, search, status]) const kpis = useMemo(() => { const all = leads || [] const neu = all.filter((l) => (l.crmStatus || 'neu') === 'neu').length const gewonnen = all.filter((l) => l.crmStatus === 'gewonnen').length 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, neu, gewonnen, avg, orte: orte.size } }, [leads]) return (
setShowSettings((s) => !s)}> Sucheinstellungen } /> {showSettings && (
setShowSettings(false)} />
)}
{STATUS_OPTIONS.map((s) => ( ))}
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 meta = statusMeta(lead.crmStatus || 'neu') 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
{meta.label} {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 }} />