// ============================================================================ // public/screens/campaigns.jsx — Campaigns list + detail + create wizard // ---------------------------------------------------------------------------- // Three views toggle in one component: // • list → CampaignsList // • detail → CampaignDetail // • wizard → CampaignWizard (4 steps) // Status changes (start/pause/cancel) go through dedicated POST endpoints. // ============================================================================ function CampaignsScreen({ go, session }) { const [view, setView] = React.useState("list"); // list | detail | wizard const [selectedId, setSelectedId] = React.useState(null); const openDetail = (id) => { setSelectedId(id); setView("detail"); }; const back = () => setView("list"); if (view === "wizard") return { setSelectedId(id); setView("detail"); }} onCancel={back}/>; if (view === "detail" && selectedId) return ; return setView("wizard")} go={go}/>; } // ── List view ────────────────────────────────────────────────────────── function CampaignsList({ onOpenDetail, onNew, go }) { const [campaigns, setCampaigns] = React.useState(null); const [stats, setStats] = React.useState(null); const [filter, setFilter] = React.useState("all"); const [q, setQ] = React.useState(""); const [err, setErr] = React.useState(null); const reload = React.useCallback(() => { setErr(null); const params = new URLSearchParams(); if (filter !== "all") params.append("status", filter); if (q.trim()) params.append("q", q.trim()); Promise.all([ VoaisAPI.get("/api/campaigns?" + params.toString()), VoaisAPI.get("/api/campaigns/stats"), ]).then(([list, st]) => { if (list.ok && list.data?.ok) setCampaigns(list.data.campaigns); else setErr(list.data?.msg || "Failed to load campaigns."); if (st.ok && st.data?.ok) setStats(st.data.stats); }); }, [filter, q]); React.useEffect(() => { const id = setTimeout(reload, q ? 250 : 0); return () => clearTimeout(id); }, [reload, q]); const handleStatusChange = async (id, action) => { const r = await VoaisAPI.post(`/api/campaigns/${id}/${action}`); if (!r.ok) { alert(r.data?.msg || "Action failed."); return; } reload(); }; if (campaigns === null && !err) return ; const counts = stats || { active: 0, scheduled: 0, finished: 0, callsPending: 0, avgConnectRate: null, hotConversion: null }; return (
{/* Top KPIs */}
Active campaigns
{Number(counts.active).toLocaleString("en-IN")}
{counts.scheduled} scheduled
Calls pending
{Number(counts.callsPending).toLocaleString("en-IN")}
across active campaigns
Avg connect rate
{counts.avgConnectRate != null ? `${counts.avgConnectRate}%` : "—"}
across all campaigns
Hot lead conversion
{counts.hotConversion != null ? `${counts.hotConversion}%` : "—"}
of dialed calls
{/* Action bar */}
setQ(e.target.value)}/>
} onClick={onNew}>New campaign
{err &&
{err}
} {/* Table or empty state */} {campaigns?.length === 0 ? (

{q || filter !== "all" ? "No campaigns match" : "No campaigns yet"}

{q || filter !== "all" ? "Try a different filter or search term." : "A campaign points an AI agent at a contact list and runs calls on a schedule. Set one up to launch your first batch."}

{!q && filter === "all" && } onClick={onNew}>Create your first campaign}
) : ( {campaigns?.map((c) => { const pct = c.total > 0 ? Math.round((c.dialed / c.total) * 100) : 0; return ( onOpenDetail(c.id)}> ); })}
Campaign Agent Progress Connect rate Hot leads Status
{c.name}
{c.industry || "—"} · {c.code}
{c.agent_name ? (
{c.agent_name}
) : }
{Number(c.dialed).toLocaleString("en-IN")} / {Number(c.total).toLocaleString("en-IN")}
{c.connect_rate != null ? `${Math.round(c.connect_rate)}%` : "—"} {Number(c.hot_leads || 0)} {c.status} e.stopPropagation()}>
)}
); } function statusTone(s) { if (s === "running" || s === "active") return "green"; if (s === "paused") return "yellow"; if (s === "completed") return "blue"; if (s === "scheduled") return "blue"; if (s === "cancelled" || s === "failed") return "red"; return "gray"; } function CampaignRowActions({ campaign, onAction }) { const c = campaign; if (c.status === "draft" || c.status === "scheduled" || c.status === "paused") { return } onClick={() => onAction(c.id, "start")}>Start; } if (c.status === "running") { return } onClick={() => onAction(c.id, "pause")}>Pause; } return null; } // ── Detail view ───────────────────────────────────────────────────────── function CampaignDetail({ id, onBack, go }) { const [data, setData] = React.useState(null); const [err, setErr] = React.useState(null); const reload = React.useCallback(() => { VoaisAPI.get(`/api/campaigns/${id}`).then((r) => { if (r.ok && r.data?.ok) setData(r.data); else setErr(r.data?.msg || "Couldn't load campaign."); }); }, [id]); React.useEffect(reload, [reload]); const handleAction = async (action) => { const r = await VoaisAPI.post(`/api/campaigns/${id}/${action}`); if (!r.ok) { alert(r.data?.msg || "Action failed."); return; } reload(); }; const handleDelete = async () => { if (!confirm("Delete this campaign? This cannot be undone.")) return; const r = await VoaisAPI.del(`/api/campaigns/${id}`); if (!r.ok) { alert(r.data?.msg || "Delete failed."); return; } onBack(); }; if (!data && !err) return ; if (err) return ; const c = data.campaign; const pct = c.total > 0 ? Math.round((c.dialed / c.total) * 100) : 0; const schedule = parseScheduleJson(c.schedule_json); return (
{/* Header bar */}
} onClick={onBack}>Back
{c.code} · {c.industry || "—"}

