// ============================================================================ // public/screens/contacts.jsx — Contacts manager (segments rail + table) // ---------------------------------------------------------------------------- // • Top: KPI strip from /api/contacts/segments // • Left: segments rail (filter by status / last_outcome) // • Center: search + table with bulk-select // • Modals: CSV upload, manual add, phonebook management // ============================================================================ function ContactsScreen({ go }) { const [segments, setSegments] = React.useState(null); const [contacts, setContacts] = React.useState([]); const [total, setTotal] = React.useState(0); const [loading, setLoading] = React.useState(true); const [filter, setFilter] = React.useState("all"); // segment id const [phonebook, setPhonebook] = React.useState(""); // phonebook id const [phonebooks, setPhonebooks] = React.useState([]); const [q, setQ] = React.useState(""); const [selected, setSelected] = React.useState(new Set()); const [showUpload, setShowUpload] = React.useState(false); const [showAdd, setShowAdd] = React.useState(false); // Fetch list + segments together. const reload = React.useCallback(() => { setLoading(true); const params = new URLSearchParams(); if (filter !== "all" && filter !== "completed") params.append("status", filter); if (phonebook) params.append("phonebook", phonebook); if (q.trim()) params.append("q", q.trim()); Promise.all([ VoaisAPI.get("/api/contacts?" + params.toString() + "&limit=100"), VoaisAPI.get("/api/contacts/segments"), VoaisAPI.get("/api/contacts/phonebooks"), ]).then(([list, seg, pb]) => { setContacts(list.ok && list.data?.ok ? list.data.contacts : []); setTotal( list.ok && list.data?.ok ? list.data.total : 0); setSegments(seg.ok && seg.data?.ok ? seg.data.segments : null); setPhonebooks(pb.ok && pb.data?.ok ? pb.data.phonebooks : []); setLoading(false); }); }, [filter, phonebook, q]); // Debounce search. React.useEffect(() => { const id = setTimeout(reload, q ? 250 : 0); return () => clearTimeout(id); }, [reload, q]); const toggle = (id) => { const s = new Set(selected); s.has(id) ? s.delete(id) : s.add(id); setSelected(s); }; const toggleAll = () => { if (selected.size === contacts.length) setSelected(new Set()); else setSelected(new Set(contacts.map((c) => c.id))); }; const bulkAction = async (action) => { if (!selected.size) return; if (action === "delete" && !confirm(`Delete ${selected.size} contacts?`)) return; const r = await VoaisAPI.post("/api/contacts/bulk-action", { ids: [...selected], action }); if (!r.ok) { alert(r.data?.msg || "Bulk action failed."); return; } setSelected(new Set()); reload(); }; // ── KPIs from segments ──────────────────────────────────────────── const k = segments || {}; const totalC = Number(k.total || 0); const pendingC = Number(k.pending || 0); const dialedToday = Number(k.dialed_today || 0); const dncC = Number(k.dnc || 0); return (
{/* KPI strip */}
Total contacts
{totalC.toLocaleString("en-IN")}
across {phonebooks.length} phonebook{phonebooks.length === 1 ? "" : "s"}
Pending dial
{pendingC.toLocaleString("en-IN")}
{totalC > 0 ? `${Math.round(pendingC / totalC * 100)}% of total` : "—"}
Dialed today
{dialedToday.toLocaleString("en-IN")}
last 24 hours
DND / Opted out
{dncC.toLocaleString("en-IN")}
{totalC > 0 ? `${(dncC / totalC * 100).toFixed(2)}% of total` : "—"}
{/* Layout: segments rail | table */}
{/* Segments rail */}

Segments

} label="All contacts" count={totalC} value="all" active={filter} onClick={setFilter}/> } label="Pending" count={pendingC} value="pending" active={filter} onClick={setFilter}/> } label="Active" count={Number(k.active||0)} value="active" active={filter} onClick={setFilter}/> } label="DND / Opt-out" count={dncC} value="do_not_call" active={filter} onClick={setFilter}/> } label="Blocked" count={Number(k.blocked||0)} value="blocked" active={filter} onClick={setFilter}/>

