scheduler / static /js /app.js
umangchaudhry's picture
Upload 31 files
0d04b76 verified
Raw
History Blame Contribute Delete
49.9 kB
// 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;