Spaces:
Runtime error
Runtime error
| // 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; | |