// ============================================================================
// 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 ? (
) : (
)}
{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" && (
)}
{step === "done" && result && (
)}
);
}
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 (
);
}
window.ContactsScreen = ContactsScreen;