Phonebooks

} label="All phonebooks" count={null} value="" active={phonebook} onClick={setPhonebook}/> {phonebooks.map((pb) => ( } label={pb.name} count={pb.count} value={String(pb.id)} active={phonebook} onClick={setPhonebook} /> ))} {phonebooks.length === 0 && (
No phonebooks yet.
)}
} onClick={() => setShowUpload(true)}> Upload CSV } onClick={() => setShowAdd(true)}> Add manually
{/* Table side */} {/* Search + bulk-action bar */}
setQ(e.target.value)}/>
{selected.size > 0 ? ( <> {selected.size} selected bulkAction("dnc")}>Mark DNC bulkAction("activate")}>Activate bulkAction("delete")} style={{ color: "var(--err)" }}>Delete ) : ( {total.toLocaleString("en-IN")} total{q ? " (filtered)" : ""} )}
{/* Table */} {loading ? (
Loading…
) : contacts.length === 0 ? (
) : ( {contacts.map((c) => ( ))}
0} onChange={toggleAll}/> Name / Phone Email Phonebook Status Attempts Last contact
e.stopPropagation()}> toggle(c.id)}/>
{c.name || (unnamed)}
{c.phone}
{c.email || "—"} {c.phonebook_name || "—"} {c.status.replace(/_/g, " ")} {c.attempts || 0} {c.last_contacted_at ? new Date(c.last_contacted_at).toLocaleString("en-IN", { dateStyle: "short", timeStyle: "short" }) : "—"}
)}
{showUpload && ( setShowUpload(false)} onUploaded={() => { setShowUpload(false); reload(); }} /> )} {showAdd && ( setShowAdd(false)} onAdded={() => { setShowAdd(false); reload(); }} /> )}
); } // ── Segment row in the rail ───────────────────────────────────────────── function SegmentItem({ icon, label, count, value, active, onClick }) { return (
onClick(value)} > {icon} {label} {count !== null && count !== undefined && ( {Number(count).toLocaleString("en-IN")} )}
); } // ── Upload CSV modal ──────────────────────────────────────────────────── function UploadCsvModal({ phonebooks, onClose, onUploaded }) { const [step, setStep] = React.useState("pick"); // pick → uploading → done const [pbMode, setPbMode] = React.useState(phonebooks.length ? "existing" : "new"); const [pbId, setPbId] = React.useState(phonebooks[0]?.id || ""); const [newPbName, setNewPbName] = React.useState(""); const [csvText, setCsvText] = React.useState(""); const [result, setResult] = React.useState(null); const [err, setErr] = React.useState(null); const fileRef = React.useRef(null); const onFile = (f) => { if (!f) return; const reader = new FileReader(); reader.onload = (e) => setCsvText(String(e.target.result || "")); reader.readAsText(f); }; const submit = async () => { setErr(null); if (!csvText.trim()) return setErr("Choose a CSV file or paste CSV text."); let targetPb = pbId; if (pbMode === "new") { if (!newPbName.trim()) return setErr("Enter a name for the new phonebook."); const create = await VoaisAPI.post("/api/contacts/phonebooks", { name: newPbName.trim() }); if (!create.ok) return setErr(create.data?.msg || "Failed to create phonebook."); targetPb = create.data.phonebookId; } if (!targetPb) return setErr("Pick a phonebook to import into."); setStep("uploading"); const r = await VoaisAPI.post(`/api/contacts/phonebooks/${targetPb}/import-csv`, { csv: csvText }); if (!r.ok) { setErr(r.data?.msg || "Import failed."); setStep("pick"); return; } setResult(r.data); setStep("done"); }; return ( {err &&
{err}
} {step === "pick" && (
{phonebooks.length > 0 && ( )}
{pbMode === "existing" ? ( ) : ( setNewPbName(e.target.value)}/> )}
onFile(e.target.files?.[0])} /> {csvText && (
Preview ({csvText.length.toLocaleString()} chars)
                {csvText.split("\n").slice(0, 4).join("\n")}{csvText.split("\n").length > 4 ? "\n…" : ""}
              
)}
Cancel Import
)} {step === "uploading" && (
Importing contacts…
)} {step === "done" && result && (

Import complete

Done
)} ); } function Stat({ label, value, color }) { return (
{Number(value || 0).toLocaleString("en-IN")}
{label}
); } // ── Add contact manually ──────────────────────────────────────────────── function AddContactModal({ phonebooks, onClose, onAdded }) { const [form, setForm] = React.useState({ name: "", phone: "", email: "", company: "", phonebook_id: phonebooks[0]?.id || "", }); const [submitting, setSubmitting] = React.useState(false); const [err, setErr] = React.useState(null); const submit = async (e) => { e.preventDefault(); setErr(null); if (!form.phone) return setErr("Phone number is required."); setSubmitting(true); const r = await VoaisAPI.post("/api/contacts", { ...form, phonebook_id: form.phonebook_id || null, }); setSubmitting(false); if (r.ok) onAdded(); else setErr(r.data?.msg || "Failed to add contact."); }; return (
{err &&
{err}
} setForm({ ...form, name: e.target.value })}/> setForm({ ...form, phone: e.target.value })}/>
setForm({ ...form, email: e.target.value })}/> setForm({ ...form, company: e.target.value })}/>
{phonebooks.length > 0 && ( )}
Cancel {submitting ? "Adding…" : "Add contact"}
); } window.ContactsScreen = ContactsScreen;