// store.jsx — React context that loads & exposes live data shaped to the design. // // The design components were written against the mock `MS_DATA` shape // (PATIENTS = [{id, first, last, age, sex, mrn, phone, email, lastVisit, flags, // chronic, avatar, ...}], APPOINTMENTS, PROVIDERS). This module // fetches the real backend data and adapts it to that shape so existing // component code keeps working — and exposes raw payloads + actions for any // screen that needs them (start session, save profile, etc.). const { createContext, useContext, useState, useEffect, useCallback, useMemo, useRef } = React; const StoreCtx = createContext(null); const PROVIDER_PALETTE = [ "#3b8aa8", "#a87b3b", "#5a8a3b", "#8a3b8a", "#3b5a8a", "#8a3b5a", "#3b8a8a", ]; const PREF_THEME_KEY = "medsync-theme"; const PREF_ENCOUNTER_LANGUAGE_KEY = "medsync-default-encounter-language"; function normalizeTheme(theme) { return theme === "dark" ? "dark" : "light"; } function inferDefaultEncounterLanguage() { return "auto"; } function getStoredTheme() { try { return normalizeTheme(localStorage.getItem(PREF_THEME_KEY)); } catch (_) { return "light"; } } function getStoredDefaultEncounterLanguage() { try { return localStorage.getItem(PREF_ENCOUNTER_LANGUAGE_KEY) || inferDefaultEncounterLanguage(); } catch (_) { return inferDefaultEncounterLanguage(); } } function applyTheme(theme) { const next = normalizeTheme(theme); try { localStorage.setItem(PREF_THEME_KEY, next); } catch (_) {} if (typeof document !== "undefined" && document.documentElement) { document.documentElement.setAttribute("data-theme", next); } return next; } function initials(name) { if (!name) return "??"; const parts = String(name).trim().split(/\s+/).filter(Boolean); if (parts.length === 0) return "??"; if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); } function splitName(name) { const parts = String(name || "").trim().split(/\s+/).filter(Boolean); if (parts.length === 0) return { first: "", last: "" }; if (parts.length === 1) return { first: parts[0], last: "" }; return { first: parts[0], last: parts.slice(1).join(" ") }; } function isoDateOnly(iso) { if (!iso) return ""; return String(iso).slice(0, 10); } function timeFromISO(iso) { if (!iso) return ""; const d = new Date(iso); if (isNaN(d)) return ""; const hh = String(d.getHours()).padStart(2, "0"); const mm = String(d.getMinutes()).padStart(2, "0"); return `${hh}:${mm}`; } function buildProviders(members, doctorId) { // Members come back with role in {owner, admin_doctor, doctor, staff, ...}. // Only providers (not staff) belong on the Schedule lane / Analytics chart. const providerRoles = new Set(["owner", "admin_doctor", "doctor"]); const list = (members || []).filter(m => providerRoles.has(m.role)); if (list.length === 0 && doctorId) { return [{ id: doctorId, name: window._currentDoctor && window._currentDoctor.name || "You", role: window._currentDoctor && window._currentDoctor.specialty || "Provider", color: PROVIDER_PALETTE[0], initials: initials(window._currentDoctor && window._currentDoctor.name), }]; } return list.map((m, i) => ({ id: m.id, name: m.name && (m.name.startsWith("Dr.") || m.name.startsWith("Dr ")) ? m.name : ("Dr. " + (m.name || "Unnamed")), role: m.specialty || (m.role === "owner" ? "Owner" : m.role === "admin_doctor" ? "Admin doctor" : "Provider"), color: m.avatar_color || PROVIDER_PALETTE[i % PROVIDER_PALETTE.length], initials: initials(m.name), raw: m, })); } function buildPatients(directory) { return (directory || []).map(p => { const { first, last } = splitName(p.name); return { id: p.name, // patient_name keys the backend; we use it as our row id. raw: p, first, last, dob: "", age: p.age || "", sex: "", mrn: "", phone: p.phone || "", email: p.email || "", insurance: "", lastVisit: isoDateOnly(p.last_visit), flags: [], chronic: p.diagnoses || [], avatar: initials(p.name), total_visits: p.total_visits || 0, last_session_id: p.last_session_id || null, }; }); } function buildAppointments(rows, providersById) { return (rows || []).map(a => { const time = timeFromISO(a.scheduled_at); const status = a.status === "completed" ? "done" : a.status === "no_show" ? "no-show" : a.status === "cancelled" ? "cancelled" : a.session_id ? "in-room" : "confirmed"; return { id: a.id, raw: a, patientId: a.patient_id || null, patientName: a.patient_name || "Unknown patient", providerId: a.doctor_id || null, providerName: a.doctor_name || "", time, scheduledAt: a.scheduled_at, duration: a.duration_minutes || 30, type: (a.reason && a.reason.split(/[—:]/)[0].trim()) || "Visit", reason: a.reason || "", source: a.source || "manual", assignmentStatus: a.assignment_status || "", room: "", status, visitMode: "in-person", sessionId: a.session_id || null, }; }); } function StoreProvider({ children }) { const [profile, setProfile] = useState(window._currentDoctor || null); const [activeAccount, setActiveAccount] = useState(window._currentAccount || null); const [accounts, setAccounts] = useState(window._currentAccounts || []); const [activeRole, setActiveRole] = useState(window._currentRole || null); const [members, setMembers] = useState([]); const [invitations, setInvitations] = useState([]); const [providers, setProviders] = useState([]); const [patientsRaw, setPatientsRaw] = useState([]); const [patients, setPatients] = useState([]); const [appointmentsRaw, setAppointmentsRaw] = useState([]); const [appointments, setAppointments] = useState([]); const [notifications, setNotifications] = useState([]); const [unreadNotifications, setUnreadNotifications] = useState(0); const [templates, setTemplates] = useState([]); const [analytics, setAnalytics] = useState(null); const [loadingFlags, setLoadingFlags] = useState({}); const [errors, setErrors] = useState({}); const [version, setVersion] = useState(0); const bumpVersion = useCallback(() => setVersion(v => v + 1), []); const setLoading = useCallback((key, val) => setLoadingFlags(f => ({ ...f, [key]: val })), []); const setError = useCallback((key, err) => setErrors(e => ({ ...e, [key]: err && err.message ? err.message : err })), []); const isAdmin = useMemo(() => activeRole === "owner" || activeRole === "admin_doctor", [activeRole]); async function loadAccountMembers() { setLoading("members", true); try { const data = await window.MSApi.apiFetch("/api/account/members"); setMembers(data && data.members || []); setInvitations(data && data.invitations || []); setError("members", null); } catch (e) { setError("members", e); } finally { setLoading("members", false); } } async function loadProfile() { try { const data = await window.MSApi.apiFetch("/api/profile"); setProfile(data || null); window._currentDoctor = data || null; setError("profile", null); } catch (e) { setError("profile", e); } } async function loadPatients() { setLoading("patients", true); try { const data = await window.MSApi.apiFetch("/api/patients"); const list = Array.isArray(data) ? data : []; setPatientsRaw(list); setPatients(buildPatients(list)); setError("patients", null); } catch (e) { setError("patients", e); } finally { setLoading("patients", false); } } async function loadAppointments(rangeDays) { rangeDays = rangeDays || 30; setLoading("appointments", true); try { const now = new Date(); const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const from = new Date(startOfDay.getTime() - 86400000 * 7).toISOString(); const to = new Date(startOfDay.getTime() + 86400000 * rangeDays).toISOString(); const params = new URLSearchParams({ from, to }); const data = await window.MSApi.apiFetch("/api/appointments?" + params.toString()); const list = Array.isArray(data) ? data : []; setAppointmentsRaw(list); setError("appointments", null); return list; } catch (e) { setError("appointments", e); return []; } finally { setLoading("appointments", false); } } async function loadTemplates() { try { const data = await window.MSApi.apiFetch("/api/templates"); setTemplates(Array.isArray(data) ? data : []); } catch (_) { /* templates are optional */ } } async function loadAnalytics() { setLoading("analytics", true); try { const data = await window.MSApi.apiFetch("/api/analytics"); setAnalytics(data || null); setError("analytics", null); } catch (e) { setError("analytics", e); } finally { setLoading("analytics", false); } } async function loadNotifications() { try { const data = await window.MSApi.apiFetch("/api/notifications?limit=20"); setNotifications((data && data.notifications) || []); setUnreadNotifications((data && data.unread_count) || 0); setError("notifications", null); } catch (e) { setError("notifications", e); } } async function refreshAll() { await Promise.all([ loadProfile(), loadPatients(), loadAppointments(), loadTemplates(), loadAccountMembers(), loadNotifications(), ]); bumpVersion(); } // Re-derive providers + appointments when raw data or members change. useEffect(() => { const provs = buildProviders(members, profile && profile.id); setProviders(provs); }, [members, profile && profile.id]); useEffect(() => { const provsById = {}; providers.forEach(p => { provsById[p.id] = p; }); setAppointments(buildAppointments(appointmentsRaw, provsById)); }, [appointmentsRaw, providers]); // Initial load whenever the account changes. useEffect(() => { if (!activeAccount || !activeAccount.id) return; if (window.MSApi) window.MSApi.setAccountId(activeAccount.id); refreshAll(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeAccount && activeAccount.id]); function setAuthFromPayload(payload) { if (!payload) return; if (payload.token && window.MSApi) window.MSApi.setToken(payload.token); if (payload.doctor) { setProfile(payload.doctor); window._currentDoctor = payload.doctor; } if (payload.active_account) { setActiveAccount(payload.active_account); window._currentAccount = payload.active_account; } if (payload.accounts) { setAccounts(payload.accounts); window._currentAccounts = payload.accounts; } if (payload.active_role) { setActiveRole(payload.active_role); window._currentRole = payload.active_role; } if (payload.active_account && payload.active_account.id && window.MSApi) { window.MSApi.setAccountId(payload.active_account.id); } } async function clearAuth() { if (window.MSApi) await window.MSApi.logout(); setProfile(null); setActiveAccount(null); setAccounts([]); setActiveRole(null); setMembers([]); setInvitations([]); setProviders([]); setPatientsRaw([]); setPatients([]); setAppointmentsRaw([]); setAppointments([]); setAnalytics(null); } // Imperative actions ───────────────────────────────────────────────────── async function createPatient({ name, age, phone, email }) { const body = { name: (name || "").trim(), age: age || "", phone: phone || "", email: email || "" }; if (!body.name) throw new Error("Patient name required"); const r = await window.MSApi.apiFetch("/api/patients", { method: "POST", body }); await loadPatients(); return r; } async function updatePatient(name, { age, phone, email }) { if (!name) throw new Error("Patient name required"); const params = new URLSearchParams({ name }).toString(); const body = { age: age || "", phone: phone || "", email: email || "" }; const r = await window.MSApi.apiFetch("/api/patients/update?" + params, { method: "PUT", body }); await loadPatients(); return r; } async function deletePatient(name) { if (!name) throw new Error("Patient name required"); const params = new URLSearchParams({ name }).toString(); const r = await window.MSApi.apiFetch("/api/patients?" + params, { method: "DELETE" }); await Promise.all([loadPatients(), loadAppointments()]); return r; } async function getPatientProfile(name) { const params = new URLSearchParams({ name }).toString(); return window.MSApi.apiFetch("/api/patients/profile?" + params); } async function startSession(payload) { return window.MSApi.apiFetch("/api/sessions/start", { method: "POST", body: payload || {} }); } async function endSession(sessionId) { return window.MSApi.apiFetch(`/api/sessions/${sessionId}/end`, { method: "POST" }); } async function uploadAudio(sessionId, blob, template) { const fd = new FormData(); const filename = "session-" + sessionId + (blob.type && blob.type.includes("mp4") ? ".mp4" : ".webm"); fd.append("audio", blob, filename); fd.append("template", template || "soap"); return window.MSApi.apiFetch(`/api/sessions/${sessionId}/upload-audio`, { method: "POST", body: fd }); } async function createAppointment(payload) { const r = await window.MSApi.apiFetch("/api/appointments", { method: "POST", body: payload || {} }); await loadAppointments(); return r; } async function searchCalendarPatients(search) { const params = new URLSearchParams({ search: search || "", limit: "100" }); return window.MSApi.apiFetch("/api/calendar/patients?" + params.toString()); } async function updateSessionVitals(sessionId, vitals) { if (!sessionId) throw new Error("No visit with vitals is available for this patient yet."); const r = await window.MSApi.apiFetch(`/api/sessions/${sessionId}/vitals`, { method: "PUT", body: { vitals_bp: vitals && vitals.bp || "", vitals_hr: vitals && vitals.hr || "", vitals_temp: vitals && vitals.temp || "", vitals_weight: vitals && vitals.weight || "", vitals_spo2: vitals && vitals.spo2 || "", }, }); await loadPatients(); return r; } async function ensureFollowupDraft(sessionId) { if (!sessionId) throw new Error("No session available for follow-up scheduling."); const r = await window.MSApi.apiFetch(`/api/sessions/${sessionId}/follow-up-draft`, { method: "POST" }); await loadAppointments(365); return r; } async function markNotificationRead(id) { const r = await window.MSApi.apiFetch(`/api/notifications/${id}/read`, { method: "PATCH" }); await loadNotifications(); return r; } async function markAllNotificationsRead() { const r = await window.MSApi.apiFetch("/api/notifications/read-all", { method: "POST" }); await loadNotifications(); return r; } async function summarizeSession(sessionId, template) { return window.MSApi.apiFetch(`/api/sessions/${sessionId}/summarize`, { method: "POST", body: { template: template || "soap" }, }); } async function getSessionSummary(sessionId) { return window.MSApi.apiFetch(`/api/sessions/${sessionId}/summary`); } async function getSession(sessionId) { return window.MSApi.apiFetch(`/api/sessions/${sessionId}`); } async function saveClinicalNote(sessionId, noteText) { return window.MSApi.apiFetch(`/api/sessions/${sessionId}/clinical-note`, { method: "PUT", body: { note_text: noteText }, }); } async function updateProfile(fields) { const r = await window.MSApi.apiFetch("/api/profile", { method: "PUT", body: fields }); setProfile(r || null); window._currentDoctor = r || null; return r; } async function deleteOwnAccount() { const r = await window.MSApi.apiFetch("/api/profile", { method: "DELETE" }); await clearAuth(); window._msInitialAuth = null; window.location.assign("/?signup=1"); return r; } async function updateAccount(name, opts) { const body = { name, ...(opts || {}) }; const r = await window.MSApi.apiFetch("/api/account", { method: "PATCH", body }); if (r && r.account) { setActiveAccount(r.account); window._currentAccount = r.account; } return r; } async function inviteTeamMember(email, role) { const r = await window.MSApi.apiFetch("/api/account/invitations", { method: "POST", body: { email, role: role || "doctor" }, }); await loadAccountMembers(); return r; } // Local helpers ────────────────────────────────────────────────────────── const byId = useCallback( (collection, id) => (collection || []).find(x => x && x.id === id) || null, [] ); const value = { // state profile, activeAccount, accounts, activeRole, isAdmin, members, invitations, providers, patients, patientsRaw, appointments, appointmentsRaw, templates, analytics, notifications, unreadNotifications, loading: loadingFlags, errors, version, // mutators / actions setAuthFromPayload, clearAuth, refreshAll, loadProfile, loadPatients, loadAppointments, loadAnalytics, loadAccountMembers, loadTemplates, createPatient, updatePatient, deletePatient, getPatientProfile, createAppointment, searchCalendarPatients, updateSessionVitals, ensureFollowupDraft, loadNotifications, markNotificationRead, markAllNotificationsRead, startSession, endSession, uploadAudio, summarizeSession, getSessionSummary, getSession, saveClinicalNote, updateProfile, updateAccount, inviteTeamMember, deleteOwnAccount, byId, }; return React.createElement(StoreCtx.Provider, { value }, children); } function useStore() { return useContext(StoreCtx); } // ── i18n React layer ───────────────────────────────────────────────────────── // The dictionary + setter live in window.MSi18n (loaded before this file via // plain