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 <noreply@anthropic.com>
This commit is contained in:
@@ -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'
|
import { COLLECTIONS, DATABASE_ID, Query, databases } from '../lib/appwrite'
|
||||||
|
|
||||||
const PAGE = 100
|
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() {
|
export function useLeads() {
|
||||||
const [leads, setLeads] = useState([])
|
const [leads, setLeads] = useState([])
|
||||||
|
const [pipelines, setPipelines] = useState({})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
const fetchLeads = useCallback(async () => {
|
const fetchLeads = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const out = []
|
const out = await listAll(COLLECTIONS.LEADS, [Query.orderDesc('leadScore')])
|
||||||
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
|
|
||||||
}
|
|
||||||
setLeads(out)
|
setLeads(out)
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -36,12 +44,31 @@ export function useLeads() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
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(() => {
|
useEffect(() => {
|
||||||
fetchLeads()
|
fetchLeads()
|
||||||
}, [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) => {
|
const updateLead = async (id, data) => {
|
||||||
try {
|
try {
|
||||||
const doc = await databases.updateDocument(DATABASE_ID, COLLECTIONS.LEADS, id, {
|
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 = {
|
export const DEFAULT_LEAD_SETTINGS = {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export const COLLECTIONS = {
|
|||||||
WEBSITE_PROJECTS: 'websiteProjects',
|
WEBSITE_PROJECTS: 'websiteProjects',
|
||||||
LEADS: 'leads',
|
LEADS: 'leads',
|
||||||
LEAD_SETTINGS: 'leadSettings',
|
LEAD_SETTINGS: 'leadSettings',
|
||||||
|
LEAD_PIPELINE: 'leadPipeline',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WEBPAGE_TICKET_TYPE = 'Webpage'
|
export const WEBPAGE_TICKET_TYPE = 'Webpage'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
FaSpinner,
|
FaSpinner,
|
||||||
FaStar,
|
FaStar,
|
||||||
@@ -13,11 +13,28 @@ import {
|
|||||||
FaChevronUp,
|
FaChevronUp,
|
||||||
FaGitAlt,
|
FaGitAlt,
|
||||||
FaFloppyDisk,
|
FaFloppyDisk,
|
||||||
|
FaArrowsRotate,
|
||||||
|
FaCheck,
|
||||||
|
FaCopy,
|
||||||
|
FaTriangleExclamation,
|
||||||
} from 'react-icons/fa6'
|
} 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 { PageHeader, Card, Button, Badge, EmptyState, Field, Kpi } from '../components/ui'
|
||||||
import CopyableCredential from '../components/CopyableCredential'
|
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 = [
|
const STATUS_OPTIONS = [
|
||||||
{ id: 'neu', label: 'Neu', tone: 'info' },
|
{ id: 'neu', label: 'Neu', tone: 'info' },
|
||||||
{ id: 'kontaktiert', label: 'Kontaktiert', tone: 'warn' },
|
{ id: 'kontaktiert', label: 'Kontaktiert', tone: 'warn' },
|
||||||
@@ -34,8 +51,72 @@ function scoreColor(score) {
|
|||||||
return '#f87171'
|
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 (
|
||||||
|
<div className="ui-card pad" style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
<div className="grow" style={{ minWidth: 0 }}>
|
||||||
|
<div className="flex" style={{ justifyContent: 'space-between', alignItems: 'baseline', gap: 10 }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 13 }}>Lead-Automatik (alle 5 Minuten)</div>
|
||||||
|
<div className="muted" style={{ fontSize: 12, whiteSpace: 'nowrap' }}>{label}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 8, borderRadius: 4, background: 'var(--surface-2)', overflow: 'hidden', marginTop: 6 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${pct}%`,
|
||||||
|
height: '100%',
|
||||||
|
background: active ? '#3b82f6' : 'var(--accent, #3b82f6)',
|
||||||
|
transition: 'width 1s linear',
|
||||||
|
animation: active ? 'leadPulse 1.2s ease-in-out infinite' : 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={triggerNow}
|
||||||
|
disabled={active}
|
||||||
|
title="Jetzt sofort auf neue Leads prüfen und verarbeiten"
|
||||||
|
aria-label="Pipeline jetzt starten"
|
||||||
|
>
|
||||||
|
<FaArrowsRotate className={active ? 'spinner' : ''} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function LeadsPage() {
|
export default function LeadsPage() {
|
||||||
const { leads, loading, error, updateLead } = useLeads()
|
const { leads, pipelines, loading, error, updateLead } = useLeads()
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [status, setStatus] = useState('all')
|
const [status, setStatus] = useState('all')
|
||||||
const [showSettings, setShowSettings] = useState(false)
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
@@ -63,9 +144,10 @@ export default function LeadsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
|
<style>{pulseStyle}</style>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Leads"
|
title="Leads"
|
||||||
subtitle="Recherche-Ergebnisse der täglichen 06:00-Routine — gespeichert in der Datenbank statt Excel"
|
subtitle="Recherche-Ergebnisse der täglichen 06:00-Routine — neue Leads werden automatisch verarbeitet"
|
||||||
actions={
|
actions={
|
||||||
<Button variant={showSettings ? 'primary' : 'default'} onClick={() => setShowSettings((s) => !s)}>
|
<Button variant={showSettings ? 'primary' : 'default'} onClick={() => setShowSettings((s) => !s)}>
|
||||||
<FaGear /> Sucheinstellungen
|
<FaGear /> Sucheinstellungen
|
||||||
@@ -73,6 +155,8 @@ export default function LeadsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CycleBar />
|
||||||
|
|
||||||
{showSettings && (
|
{showSettings && (
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<LeadSettingsCard onClose={() => setShowSettings(false)} />
|
<LeadSettingsCard onClose={() => setShowSettings(false)} />
|
||||||
@@ -124,7 +208,7 @@ export default function LeadsPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{filtered.map((lead) => (
|
{filtered.map((lead) => (
|
||||||
<LeadCard key={lead.$id} lead={lead} onUpdate={updateLead} />
|
<LeadCard key={lead.$id} lead={lead} pipeline={pipelines[lead.leadId]} onUpdate={updateLead} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', gap: 3 }}>
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
title={`${n}. ${name}`}
|
||||||
|
style={{ flex: 1, height: 6, borderRadius: 3, background, animation }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={status === 'failed' ? 'text-red' : 'faint'}
|
||||||
|
title={status === 'failed' ? lead.pipelineError || '' : label}
|
||||||
|
style={{ fontSize: 11, marginTop: 3, display: 'flex', alignItems: 'center', gap: 4 }}
|
||||||
|
>
|
||||||
|
{status === 'failed' && <FaTriangleExclamation />}
|
||||||
|
{label} · {Math.min(step, PIPELINE_STEPS.length)}/{PIPELINE_STEPS.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<div>
|
||||||
|
<strong>Pipeline-Verlauf:</strong>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, marginTop: 4 }}>
|
||||||
|
{steps.map((s) => (
|
||||||
|
<div key={s.n} className="flex gap-2" style={{ alignItems: 'baseline', fontSize: 12 }}>
|
||||||
|
<span style={{ color: s.ok ? '#34d399' : '#f87171', flexShrink: 0 }}>
|
||||||
|
{s.ok ? <FaCheck /> : <FaXmark />}
|
||||||
|
</span>
|
||||||
|
<span style={{ flexShrink: 0 }}>{s.n}. {s.name}</span>
|
||||||
|
<span className="faint" style={{ flexShrink: 0 }}>
|
||||||
|
{s.at ? new Date(s.at).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}
|
||||||
|
</span>
|
||||||
|
{s.hint && <span className="muted">— {s.hint}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<div>
|
||||||
|
<div className="flex gap-2" style={{ alignItems: 'center' }}>
|
||||||
|
<strong>E-Mail-Entwurf:</strong>
|
||||||
|
<Button size="sm" variant="ghost" onClick={copy} title="E-Mail in die Zwischenablage kopieren">
|
||||||
|
{copied ? <FaCheck /> : <FaCopy />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{pipeline.emailBetreff && (
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 12, marginTop: 4 }}>{pipeline.emailBetreff}</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="muted"
|
||||||
|
style={{ whiteSpace: 'pre-wrap', fontSize: 12, marginTop: 4, maxHeight: 220, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 6, padding: 8 }}
|
||||||
|
>
|
||||||
|
{pipeline.emailText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LeadCard({ lead, pipeline, onUpdate }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [notiz, setNotiz] = useState(lead.notiz || '')
|
const [notiz, setNotiz] = useState(lead.notiz || '')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
@@ -176,6 +375,8 @@ function LeadCard({ lead, onUpdate }) {
|
|||||||
<div style={{ width: `${Math.min(100, score)}%`, height: '100%', background: scoreColor(score) }} />
|
<div style={{ width: `${Math.min(100, score)}%`, height: '100%', background: scoreColor(score) }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PipelineBar lead={lead} />
|
||||||
|
|
||||||
<div className="flex gap-2 wrap" style={{ alignItems: 'center' }}>
|
<div className="flex gap-2 wrap" style={{ alignItems: 'center' }}>
|
||||||
<Badge tone={meta.tone} dot>{meta.label}</Badge>
|
<Badge tone={meta.tone} dot>{meta.label}</Badge>
|
||||||
{lead.googleNote && (
|
{lead.googleNote && (
|
||||||
@@ -252,6 +453,13 @@ function LeadCard({ lead, onUpdate }) {
|
|||||||
{lead.portalPasswort && <CopyableCredential label="Portal-Passwort" value={lead.portalPasswort} secret />}
|
{lead.portalPasswort && <CopyableCredential label="Portal-Passwort" value={lead.portalPasswort} secret />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<PipelineLog pipeline={pipeline} />
|
||||||
|
{lead.pipelineStatus === 'failed' && lead.pipelineError && (
|
||||||
|
<div className="text-red" style={{ fontSize: 12 }}>
|
||||||
|
<FaTriangleExclamation /> {lead.pipelineError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<EmailDraft pipeline={pipeline} />
|
||||||
<Field label="Notiz">
|
<Field label="Notiz">
|
||||||
<textarea
|
<textarea
|
||||||
className="form-control"
|
className="form-control"
|
||||||
|
|||||||
Reference in New Issue
Block a user