// patients.jsx — Patient list (grouped by appt) + full-page chart view
// Wired to /api/patients, /api/patients/profile, POST /api/patients,
// PUT /api/patients/update via the store.
const { useState: usePSt, useMemo: usePMemo, useCallback: usePCb, useEffect: usePEf } = React;
// ── Helpers ──────────────────────────────────────────────────────────────────
function daysSince(iso) {
if (!iso) return 999;
const d = new Date(iso);
if (isNaN(d)) return 999;
return Math.round((Date.now() - d.getTime()) / 86400000);
}
// ── VitalSpark (inline sparkline) ───────────────────────────────────────────
function VitalSparkline({ values, warn, width = 56, height = 24 }) {
if (!values || values.length === 0) {
return (
);
}
const max = Math.max(...values), min = Math.min(...values);
const range = max - min || 1;
const pts = values.map((v, i) => {
const x = values.length === 1 ? width / 2 : (i / (values.length - 1)) * width;
const y = height - ((v - min) / range) * (height - 4) - 2;
return `${x},${y}`;
}).join(" ");
const color = warn ? "var(--accent-danger)" : "var(--primary)";
const lastX = values.length === 1 ? width / 2 : width;
const lastY = height - ((values[values.length - 1] - min) / range) * (height - 4) - 2;
return (
);
}
// ── PatientRow ───────────────────────────────────────────────────────────────
function PatientRow({ p, onOpen, todayAppt, lastReason, isFavorite, onToggleFavorite }) {
const t = window.MSStore.useT();
const open = () => onOpen(p.id);
return (
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); open(); } }}
>
{p.avatar}
{p.first} {p.last}
{isFavorite ? <> {t("pat.favorite")}> : (p.age ? <>{p.age}{p.sex || ""}> : (p.phone || p.email || ""))}
{p.lastVisit ? new Date(p.lastVisit).toLocaleDateString("en-US", {month: "short", day: "numeric"}) : "—"}
{lastReason || (p.total_visits ? `${p.total_visits} visit${p.total_visits === 1 ? "" : "s"}` : "No visits yet")}
);
}
// ── Add patient modal ────────────────────────────────────────────────────────
function AddPatientModal({ onClose, onAdd }) {
const [form, setForm] = usePSt({
first: "", last: "", dob: "", sex: "F",
phone: "", email: "", insurance: "",
guardian: "", flags: "", chronic: "",
});
const [pending, setPending] = usePSt(false);
const [error, setError] = usePSt("");
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
const valid = form.first.trim() && form.last.trim();
async function handleSubmit() {
if (!valid || pending) return;
setError("");
setPending(true);
try {
const fullName = `${form.first.trim()} ${form.last.trim()}`.trim();
let age = "";
if (form.dob) {
const d = new Date(form.dob);
if (!isNaN(d)) {
const ageNum = Math.floor((Date.now() - d.getTime()) / (365.25 * 86400000));
if (ageNum >= 0 && ageNum < 130) age = String(ageNum);
}
}
await onAdd({ name: fullName, age, phone: form.phone, email: form.email });
onClose();
} catch (e) {
setError(e && e.message || "Could not add patient.");
} finally {
setPending(false);
}
}
return (
e.target === e.currentTarget && onClose()}>
Add patient
);
}
// ── Edit patient drawer ─────────────────────────────────────────────────────
function PatientEditDrawer({ p, onClose, onSave }) {
const [form, setForm] = usePSt({
age: p.age || "",
phone: p.phone || "",
email: p.email || "",
});
const [pending, setPending] = usePSt(false);
const [error, setError] = usePSt("");
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
async function submit() {
setError("");
setPending(true);
try {
await onSave(form);
onClose();
} catch (e) {
setError(e && e.message || "Could not save changes.");
} finally {
setPending(false);
}
}
return (
e.target === e.currentTarget && onClose()}>
Edit patient
Patient · {p.first} {p.last}
Renaming a patient is handled at the next encounter — the directory is keyed by the recorded name.
{error && (
{error}
)}
);
}
function VitalsEditDrawer({ patient, vitals, onClose, onSave }) {
const [form, setForm] = usePSt({
bp: vitals.bp || "",
hr: vitals.hr || "",
temp: vitals.temp || "",
spo2: vitals.spo2 || "",
weight: vitals.weight || "",
});
const [pending, setPending] = usePSt(false);
const [error, setError] = usePSt("");
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
async function submit() {
setPending(true); setError("");
try {
await onSave(form);
onClose();
} catch (e) {
setError(e && e.message || "Could not save vitals.");
} finally {
setPending(false);
}
}
return (
e.target === e.currentTarget && onClose()}>
Edit vitals
{patient.first} {patient.last}
{!vitals.session_id &&
Vitals can be saved after the patient has at least one real encounter.
}
{error &&
{error}
}
);
}
// ── PatientChart (full-page) ────────────────────────────────────────────────
function buildVitalTrend(history) {
const points = [];
for (const h of (history || []).slice().reverse()) {
const bp = h.vitals && h.vitals.bp;
if (bp) {
const sys = parseInt(String(bp).split("/")[0], 10);
if (!isNaN(sys)) points.push(sys);
}
}
if (points.length >= 2) {
return { label: "BP (sys)", values: points, unit: "mmHg sys", warn: false };
}
return null;
}
function PatientChart({ patient, onBack, onOpenEncounter, onSchedule, isFavorite, onToggleFavorite }) {
const store = window.MSStore.useStore();
const t = window.MSStore.useT();
const [tab, setTab] = usePSt("overview");
const [editing, setEditing] = usePSt(false);
const [editingVitals, setEditingVitals] = usePSt(false);
const [deleting, setDeleting] = usePSt(false);
const [deleteError, setDeleteError] = usePSt("");
const [profile, setProfile] = usePSt(null);
const [profileError, setProfileError] = usePSt(null);
const [loading, setLoading] = usePSt(true);
usePEf(() => {
let cancelled = false;
setLoading(true);
setProfileError(null);
store.getPatientProfile(patient.id).then(data => {
if (cancelled) return;
setProfile(data);
setLoading(false);
}).catch(e => {
if (cancelled) return;
setProfileError(e && e.message || "Could not load patient");
setLoading(false);
});
return () => { cancelled = true; };
}, [patient.id]);
const upcoming = (store.appointments || []).filter(a => a.patientName && a.patientName.toLowerCase() === (`${patient.first} ${patient.last}`).toLowerCase());
const vitals = (profile && profile.latest_vitals) || {};
const aggregated = (profile && profile.aggregated) || { diagnoses: [], medications: [], allergies: [] };
const history = (profile && profile.history) || [];
const vitalTrend = buildVitalTrend(history);
async function handleSave(form) {
await store.updatePatient(patient.id, {
age: form.age || "",
phone: form.phone || "",
email: form.email || "",
});
const fresh = await store.getPatientProfile(patient.id);
setProfile(fresh);
}
async function handleSaveVitals(form) {
await store.updateSessionVitals(vitals.session_id, form);
const fresh = await store.getPatientProfile(patient.id);
setProfile(fresh);
}
async function handleDelete() {
if (!window.confirm(`Delete ${patient.first} ${patient.last}'s patient record and visit history?`)) return;
setDeleting(true); setDeleteError("");
try {
await store.deletePatient(patient.id);
onBack();
} catch (e) {
setDeleteError(e && e.message || "Could not delete patient.");
} finally {
setDeleting(false);
}
}
return (
{editing &&
setEditing(false)} onSave={handleSave}/>}
{editingVitals && setEditingVitals(false)} onSave={handleSaveVitals}/>}
{/* Banner */}
{patient.avatar}
{patient.first} {patient.last}
{(aggregated.allergies || []).map(f => ⚠ {f})}
{(aggregated.diagnoses || []).slice(0, 3).map(c => {c})}
{patient.age ? `${patient.age} yrs` : "Age unknown"} · {patient.phone || "—"} · {patient.email || "—"}
{patient.phone &&
}
{patient.email &&
}
{upcoming.length > 0 && (
)}
{upcoming.length === 0 && (
)}
{deleteError && {deleteError}
}
{/* Body */}
{/* Left rail */}
{t("chart.rail.vitals")} {vitals && vitals.session_id && · {t("chart.rail.latestVisit")}}
{[
{ label: "BP", val: vitals.bp || "—" },
{ label: "HR", val: vitals.hr ? vitals.hr + " bpm" : "—" },
{ label: "Temp", val: vitals.temp || "—" },
{ label: "SpO₂", val: vitals.spo2 || "—" },
{ label: "Weight", val: vitals.weight || "—" },
].map(v => (
))}
{(aggregated.diagnoses || []).length > 0 && (
{t("chart.rail.conditions")}
{aggregated.diagnoses.map(c => (
{c}
))}
)}
{(aggregated.allergies || []).length > 0 && (
{t("chart.rail.alerts")}
{aggregated.allergies.map(f => (
{f}
))}
)}
{t("chart.rail.medications")}
{(aggregated.medications || []).length === 0 &&
{t("chart.rail.noMeds")}
}
{(aggregated.medications || []).map(m => (
{m}
))}
{t("chart.rail.contact")}
{t("chart.rail.phone")}
{patient.phone || "—"}
{t("chart.rail.email")}
{patient.email || "—"}
{/* Main content */}
{["overview", "notes", "medications", "labs", "documents"].map(tabId => (
))}
{loading && (
)}
{!loading && profileError && (
{profileError}
)}
{!loading && !profileError && tab === "overview" && (
{vitalTrend && (
{t("chart.trendLabel", { label: vitalTrend.label })}
{t("chart.trendSub", { n: vitalTrend.values.length })}
{vitalTrend.values[vitalTrend.values.length - 1]}
{vitalTrend.unit}
)}
{t("chart.history")}
{t("chart.historyCount", { n: history.length })}
{history.length === 0 && (
{t("chart.noVisits")}
)}
{history.map((v, i) => {
const date = (v.started_at || v.date || "").slice(0, 10);
const reason = (v.diagnoses && v.diagnoses[0]) || v.chief_complaint || "Visit";
return (
);
})}
)}
{!loading && !profileError && tab === "notes" && (
{history.length === 0 && (
)}
{history.map((v, i) => (
{(v.diagnoses && v.diagnoses[0]) || "Visit"}
{(v.started_at || "").slice(0, 10)}
{v.session_id &&
}
{v.chief_complaint
? <>{t("chart.chiefComplaint")}{v.chief_complaint}>
: {t("chart.noChiefComplaint")}}
))}
)}
{!loading && !profileError && tab === "medications" && (
(aggregated.medications || []).length === 0 ? (
) : (
{(aggregated.medications || []).map(m => (
))}
)
)}
{!loading && !profileError && tab === "labs" && (
)}
{!loading && !profileError && tab === "documents" && (
)}
);
}
// ── Patients (main component) ────────────────────────────────────────────────
function Patients({ onOpenEncounter, onSchedulePatient, focusId }) {
const store = window.MSStore.useStore();
const t = window.MSStore.useT();
const [adding, setAdding] = usePSt(false);
const [q, setQ] = usePSt("");
const [chartId, setChartId] = usePSt(focusId || null);
const [sortFilter, setSortFilter] = usePSt("all");
const [favoriteIds, setFavoriteIds] = usePSt(() => {
try { return JSON.parse(localStorage.getItem("medsync-favorite-patients") || "[]"); }
catch (_) { return []; }
});
const favoriteSet = usePMemo(() => new Set(favoriteIds), [favoriteIds]);
const toggleFavorite = usePCb((id) => {
setFavoriteIds(prev => {
const next = prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id];
try { localStorage.setItem("medsync-favorite-patients", JSON.stringify(next)); } catch (_) {}
return next;
});
}, []);
const todayAppointments = usePMemo(() => {
const map = {};
const today = new Date(); today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today.getTime() + 86400000);
for (const a of (store.appointments || [])) {
const t = new Date(a.scheduledAt);
if (isNaN(t)) continue;
if (t >= today && t < tomorrow && a.patientName) {
const k = a.patientName.toLowerCase();
if (!map[k] || t < new Date(map[k].scheduledAt)) map[k] = a;
}
}
return map;
}, [store.appointments]);
const filtered = usePMemo(() => {
let list = [...(store.patients || [])];
if (q) {
const ql = q.toLowerCase();
list = list.filter(p =>
`${p.first} ${p.last}`.toLowerCase().includes(ql) ||
(p.phone || "").includes(q) ||
(p.email || "").toLowerCase().includes(ql)
);
}
if (sortFilter === "favorites") list = list.filter(p => favoriteSet.has(p.id));
if (sortFilter === "today") list = list.filter(p => todayAppointments[(`${p.first} ${p.last}`).toLowerCase()]);
return list;
}, [q, sortFilter, store.patients, todayAppointments, favoriteSet]);
const grouped = usePMemo(() => {
const groups = [[], [], []];
for (const p of filtered) {
const todayAppt = todayAppointments[(`${p.first} ${p.last}`).toLowerCase()];
let g;
if (todayAppt) g = 0;
else g = daysSince(p.lastVisit) <= 30 ? 1 : 2;
groups[g].push(p);
}
return groups;
}, [filtered, todayAppointments]);
const openChart = usePCb((id) => setChartId(id), []);
const backToList = usePCb(() => setChartId(null), []);
if (chartId) {
const patient = (store.patients || []).find(p => p.id === chartId);
if (!patient) {
return (
Patient not found
);
}
return ;
}
async function handleAdd(form) {
await store.createPatient(form);
}
const totalPatients = (store.patients || []).length;
const isLoading = store.loading && store.loading.patients;
const groupLabels = [t("pat.groupToday"), t("pat.groupRecent"), t("pat.groupEarlier")];
return (
{adding &&
setAdding(false)} onAdd={handleAdd}/>}
{t("pat.title")}
{isLoading ? t("pat.loading") : t("pat.count", { n: totalPatients })}
{(store.errors && store.errors.patients) && {store.errors.patients}}
{/* Toolbar */}
{/* Column headers */}
{t("pat.col.patient")}
{t("pat.col.lastVisit")}
{/* Grouped rows */}
{grouped.map((group, gi) => group.length === 0 ? null : (
{groupLabels[gi]}
{group.map(p => {
const todayAppt = todayAppointments[(`${p.first} ${p.last}`).toLowerCase()];
const lastReason = (p.raw && p.raw.diagnoses && p.raw.diagnoses[0]) || (p.total_visits ? `${p.total_visits} visit${p.total_visits === 1 ? "" : "s"}` : null);
return
;
})}
))}
{filtered.length === 0 && !isLoading && (
{q ? "No patients match your search" : "No patients yet — click Add patient to create one."}
)}
);
}
window.Patients = Patients;