{c.name}

{c.status} {(c.status === "draft" || c.status === "scheduled" || c.status === "paused") && } onClick={() => handleAction("start")}>Start} {c.status === "running" && } onClick={() => handleAction("pause")}>Pause} {c.status !== "cancelled" && c.status !== "completed" && handleAction("cancel")}>Cancel} }/>
{/* Progress strip */}
Progress
{Number(c.dialed).toLocaleString("en-IN")} / {Number(c.total).toLocaleString("en-IN")}
{/* Configuration + schedule */}
{c.agent_name} : "—"}/> {c.started_at && }
{schedule ? (
{c.start_date && } {c.end_date && }
) : (
)}
{/* Recent calls */}

Recent calls

{data.recentCalls.length}
{data.recentCalls.length > 0 && go("history")}>View all }
{data.recentCalls.length === 0 ? (
) : ( {data.recentCalls.map((rc) => ( ))}
Time Contact Phone Outcome Score Duration
{new Date(rc.started_at).toLocaleTimeString("en-IN", { hour: "2-digit", minute: "2-digit" })} {rc.contact_name || "—"} {rc.contact_phone || "—"} {rc.outcome ? {rc.outcome.replace(/_/g, " ")} : "—"} {rc.lead_score ?? "—"} {rc.duration_s ? formatDuration(rc.duration_s) : "—"}
)}
); } function Stat2({ label, value, color }) { return (
{label}
{value}
); } function ConfigRow({ k, v }) { return ( {k} {v} ); } function outcomeTone(o) { if (o === "talked" || o === "answered") return "green"; if (o === "voicemail") return "yellow"; if (o === "no_answer" || o === "busy") return "gray"; if (o === "failed" || o === "instant_hangup") return "red"; return "gray"; } function formatDuration(s) { s = Number(s) || 0; const m = Math.floor(s / 60), sec = s % 60; return `${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`; } function parseScheduleJson(v) { if (!v) return null; if (typeof v === "object") return v; try { return JSON.parse(v); } catch (_) { return null; } } // ── Wizard ────────────────────────────────────────────────────────────── function CampaignWizard({ onDone, onCancel }) { const STEPS = ["Basics", "Agent", "Contacts", "Schedule"]; const [step, setStep] = React.useState(0); const [form, setForm] = React.useState({ name: "", industry: "", agent_id: "", phonebook_id: "", concurrent_calls: 1, max_attempts: 2, retry_delay_min: 60, delay_ms: 2000, schedule_json: { days: ["mon","tue","wed","thu","fri"], start: "10:00", end: "19:00", tz: "Asia/Kolkata" }, }); const [agents, setAgents] = React.useState([]); const [phonebooks, setPhonebooks] = React.useState([]); const [submitting, setSubmitting] = React.useState(false); const [err, setErr] = React.useState(null); React.useEffect(() => { VoaisAPI.get("/api/agents").then((r) => { if (r.ok && r.data?.ok) { const active = r.data.agents.filter((a) => a.status === "active"); setAgents(active); } }); VoaisAPI.get("/api/contacts/phonebooks").then((r) => { if (r.ok && r.data?.ok) setPhonebooks(r.data.phonebooks); }); }, []); const next = () => { setErr(null); if (step === 0 && !form.name.trim()) return setErr("Give your campaign a name."); if (step === 1 && !form.agent_id) return setErr("Pick an agent (or activate one in the Agents page)."); if (step === 2 && !form.phonebook_id) return setErr("Pick a phonebook (or upload contacts first)."); if (step < STEPS.length - 1) setStep(step + 1); else submit(); }; const back = () => { setErr(null); if (step > 0) setStep(step - 1); }; const submit = async () => { setSubmitting(true); setErr(null); const r = await VoaisAPI.post("/api/campaigns", { name: form.name.trim(), industry: form.industry || null, agent_id: Number(form.agent_id), phonebook_id: Number(form.phonebook_id), concurrent_calls: Number(form.concurrent_calls), max_attempts: Number(form.max_attempts), retry_delay_min: Number(form.retry_delay_min), delay_ms: Number(form.delay_ms), schedule_json: form.schedule_json, }); setSubmitting(false); if (r.ok) onDone(r.data.campaignId); else setErr(r.data?.msg || "Failed to create campaign."); }; return (
} onClick={onCancel}>Cancel
Step {step + 1} of {STEPS.length}
{/* Step indicator */}
{STEPS.map((s, i) => (
{s}
))}
{err &&
{err}
} {/* Step 0 — Basics */} {step === 0 && (

Campaign basics

Just a name and (optionally) an industry tag so it's easy to find later.

setForm({ ...form, name: e.target.value })}/>
)} {/* Step 1 — Agent */} {step === 1 && (

Pick an AI agent

Choose the persona that will handle calls. Only active agents are shown.

{agents.length === 0 ? ( ) : (
{agents.map((a) => (
setForm({ ...form, agent_id: a.id })} style={{ padding: 14, borderRadius: 12, cursor: "pointer", border: `2px solid ${form.agent_id === a.id ? "var(--accent)" : "var(--line)"}`, background: form.agent_id === a.id ? "var(--accent-soft)" : "var(--surface)", transition: "all 0.12s", }} >
{(a.name || "?").charAt(0).toUpperCase()}
{a.name}
{a.language} · {a.llm_provider}
{a.role &&
{a.role}
}
))}
)}
)} {/* Step 2 — Contacts */} {step === 2 && (

Pick contacts

Choose the phonebook this campaign will dial through.

{phonebooks.length === 0 ? ( ) : (
{phonebooks.map((pb) => (
setForm({ ...form, phonebook_id: pb.id })} style={{ padding: "12px 16px", borderRadius: 10, cursor: "pointer", border: `2px solid ${form.phonebook_id === pb.id ? "var(--accent)" : "var(--line)"}`, background: form.phonebook_id === pb.id ? "var(--accent-soft)" : "var(--surface)", display: "flex", alignItems: "center", gap: 12, }} >
{pb.name}
{Number(pb.count).toLocaleString("en-IN")} contacts
))}
)}
)} {/* Step 3 — Schedule */} {step === 3 && (

Schedule & retries

Pick the days and hours your agent should dial. TRAI guidelines: 09:00–21:00 local time.

{[ ["mon","Mon"], ["tue","Tue"], ["wed","Wed"], ["thu","Thu"], ["fri","Fri"], ["sat","Sat"], ["sun","Sun"], ].map(([id, label]) => { const on = (form.schedule_json.days || []).includes(id); return ( ); })}
setForm({ ...form, schedule_json: { ...form.schedule_json, start: e.target.value } })}/> setForm({ ...form, schedule_json: { ...form.schedule_json, end: e.target.value } })}/>
setForm({ ...form, concurrent_calls: e.target.value })}/> setForm({ ...form, max_attempts: e.target.value })}/> setForm({ ...form, retry_delay_min: e.target.value })}/>
)}
Back {step === STEPS.length - 1 ? (submitting ? "Creating…" : "Create campaign") : "Continue"}
); } window.CampaignsScreen = CampaignsScreen;