// Main Alpine app state for the SOS Care Now dashboard. const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; // Major US IANA timezones offered in the Settings dropdown. const US_TIMEZONES = [ { key: "America/New_York", label: "Eastern — New York" }, { key: "America/Chicago", label: "Central — Chicago" }, { key: "America/Denver", label: "Mountain — Denver" }, { key: "America/Phoenix", label: "Arizona (no DST) — Phoenix" }, { key: "America/Los_Angeles", label: "Pacific — Los Angeles" }, { key: "America/Anchorage", label: "Alaska — Anchorage" }, { key: "Pacific/Honolulu", label: "Hawaii — Honolulu" }, ]; const VISIT_LEVELS_FALLBACK = [ { key: 1, label: "Level 1", duration_minutes: 45 }, { key: 2, label: "Level 2", duration_minutes: 60 }, { key: 3, label: "Level 3", duration_minutes: 90 }, { key: "Nurse", label: "Nurse Visit", duration_minutes: 60 }, ]; function generateTimeOptions() { // 08:00 → 23:45 in 15-minute steps. Late slots exist for on-call coverage. const out = []; for (let h = 8; h < 24; h++) { for (let m = 0; m < 60; m += 15) { out.push(`${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`); } } return out; } // Return {year, month, day, hour, minute} for an instant, projected into `tz` (IANA name). // Falls back to browser local if tz is missing or invalid. function wallClockIn(instant, tz) { try { const parts = new Intl.DateTimeFormat("en-US", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, }).formatToParts(instant); const get = (t) => Number(parts.find(p => p.type === t).value); let hour = get("hour"); if (hour === 24) hour = 0; // Intl quirk: hour12:false sometimes yields "24" return { year: get("year"), month: get("month"), day: get("day"), hour, minute: get("minute") }; } catch (_) { return { year: instant.getFullYear(), month: instant.getMonth() + 1, day: instant.getDate(), hour: instant.getHours(), minute: instant.getMinutes(), }; } } // Earliest valid "HH:MM" on `dateStr` that satisfies the configured lead time, // computed in the platform timezone (so an Eastern user booking Chicago appointments // sees the floor in Central time, not their browser's local time). // Returns null if no time on that date works (caller should treat as "no valid options"). function minValidTimeForDate(dateStr, leadHours = 24, tz = null) { if (!dateStr) return "08:00"; const minInstant = new Date(Date.now() + leadHours * 60 * 60 * 1000); const wc = wallClockIn(minInstant, tz); const [ty, tmo, td] = dateStr.split("-").map(Number); // Compare Y/M/D in the platform tz. const cmp = (ty - wc.year) || (tmo - wc.month) || (td - wc.day); if (cmp > 0) return "08:00"; // target date is after the min-lead day if (cmp < 0) return null; // target date is before the min-lead day // Same day in the platform tz: round UP to next 15-minute slot. let h = wc.hour, m = wc.minute; const rem = m % 15; if (rem !== 0) m += 15 - rem; if (m === 60) { m = 0; h += 1; } if (h >= 24) return null; // past end-of-day window if (h < 8) { h = 8; m = 0; } // floor at 08:00 return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; } function appState() { return { // --- Auth --- authenticated: false, userRole: null, // "admin" | "provider" currentUser: "", providerId: null, loginMode: "admin", // "admin" | "provider" loginForm: { username: "", password: "" }, providerLoginForm: { name: "", password: "" }, providerOptions: [], minLeadTimeHours: 24, // overwritten by /api/schedule/config on init platformTimezone: null, // IANA tz (e.g. "America/Chicago") — overwritten by /api/schedule/config loginError: "", loginBusy: false, // --- Navigation --- view: "schedule", // schedule | providers | patients | appointments | calendar | audit | portal // --- Loading + toast --- toast: null, loading: false, // --- Data caches --- providers: [], patients: [], appointments: [], visitLevels: VISIT_LEVELS_FALLBACK, // --- Schedule view --- sched: { phase: "form", // form | matches | booked | error patientId: "", visitLevel: "1", address: "", preferredDate: "", timeStart: "09:00", timeEnd: "12:00", timeOptions: generateTimeOptions(), error: "", threadId: "", matches: [], selectedIdx: null, bookedAppt: null, notificationsSent: false, }, // --- Cancel tab --- cancel: { patientFilter: "All", providerFilter: "All", selectedId: "", }, scheduleTab: "new", // new | cancel // --- Appointments view (admin) --- apptView: { statusFilter: "All", // All | scheduled | completed | cancelled patientFilter: "All", providerFilter: "All", list: [], }, // --- Providers view --- prov: { showAdd: false, editingId: null, expandedId: null, // id of the provider whose availability editor is open form: this ? null : null, // placeholder confirmDelete: null, newOnCallDate: "", newBlockType: "All Day", newBlock: { title: "", date: "", start_time: "08:00", end_time: "12:00" }, }, provForm: { name: "", email: "", password: "", address: "", category: "provider", work_hours: { start: "08:00", end: "17:00" }, lunch_break: { start: "12:00", end: "13:00" }, service_levels: [1, 2, 3], }, // --- Patients view --- pat: { showAdd: false, editingId: null, confirmDelete: null, }, patForm: { name: "", email: "", address: "", notes: "" }, // --- Calendar view --- cal: { selectedProviderIds: [], showUnavailable: true, showTravel: true, showOnCall: true, providerLegend: [], instance: null, selectedEvent: null, }, // --- Audit log --- audit: { search: "", action: "All", entries: [], actionTypes: [] }, // --- Settings --- sett: { current: null, defaults: null, form: null, saving: false, }, // --- Provider portal --- portal: { self: null, tab: "availability", // availability | schedule editForm: { email: "", address: "", work_hours: { start: "08:00", end: "17:00" }, lunch_break: { start: "12:00", end: "13:00" }, recurring_days_off: [] }, newBlockType: "All Day", newBlock: { title: "", date: "", start_time: "08:00", end_time: "12:00" }, newOnCallDate: "", calendar: null, selectedEvent: null, }, // ============================================================ // INIT // ============================================================ async init() { this.sched.preferredDate = fmt.tomorrow(); this.prov.newBlock.date = fmt.tomorrow(); this.prov.newOnCallDate = fmt.today(); this.portal.newBlock.date = fmt.tomorrow(); this.portal.newOnCallDate = fmt.today(); await this.loadSchedulingConfig(); await this.refreshAuth(); if (!this.authenticated) { await this.loadProviderOptions(); } else { await this.afterLogin(); } this.$watch("view", (v) => this.onViewChange(v)); this.$watch("sched.preferredDate", () => this.enforceLeadTime()); // Snap to first valid slot on the initial (tomorrow) default. this.enforceLeadTime(); }, // Restrict sched.timeStart/timeEnd to slots that respect the configured lead time. // Called on init and whenever the preferred date changes. get validStartTimes() { const minT = minValidTimeForDate(this.sched.preferredDate, this.minLeadTimeHours, this.platformTimezone); if (!minT) return []; return this.sched.timeOptions.filter(t => t >= minT); }, // Earliest selectable date given the current lead time. Tries today first, then tomorrow. get minSelectableDate() { const today = fmt.today(); if (minValidTimeForDate(today, this.minLeadTimeHours, this.platformTimezone)) return today; return fmt.tomorrow(); }, // Latest Start is NOT constrained by lead time — just must be >= timeStart. // This lets users widen the window into the evening (on-call still respects lead time per-slot). get validEndTimes() { const start = this.sched.timeStart; if (!start) return []; return this.sched.timeOptions.filter(t => t >= start); }, // Friendly label for the platform timezone, e.g. "Central — Chicago" for "America/Chicago". // Falls back to the raw IANA name if not in the known list. get platformTimezoneLabel() { if (!this.platformTimezone) return ""; const match = US_TIMEZONES.find(t => t.key === this.platformTimezone); return match ? match.label : this.platformTimezone; }, // "Now" represented as a Date whose local-time fields equal the current wall-clock // in the platform timezone. FullCalendar (with default timeZone:"local") then places // the now-indicator at the correct slot for an Eastern user viewing Chicago times. platformNow() { const tz = this.platformTimezone; if (!tz) return new Date(); const wc = wallClockIn(new Date(), tz); return new Date(wc.year, wc.month - 1, wc.day, wc.hour, wc.minute); }, enforceLeadTime() { const valid = this.validStartTimes; if (valid.length === 0) { this.sched.timeStart = ""; this.sched.timeEnd = ""; return; } if (!valid.includes(this.sched.timeStart)) this.sched.timeStart = valid[0]; const endValid = this.sched.timeOptions.filter(t => t >= this.sched.timeStart); if (!endValid.includes(this.sched.timeEnd) || this.sched.timeEnd < this.sched.timeStart) { // Default end = 3 hours after start (12 slots) or last available const endIdx = Math.min(12, endValid.length - 1); this.sched.timeEnd = endValid[endIdx]; } }, async refreshAuth() { try { const me = await API.get("/api/auth/me"); if (me.authenticated) { this.authenticated = true; this.userRole = me.user_role; this.currentUser = me.current_user; this.providerId = me.provider_id || null; } } catch (e) { /* not logged in */ } }, async loadProviderOptions() { try { this.providerOptions = await API.get("/api/auth/provider-names"); } catch (e) { this.providerOptions = []; } }, async loadSchedulingConfig() { try { const cfg = await API.get("/api/schedule/config"); if (cfg && typeof cfg.min_lead_time_hours === "number") { this.minLeadTimeHours = cfg.min_lead_time_hours; } if (cfg && typeof cfg.timezone === "string" && cfg.timezone) { this.platformTimezone = cfg.timezone; } } catch (e) { /* keep defaults */ } }, async afterLogin() { // Pick a default view per role if (this.userRole === "provider") { this.view = "portal"; await this.loadPortal(); } else { this.view = "schedule"; await this.loadVisitLevels(); await this.loadPatients(); } }, // ============================================================ // LOGIN // ============================================================ async submitLogin() { this.loginError = ""; this.loginBusy = true; try { if (this.loginMode === "admin") { const res = await API.post("/api/auth/login", this.loginForm); this.authenticated = true; this.userRole = res.user_role; this.currentUser = res.current_user; this.providerId = null; } else { const res = await API.post("/api/auth/provider-login", this.providerLoginForm); this.authenticated = true; this.userRole = res.user_role; this.currentUser = res.current_user; this.providerId = res.provider_id; } await this.afterLogin(); } catch (e) { this.loginError = e.message || "Login failed"; } finally { this.loginBusy = false; } }, async logout() { try { await API.post("/api/auth/logout"); } catch (e) {} this.authenticated = false; this.userRole = null; this.currentUser = ""; this.providerId = null; this.loginForm = { username: "", password: "" }; this.providerLoginForm = { name: "", password: "" }; await this.loadProviderOptions(); }, // ============================================================ // VIEW NAVIGATION // ============================================================ async onViewChange(v) { // Destroy any existing calendar instance if (this.cal.instance) { this.cal.instance.destroy(); this.cal.instance = null; } if (this.portal.calendar) { this.portal.calendar.destroy(); this.portal.calendar = null; } if (v === "schedule") { await this.loadVisitLevels(); await this.loadPatients(); } if (v === "providers") { await this.loadProviders(); } if (v === "patients") { await this.loadPatients(); } if (v === "appointments") { await this.loadAppointmentsList(); await this.loadProviders(); await this.loadPatients(); } if (v === "calendar") { await this.loadProviders(); await this.$nextTick(); this.mountCalendar(); } if (v === "audit") { await this.loadAudit(); } if (v === "portal") { await this.loadPortal(); } if (v === "settings") { await this.loadSettings(); } }, // ============================================================ // SETTINGS // ============================================================ async loadSettings() { try { const res = await API.get("/api/settings"); this.sett.current = res.current; this.sett.defaults = res.defaults; // Build editable form (deep-copy to avoid mutating .current until save) this.sett.form = JSON.parse(JSON.stringify(res.current)); } catch (e) { this.showToast(e.message, "error"); } }, get timezoneOptions() { // Surface the saved value even if it's not in the standard US list. const current = this.sett.form && this.sett.form.timezone; const standardKeys = US_TIMEZONES.map(t => t.key); if (current && !standardKeys.includes(current)) { return [{ key: current, label: `${current} (current)` }, ...US_TIMEZONES]; } return US_TIMEZONES; }, get visitLevelEntries() { // Return [{key, label, duration_minutes}, ...] for stable iteration in the template. if (!this.sett.form || !this.sett.form.visit_levels) return []; return Object.entries(this.sett.form.visit_levels).map(([k, v]) => ({ key: k, label: v.label || k, duration_minutes: v.duration_minutes, })); }, updateVisitLevelDuration(key, value) { const n = parseInt(value); if (isNaN(n)) return; this.sett.form.visit_levels[key].duration_minutes = n; }, async saveSettings() { if (this.sett.saving) return; this.sett.saving = true; try { const res = await API.put("/api/settings", this.sett.form); this.sett.current = res.current; this.sett.form = JSON.parse(JSON.stringify(res.current)); // Refresh the schedule view's visit-level dropdown so changes are visible immediately. this.visitLevels = Object.entries(res.current.visit_levels).map(([k, v]) => ({ key: (isNaN(parseInt(k)) ? k : parseInt(k)), label: v.label, duration_minutes: v.duration_minutes, })); if (typeof res.current.min_lead_time_hours === "number") { this.minLeadTimeHours = res.current.min_lead_time_hours; this.enforceLeadTime(); } if (res.changes.length === 0) { this.showToast("No changes to save.", "info"); } else { this.showToast(`Saved ${res.changes.length} change(s).`, "success"); } } catch (e) { this.showToast(e.message, "error"); } finally { this.sett.saving = false; } }, async resetSettings() { if (!confirm("Reset ALL settings to defaults? This cannot be undone.")) return; try { const res = await API.post("/api/settings/reset"); this.sett.current = res.current; this.sett.form = JSON.parse(JSON.stringify(res.current)); this.showToast("Settings reset to defaults.", "success"); } catch (e) { this.showToast(e.message, "error"); } }, // ============================================================ // TOAST // ============================================================ showToast(msg, type = "info") { this.toast = { msg, type }; setTimeout(() => { if (this.toast && this.toast.msg === msg) this.toast = null; }, 3500); }, // ============================================================ // DATA LOADERS // ============================================================ async loadVisitLevels() { try { this.visitLevels = await API.get("/api/schedule/visit-levels"); } catch (e) { /* keep fallback */ } }, async loadProviders() { try { this.providers = await API.get("/api/providers?active_only=false"); } catch (e) { this.showToast(e.message, "error"); } }, async loadPatients() { try { this.patients = await API.get("/api/patients"); } catch (e) { this.showToast(e.message, "error"); } }, // ============================================================ // SCHEDULE — find matches & book // ============================================================ get selectedPatient() { return this.patients.find(p => p.id === this.sched.patientId); }, onPatientChange() { const p = this.selectedPatient; if (p) this.sched.address = p.address || ""; }, async findMatches() { if (!this.sched.patientId) { this.showToast("Select a patient", "error"); return; } if (!this.sched.address) { this.showToast("Address required", "error"); return; } if (!this.sched.timeStart || !this.sched.timeEnd) { this.showToast(`No appointment times on this date meet the ${this.minLeadTimeHours}-hour lead time. Pick a later date.`, "error"); return; } this.loading = true; this.sched.error = ""; try { const vl = isNaN(parseInt(this.sched.visitLevel)) ? this.sched.visitLevel : parseInt(this.sched.visitLevel); const res = await API.post("/api/schedule/find-matches", { patient_id: this.sched.patientId, visit_level: vl, visit_address: this.sched.address, preferred_dates: [this.sched.preferredDate], preferred_time_window: { start: this.sched.timeStart, end: this.sched.timeEnd }, }); if (res.error) { this.sched.error = res.error; this.sched.phase = "error"; } else if ((res.ranked_matches || []).length === 0) { this.sched.error = "No matches found. Try different dates or times."; this.sched.phase = "error"; } else { this.sched.threadId = res.thread_id; this.sched.matches = res.ranked_matches; this.sched.selectedIdx = null; this.sched.phase = "matches"; } } catch (e) { this.sched.error = e.message; this.sched.phase = "error"; } finally { this.loading = false; } }, get groupedMatches() { // Group ranked matches by provider, preserving rank order. const groups = []; const seen = {}; this.sched.matches.forEach((m, idx) => { if (!(m.provider_id in seen)) { seen[m.provider_id] = { provider_id: m.provider_id, name: m.provider_name, min_travel: m.travel_time_minutes, is_oncall: !!m.is_oncall, slots: [] }; groups.push(seen[m.provider_id]); } else { seen[m.provider_id].is_oncall = seen[m.provider_id].is_oncall || !!m.is_oncall; } seen[m.provider_id].slots.push({ idx, ...m }); }); return groups; }, selectSlot(idx) { this.sched.selectedIdx = idx; }, async bookAppointment() { if (this.sched.selectedIdx === null) return; this.loading = true; try { const res = await API.post("/api/schedule/book", { thread_id: this.sched.threadId, selection_index: this.sched.selectedIdx, }); if (res.error) { this.sched.error = res.error; this.sched.phase = "error"; } else { this.sched.bookedAppt = res.booked_appointment; this.sched.notificationsSent = res.notifications_sent; this.sched.phase = "booked"; } } catch (e) { this.sched.error = e.message; this.sched.phase = "error"; } finally { this.loading = false; } }, startOver() { this.sched.phase = "form"; this.sched.error = ""; this.sched.matches = []; this.sched.selectedIdx = null; this.sched.bookedAppt = null; }, // Like startOver(), but keeps the form inputs so the user can tweak and re-submit. backToForm() { this.sched.phase = "form"; this.sched.error = ""; this.sched.matches = []; this.sched.selectedIdx = null; }, get matchesCount() { return this.sched.matches.length; }, // ============================================================ // CANCEL APPOINTMENT (admin) // ============================================================ async loadFutureAppointments() { const all = await API.get("/api/appointments?status=scheduled"); const today = fmt.today(); this.appointments = all.filter(a => a.date >= today); }, async openCancelTab() { this.scheduleTab = "cancel"; await this.loadFutureAppointments(); await this.loadProviders(); await this.loadPatients(); }, get filteredCancelAppts() { let list = [...this.appointments]; if (this.cancel.patientFilter !== "All") { const pid = (this.patients.find(p => p.name === this.cancel.patientFilter) || {}).id; if (pid) list = list.filter(a => a.patient_id === pid); } if (this.cancel.providerFilter !== "All") { const pid = (this.providers.find(p => p.name === this.cancel.providerFilter) || {}).id; if (pid) list = list.filter(a => a.provider_id === pid); } return list; }, async cancelAppointment() { if (!this.cancel.selectedId) { this.showToast("Select an appointment", "error"); return; } if (!confirm("Cancel this appointment? Calendar event and notifications will be sent.")) return; this.loading = true; try { await API.del(`/api/appointments/${this.cancel.selectedId}`); this.showToast("Appointment cancelled", "success"); this.cancel.selectedId = ""; await this.loadFutureAppointments(); } catch (e) { this.showToast(e.message, "error"); } finally { this.loading = false; } }, // ============================================================ // APPOINTMENTS view (admin) // ============================================================ async loadAppointmentsList() { this.apptView.list = await API.get("/api/appointments"); }, get filteredAppointmentsList() { let list = [...this.apptView.list]; if (this.apptView.statusFilter !== "All") { list = list.filter(a => (a.status || "scheduled") === this.apptView.statusFilter); } if (this.apptView.patientFilter !== "All") { const pid = (this.patients.find(p => p.name === this.apptView.patientFilter) || {}).id; if (pid) list = list.filter(a => a.patient_id === pid); } if (this.apptView.providerFilter !== "All") { const pid = (this.providers.find(p => p.name === this.apptView.providerFilter) || {}).id; if (pid) list = list.filter(a => a.provider_id === pid); } // Sort: upcoming first (date+time ascending), then completed/cancelled at the bottom. const today = fmt.today(); list.sort((a, b) => { const akey = (a.date || "") + "T" + (a.start_time || ""); const bkey = (b.date || "") + "T" + (b.start_time || ""); const aFuture = (a.date || "") >= today && (a.status || "scheduled") === "scheduled"; const bFuture = (b.date || "") >= today && (b.status || "scheduled") === "scheduled"; if (aFuture !== bFuture) return aFuture ? -1 : 1; return akey.localeCompare(bkey); }); return list; }, statusBadgeClass(status) { const s = status || "scheduled"; if (s === "completed") return "badge badge-emerald"; if (s === "cancelled") return "badge badge-red"; return "badge badge-blue"; }, // True when the appointment's start time has passed in the platform timezone. // Used to enable/disable the "Mark Complete" button for providers. canCompleteAppointment(a) { if (!a || (a.status && a.status !== "scheduled")) return false; if (!a.date || !a.start_time) return false; const now = this.platformNow(); const [y, mo, d] = a.date.split("-").map(Number); const [h, mi] = a.start_time.split(":").map(Number); const startLocal = new Date(y, mo - 1, d, h, mi); return now >= startLocal; }, async markAppointmentComplete(appointmentId) { if (!appointmentId) return; this.loading = true; try { await API.post(`/api/appointments/${appointmentId}/complete`, {}); this.showToast("Marked complete", "success"); // Refresh whichever view we're on if (this.view === "portal" && this.portal.calendar) this.portal.calendar.refetchEvents(); if (this.view === "appointments") await this.loadAppointmentsList(); this.portal.selectedEvent = null; } catch (e) { this.showToast(e.message, "error"); } finally { this.loading = false; } }, // ============================================================ // PROVIDERS view // ============================================================ openAddProvider() { this.prov.editingId = null; this.provForm = { name: "", email: "", password: "", address: "", category: "provider", work_hours: { start: "08:00", end: "17:00" }, lunch_break: { start: "12:00", end: "13:00" }, service_levels: [1, 2, 3], }; this.prov.showAdd = true; }, openEditProvider(p) { this.prov.editingId = p.id; this.provForm = { name: p.name || "", email: p.email || "", password: p.password || "", address: p.address || "", category: p.category || "provider", work_hours: { ...(p.work_hours || { start: "08:00", end: "17:00" }) }, lunch_break: { ...(p.lunch_break || { start: "12:00", end: "13:00" }) }, service_levels: [...(p.service_levels || [])], }; this.prov.showAdd = true; }, toggleServiceLevel(level) { const idx = this.provForm.service_levels.findIndex(x => x == level); if (idx >= 0) this.provForm.service_levels.splice(idx, 1); else this.provForm.service_levels.push(level); }, async saveProvider() { this.loading = true; try { if (this.prov.editingId) { await API.put(`/api/providers/${this.prov.editingId}`, this.provForm); this.showToast("Provider updated", "success"); } else { await API.post("/api/providers", this.provForm); this.showToast("Provider added", "success"); } this.prov.showAdd = false; await this.loadProviders(); } catch (e) { this.showToast(e.message, "error"); } finally { this.loading = false; } }, async deleteProvider(id) { try { await API.del(`/api/providers/${id}`); this.showToast("Provider deleted", "success"); this.prov.confirmDelete = null; await this.loadProviders(); } catch (e) { this.showToast(e.message, "error"); } }, async saveRecurringDaysOff(provider) { try { await API.put(`/api/providers/${provider.id}/recurring-days-off`, { recurring_days_off: provider.recurring_days_off || [] }); this.showToast("Standing days off updated", "success"); await this.loadProviders(); } catch (e) { this.showToast(e.message, "error"); } }, toggleRecurringDay(provider, day) { const list = provider.recurring_days_off || []; const idx = list.indexOf(day); if (idx >= 0) list.splice(idx, 1); else list.push(day); provider.recurring_days_off = list; }, async addBlock(provider) { const b = this.prov.newBlock; const payload = { date: b.date, all_day: this.prov.newBlockType === "All Day", start_time: this.prov.newBlockType === "Specific Time" ? b.start_time : null, end_time: this.prov.newBlockType === "Specific Time" ? b.end_time : null, title: b.title || "Unavailable", }; try { await API.post(`/api/providers/${provider.id}/days-off`, payload); this.showToast("Unavailable block added", "success"); this.prov.newBlock = { title: "", date: b.date, start_time: "08:00", end_time: "12:00" }; await this.loadProviders(); } catch (e) { this.showToast(e.message, "error"); } }, async removeBlock(providerId, index) { try { await API.del(`/api/providers/${providerId}/days-off/${index}`); this.showToast("Block removed", "success"); await this.loadProviders(); } catch (e) { this.showToast(e.message, "error"); } }, async addOnCall(provider) { if (!this.prov.newOnCallDate) return; try { await API.post(`/api/providers/${provider.id}/on-call`, { date: this.prov.newOnCallDate }); this.showToast("On-call day added", "success"); await this.loadProviders(); } catch (e) { this.showToast(e.message, "error"); } }, async toggleOnCallDate(provider, date) { const isOn = (provider.on_call || []).includes(date); try { await API.post(`/api/providers/${provider.id}/on-call/toggle`, { date, on_call: !isOn }); this.showToast(isOn ? "On-call removed" : "On-call set", "success"); await this.loadProviders(); } catch (e) { this.showToast(e.message, "error"); } }, async removeOnCall(providerId, date) { try { await API.del(`/api/providers/${providerId}/on-call/${date}`); this.showToast("On-call day removed", "success"); await this.loadProviders(); } catch (e) { this.showToast(e.message, "error"); } }, formatBlock(off) { if (typeof off === "string") return `${off} — All Day`; if (off.all_day) return `${off.date} — All Day — ${off.title || "Unavailable"}`; return `${off.date} ${off.start_time}–${off.end_time} — ${off.title || "Unavailable"}`; }, // Compact summary like "May 30" (all day) or "May 30 9:00 AM–12:00 PM". formatBlockShort(off) { if (typeof off === "string") return fmt.dateShort(off); const d = fmt.dateShort(off.date); if (off.all_day) return d; return `${d} ${fmt.time(off.start_time)}–${fmt.time(off.end_time)}`; }, // Returns {block, originalIndex} pairs for blocks whose date is today or later. // Preserves original index so removeBlock still targets the right sheet row. upcomingBlocks(provider) { const today = fmt.today(); const out = []; (provider.days_off || []).forEach((off, i) => { const d = typeof off === "string" ? off : off?.date; if (d && d >= today) out.push({ block: off, originalIndex: i }); }); return out; }, // ============================================================ // PATIENTS view // ============================================================ openAddPatient() { this.pat.editingId = null; this.patForm = { name: "", email: "", address: "", notes: "" }; this.pat.showAdd = true; }, openEditPatient(p) { this.pat.editingId = p.id; this.patForm = { name: p.name || "", email: p.email || "", address: p.address || "", notes: p.notes || "" }; this.pat.showAdd = true; }, async savePatient() { this.loading = true; try { if (this.pat.editingId) { await API.put(`/api/patients/${this.pat.editingId}`, this.patForm); this.showToast("Patient updated", "success"); } else { await API.post("/api/patients", this.patForm); this.showToast("Patient added", "success"); } this.pat.showAdd = false; await this.loadPatients(); } catch (e) { this.showToast(e.message, "error"); } finally { this.loading = false; } }, async deletePatient(id) { try { await API.del(`/api/patients/${id}`); this.showToast("Patient deleted", "success"); this.pat.confirmDelete = null; await this.loadPatients(); } catch (e) { this.showToast(e.message, "error"); } }, // ============================================================ // CALENDAR view // ============================================================ async mountCalendar() { const el = document.getElementById("calendar-root"); if (!el || typeof FullCalendar === "undefined") return; this.cal.providerLegend = this.providers.map(p => ({ id: p.id, name: p.name })); if (this.cal.selectedProviderIds.length === 0) { this.cal.selectedProviderIds = this.providers.map(p => p.id); } this.cal.instance = new FullCalendar.Calendar(el, { initialView: "timeGridWeek", headerToolbar: { left: "prev,next today", center: "title", right: "dayGridMonth,timeGridWeek,timeGridDay,listWeek" }, height: 720, slotMinTime: "00:00:00", slotMaxTime: "24:00:00", scrollTime: "07:00:00", nowIndicator: true, now: () => this.platformNow(), events: (info, success, failure) => this.fetchCalEvents(info, success, failure), eventClick: (info) => { const p = info.event.extendedProps; if (p.type === "appointment" || p.type === "days_off" || p.type === "on_call") { this.cal.selectedEvent = { ...p, eventStart: info.event.start, eventEnd: info.event.end, title: info.event.title }; } }, }); this.cal.instance.render(); }, async fetchCalEvents(info, success, failure) { try { const params = new URLSearchParams({ start: info.startStr, end: info.endStr, show_unavailable: this.cal.showUnavailable ? "true" : "false", show_travel: this.cal.showTravel ? "true" : "false", show_oncall: this.cal.showOnCall ? "true" : "false", }); if (this.cal.selectedProviderIds.length) params.set("provider_ids", this.cal.selectedProviderIds.join(",")); const res = await API.get(`/api/calendar/events?${params.toString()}`); this.cal.providerLegend = res.providers || []; success(res.events || []); } catch (e) { failure(e); this.showToast(e.message, "error"); } }, toggleCalProvider(id) { const i = this.cal.selectedProviderIds.indexOf(id); if (i >= 0) this.cal.selectedProviderIds.splice(i, 1); else this.cal.selectedProviderIds.push(id); if (this.cal.instance) this.cal.instance.refetchEvents(); }, toggleAllProviders() { if (this.cal.selectedProviderIds.length === this.providers.length) { this.cal.selectedProviderIds = []; } else { this.cal.selectedProviderIds = this.providers.map(p => p.id); } if (this.cal.instance) this.cal.instance.refetchEvents(); }, refetchCalendar() { if (this.cal.instance) this.cal.instance.refetchEvents(); }, // ============================================================ // AUDIT // ============================================================ async loadAudit() { try { this.audit.actionTypes = await API.get("/api/audit/actions"); } catch (e) {} await this.refreshAudit(); }, async refreshAudit() { try { const params = new URLSearchParams(); if (this.audit.search) params.set("search", this.audit.search); if (this.audit.action && this.audit.action !== "All") params.set("action", this.audit.action); this.audit.entries = await API.get(`/api/audit?${params.toString()}`); } catch (e) { this.showToast(e.message, "error"); } }, // ============================================================ // PROVIDER PORTAL // ============================================================ async loadPortal() { if (!this.providerId) return; try { this.portal.self = await API.get(`/api/providers/${this.providerId}`); this.portal.editForm = { email: this.portal.self.email || "", address: this.portal.self.address || "", work_hours: { ...(this.portal.self.work_hours || { start: "08:00", end: "17:00" }) }, lunch_break: { ...(this.portal.self.lunch_break || { start: "12:00", end: "13:00" }) }, recurring_days_off: [...(this.portal.self.recurring_days_off || [])], }; if (this.portal.tab === "schedule") { await this.$nextTick(); this.mountPortalCalendar(); } } catch (e) { this.showToast(e.message, "error"); } }, async savePortalProfile() { if (!this.providerId) return; try { await API.put(`/api/providers/${this.providerId}`, { email: this.portal.editForm.email, address: this.portal.editForm.address, work_hours: this.portal.editForm.work_hours, lunch_break: this.portal.editForm.lunch_break, }); this.showToast("Profile updated", "success"); await this.loadPortal(); } catch (e) { this.showToast(e.message, "error"); } }, togglePortalRecurringDay(day) { const list = this.portal.editForm.recurring_days_off; const i = list.indexOf(day); if (i >= 0) list.splice(i, 1); else list.push(day); }, async savePortalRecurring() { try { await API.put(`/api/providers/${this.providerId}/recurring-days-off`, { recurring_days_off: this.portal.editForm.recurring_days_off }); this.showToast("Standing days off updated", "success"); await this.loadPortal(); } catch (e) { this.showToast(e.message, "error"); } }, async addPortalBlock() { const b = this.portal.newBlock; const payload = { date: b.date, all_day: this.portal.newBlockType === "All Day", start_time: this.portal.newBlockType === "Specific Time" ? b.start_time : null, end_time: this.portal.newBlockType === "Specific Time" ? b.end_time : null, title: b.title || "Unavailable", }; try { await API.post(`/api/providers/${this.providerId}/days-off`, payload); this.showToast("Block added", "success"); this.portal.newBlock = { title: "", date: b.date, start_time: "08:00", end_time: "12:00" }; await this.loadPortal(); } catch (e) { this.showToast(e.message, "error"); } }, async removePortalBlock(index) { try { await API.del(`/api/providers/${this.providerId}/days-off/${index}`); this.showToast("Block removed", "success"); await this.loadPortal(); } catch (e) { this.showToast(e.message, "error"); } }, async addPortalOnCall() { if (!this.portal.newOnCallDate) return; try { await API.post(`/api/providers/${this.providerId}/on-call`, { date: this.portal.newOnCallDate }); this.showToast("On-call day added", "success"); await this.loadPortal(); } catch (e) { this.showToast(e.message, "error"); } }, async togglePortalOnCall(date) { const isOn = (this.portal.self?.on_call || []).includes(date); try { await API.post(`/api/providers/${this.providerId}/on-call/toggle`, { date, on_call: !isOn }); this.showToast(isOn ? "On-call removed" : "On-call set", "success"); await this.loadPortal(); } catch (e) { this.showToast(e.message, "error"); } }, async removePortalOnCall(date) { try { await API.del(`/api/providers/${this.providerId}/on-call/${date}`); this.showToast("Removed", "success"); await this.loadPortal(); } catch (e) { this.showToast(e.message, "error"); } }, async switchPortalTab(tab) { this.portal.tab = tab; if (this.portal.calendar) { this.portal.calendar.destroy(); this.portal.calendar = null; } if (tab === "schedule") { await this.$nextTick(); this.mountPortalCalendar(); } }, mountPortalCalendar() { const el = document.getElementById("portal-calendar-root"); if (!el || typeof FullCalendar === "undefined") return; this.portal.calendar = new FullCalendar.Calendar(el, { initialView: "timeGridWeek", headerToolbar: { left: "prev,next today", center: "title", right: "dayGridMonth,timeGridWeek,timeGridDay,listWeek" }, height: 720, slotMinTime: "00:00:00", slotMaxTime: "24:00:00", scrollTime: "07:00:00", nowIndicator: true, now: () => this.platformNow(), events: async (info, success, failure) => { try { const params = new URLSearchParams({ start: info.startStr, end: info.endStr, show_unavailable: "true", show_travel: "true" }); const res = await API.get(`/api/calendar/events?${params.toString()}`); success(res.events || []); } catch (e) { failure(e); } }, eventClick: (info) => { const p = info.event.extendedProps; this.portal.selectedEvent = { ...p, eventStart: info.event.start, eventEnd: info.event.end, title: info.event.title }; }, }); this.portal.calendar.render(); }, formatTime: (t) => fmt.time(t), formatDate: (d) => fmt.date(d), formatDateShort: (d) => fmt.dateShort(d), formatTs(ts) { if (!ts) return ""; const d = new Date(ts); return d.toLocaleString(); }, }; } window.appState = appState;