// people.jsx — Manifest CRM workspace
const { useState: useStateM, useEffect: useEffectM, useMemo: useMemoM } = React;
const CRM_TOUCH_KINDS = ["note", "email", "call", "meeting", "message", "intro", "follow-up"];
const CRM_ORG_KINDS = ["client", "partner", "vendor", "funder", "agency", "company", "nonprofit", "other"];
const CRM_STATUSES = ["active", "paused", "archived"];
function crmGmailCompose(email) {
return `https://mail.google.com/mail/?view=cm&fs=1&to=${encodeURIComponent(email || "")}`;
}
function crmGmailSearch(email) {
const q = `from:${email || ""} OR to:${email || ""}`;
return `https://mail.google.com/mail/u/0/#search/${encodeURIComponent(q)}`;
}
function crmMapSearch(location) {
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(location || "")}`;
}
function crmTodayPlus(days) {
const d = new Date();
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
}
function crmFmtDate(value) {
if (!value) return "";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return String(value).slice(0, 10);
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
}
function crmOpen(url) {
if (url) window.open(url, "_blank", "noopener,noreferrer");
}
function ContactActions({ person, compact }) {
const email = person?.email || "";
const phone = person?.phone || "";
const location = person?.location || "";
return (
Call
);
}
function PeopleView({ projects }) {
const [people, setPeople] = useStateM([]);
const [orgs, setOrgs] = useStateM([]);
const [q, setQ] = useStateM("");
const [view, setView] = useStateM("all");
const [projectFilter, setProjectFilter] = useStateM("");
const [orgFilter, setOrgFilter] = useStateM("");
const [selectedKind, setSelectedKind] = useStateM("person");
const [selectedId, setSelectedId] = useStateM(null);
const [detail, setDetail] = useStateM(null);
const [orgDetail, setOrgDetail] = useStateM(null);
const [showCreatePerson, setShowCreatePerson] = useStateM(false);
const [showCreateOrg, setShowCreateOrg] = useStateM(false);
const status = view === "archived" ? "archived" : view === "paused" ? "paused" : "active";
const loadPeople = () => {
const params = new URLSearchParams();
if (q) params.set("q", q);
if (view === "due") params.set("due", "1");
if (projectFilter) params.set("project_id", projectFilter);
if (orgFilter) params.set("org_id", orgFilter);
if (status) params.set("status", status);
fetch(`/api/v1/people?${params}`)
.then(r => r.ok ? r.json() : [])
.then(setPeople)
.catch(() => setPeople([]));
};
const loadOrgs = () => {
fetch("/api/v1/organizations")
.then(r => r.ok ? r.json() : [])
.then(setOrgs)
.catch(() => setOrgs([]));
};
useEffectM(loadPeople, [q, view, projectFilter, orgFilter]);
useEffectM(loadOrgs, []);
useEffectM(() => {
if (!selectedId) { setDetail(null); setOrgDetail(null); return; }
if (selectedKind === "organization") {
fetch(`/api/v1/organizations/${selectedId}`)
.then(r => r.ok ? r.json() : null)
.then(setOrgDetail)
.catch(() => setOrgDetail(null));
return;
}
fetch(`/api/v1/people/${selectedId}`)
.then(r => r.ok ? r.json() : null)
.then(setDetail)
.catch(() => setDetail(null));
}, [selectedId, selectedKind]);
const dueCount = people.filter(p => p.next_touch).length;
const selectedProject = projects.find(p => p.id === projectFilter);
const refreshSelected = () => {
loadPeople();
loadOrgs();
if (!selectedId) return;
if (selectedKind === "organization") {
fetch(`/api/v1/organizations/${selectedId}`).then(r => r.ok ? r.json() : null).then(setOrgDetail);
} else {
fetch(`/api/v1/people/${selectedId}`).then(r => r.ok ? r.json() : null).then(setDetail);
}
};
return (
{showCreatePerson && (
setShowCreatePerson(false)}
onSaved={(p) => { setShowCreatePerson(false); setSelectedKind("person"); setSelectedId(p.id); refreshSelected(); }}
/>
)}
{showCreateOrg && (
setShowCreateOrg(false)}
onSaved={(o) => { setShowCreateOrg(false); setSelectedKind("organization"); setSelectedId(o.id); refreshSelected(); }}
/>
)}
NameOrganizationLast touchNext
{people.map(p => (
))}
{people.length === 0 &&
No matching people.
}
Organizations
{orgs.map(o => (
))}
{selectedKind === "person" && detail && (
{ setSelectedId(null); setDetail(null); refreshSelected(); }}
/>
)}
{selectedKind === "organization" && orgDetail && (
{ setSelectedId(null); setOrgDetail(null); refreshSelected(); }}
/>
)}
{!selectedId && (
Select a person or organization.
Open a dossier to edit contact details, log touchpoints, and manage follow-ups.
)}
);
}
function PersonForm({ person, orgs, onSaved, onCancel }) {
const isEdit = !!person;
const [draft, setDraft] = useStateM({
name: person?.name || "",
title: person?.title || "",
org_id: person?.org_id || "",
email: person?.email || "",
phone: person?.phone || "",
location: person?.location || "",
preferred_contact: person?.preferred_contact || "",
next_touch: person?.next_touch || "",
status: person?.status || "active",
context: person?.context || "",
notes: person?.notes || "",
tags: (person?.tags || []).join(", "),
linkedin: person?.links?.linkedin || "",
website: person?.links?.website || "",
});
const [error, setError] = useStateM("");
const set = (field, value) => setDraft(prev => ({ ...prev, [field]: value }));
async function submit(e) {
e.preventDefault();
setError("");
const body = {
name: draft.name.trim(),
title: draft.title.trim(),
org_id: draft.org_id || null,
email: draft.email.trim(),
phone: draft.phone.trim(),
location: draft.location.trim(),
preferred_contact: draft.preferred_contact,
next_touch: draft.next_touch,
status: draft.status,
context: draft.context.trim(),
notes: draft.notes.trim(),
tags: draft.tags.split(",").map(t => t.trim()).filter(Boolean),
links: {
linkedin: draft.linkedin.trim(),
website: draft.website.trim(),
},
};
try {
const r = await fetch(isEdit ? `/api/v1/people/${person.id}` : "/api/v1/people", {
method: isEdit ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.detail || "Could not save person");
onSaved(data);
} catch (err) {
setError(err.message);
}
}
return (
);
}
function OrganizationForm({ org, onSaved, onCancel }) {
const isEdit = !!org;
const [draft, setDraft] = useStateM({
name: org?.name || "",
kind: org?.kind || "company",
website: org?.website || "",
email: org?.email || "",
phone: org?.phone || "",
location: org?.location || "",
tags: (org?.tags || []).join(", "),
status: org?.status || "active",
notes: org?.notes || "",
});
const [error, setError] = useStateM("");
const set = (field, value) => setDraft(prev => ({ ...prev, [field]: value }));
async function submit(e) {
e.preventDefault();
setError("");
const body = {
...draft,
tags: draft.tags.split(",").map(t => t.trim()).filter(Boolean),
};
try {
const r = await fetch(isEdit ? `/api/v1/organizations/${org.id}` : "/api/v1/organizations", {
method: isEdit ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.detail || "Could not save organization");
onSaved(data);
} catch (err) {
setError(err.message);
}
}
return (
);
}
function PersonDossier({ detail, orgs, projects, onChanged, onDeleted }) {
const [editing, setEditing] = useStateM(false);
const [interactions, setInteractions] = useStateM([]);
const [kind, setKind] = useStateM("note");
const [direction, setDirection] = useStateM("");
const [summary, setSummary] = useStateM("");
const [body, setBody] = useStateM("");
const [touchProject, setTouchProject] = useStateM("");
const [nextTouch, setNextTouch] = useStateM("");
const loadInteractions = () => {
fetch(`/api/v1/people/${detail.id}/interactions`)
.then(r => r.ok ? r.json() : [])
.then(setInteractions)
.catch(() => setInteractions([]));
};
useEffectM(loadInteractions, [detail.id]);
async function patchPerson(updates) {
const r = await fetch(`/api/v1/people/${detail.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates),
});
if (r.ok) onChanged();
}
async function logTouch(e) {
e.preventDefault();
if (!summary.trim()) return;
const r = await fetch(`/api/v1/people/${detail.id}/interactions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
kind,
direction,
summary: summary.trim(),
body: body.trim(),
project_id: touchProject || null,
org_id: detail.org_id || null,
next_touch: nextTouch || undefined,
}),
});
if (r.ok) {
setKind("note"); setDirection(""); setSummary(""); setBody(""); setTouchProject(""); setNextTouch("");
loadInteractions();
onChanged();
}
}
async function removePerson() {
if (!window.confirm(`Remove ${detail.name} from Manifest?`)) return;
await fetch(`/api/v1/people/${detail.id}`, { method: "DELETE" });
onDeleted();
}
if (editing) {
return setEditing(false)} onSaved={() => { setEditing(false); onChanged(); }} />;
}
return (
{detail.initials || detail.name.slice(0, 2).toUpperCase()}
{detail.org_name || "Independent"}
{detail.name}
{detail.title || "No title set"}
{(detail.links?.linkedin || detail.links?.website) && (
)}
{detail.context &&
}
{detail.notes &&
}
Follow-up shortcuts
{detail.projects?.length > 0 && (
Project affiliations
{detail.projects.map(a => (
{a.project_name || a.project_id}
{a.role || a.kind}
))}
)}
);
}
function OrganizationDossier({ org, onChanged, onDeleted }) {
const [editing, setEditing] = useStateM(false);
async function removeOrg() {
if (!window.confirm(`Delete ${org.name}? People will remain in Manifest without an organization.`)) return;
await fetch(`/api/v1/organizations/${org.id}`, { method: "DELETE" });
onDeleted();
}
if (editing) {
return setEditing(false)} onSaved={() => { setEditing(false); onChanged(); }} />;
}
return (
{org.kind || "organization"}
{org.name}
{org.location || `${org.people_count || 0} people`}
Call
{org.notes &&
}
People
{(org.people || []).map(p => (
{p.initials || p.name.slice(0, 2).toUpperCase()}
{p.name}{p.title}{p.next_touch ? crmFmtDate(p.next_touch) : ""}
))}
{(org.people || []).length === 0 &&
No people in this organization yet.
}
);
}
function InteractionList({ interactions, onChanged }) {
async function remove(interactionId) {
if (!window.confirm("Delete this interaction?")) return;
await fetch(`/api/v1/interactions/${interactionId}`, { method: "DELETE" });
if (onChanged) onChanged();
}
return (
{interactions.map(i => (
{i.kind}
{i.summary}
{i.body &&
{i.body}
}
{crmFmtDate(i.occurred_at)}{i.direction ? ` · ${i.direction}` : ""}
{i.external_url &&
}
{onChanged &&
}
))}
{interactions.length === 0 &&
No interactions logged.
}
);
}
function crmInfoAction(label, value) {
if (!value) return null;
if (label === "Email") return { href: crmGmailCompose(value), text: value };
if (label === "Phone") return { href: `tel:${value}`, text: value };
if (label === "Location") return { href: crmMapSearch(value), text: value };
return null;
}
function Info({ label, value }) {
const action = crmInfoAction(label, value);
return (
);
}
Object.assign(window, { PeopleView, ContactActions, crmGmailCompose, crmGmailSearch, crmFmtDate });