// schedule.jsx — Day / Week / Month views wired to /api/appointments.
const { useState: useStateS, useMemo: useMemoS, useEffect: useEffectS } = React;
function startOfDay(d) {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}
function startOfWeek(d) {
// ISO-style: week starts on Monday.
const x = startOfDay(d);
const day = x.getDay(); // 0..6, Sun..Sat
const diff = (day === 0 ? -6 : 1 - day);
x.setDate(x.getDate() + diff);
return x;
}
function startOfMonth(d) {
const x = startOfDay(d);
x.setDate(1);
return x;
}
function addDays(d, n) {
const x = new Date(d);
x.setDate(x.getDate() + n);
return x;
}
function sameDay(a, b) {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
function dateInputValue(d) {
const x = d ? new Date(d) : new Date();
if (isNaN(x)) return "";
const yyyy = x.getFullYear();
const mm = String(x.getMonth() + 1).padStart(2, "0");
const dd = String(x.getDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
function timeInputValue(d) {
const x = d ? new Date(d) : new Date();
if (isNaN(x)) return "09:00";
return `${String(x.getHours()).padStart(2, "0")}:${String(x.getMinutes()).padStart(2, "0")}`;
}
function slotDateFromPointer(baseDay, event, hours) {
const rect = event.currentTarget.getBoundingClientRect();
const y = Math.max(0, Math.min(rect.height, event.clientY - rect.top));
const minutesFromStart = Math.round((y / (64 / 60)) / 15) * 15;
const startHour = hours && hours.length ? hours[0] : 0;
const maxMinutes = Math.max(0, ((hours && hours.length ? hours.length : 24) * 60) - 15);
const clamped = Math.min(maxMinutes, minutesFromStart);
const next = new Date(baseDay);
next.setHours(startHour, 0, 0, 0);
next.setMinutes(next.getMinutes() + clamped);
return next;
}
function Schedule({ onOpenEncounter, initialPatient }) {
const store = window.MSStore.useStore();
const t = window.MSStore.useT();
const [view, setView] = useStateS("day");
const [providerFilter, setProviderFilter] = useStateS("all");
const [cursor, setCursor] = useStateS(() => startOfDay(new Date()));
const [bookingOpen, setBookingOpen] = useStateS(false);
const [bookingDraft, setBookingDraft] = useStateS(null);
const account = store.activeAccount || {};
const bizStart = account.business_start_hour != null ? account.business_start_hour : 9;
const bizEnd = account.business_end_hour != null ? account.business_end_hour : 18;
const HOURS = Array.from({length: 24}, (_, i) => i); // 24-hour calendar
const providers = (store.providers || []);
const apps = (store.appointments || []).filter(a => providerFilter === "all" || a.providerId === providerFilter);
const visibleProviders = providers.filter(p => providerFilter === "all" || p.id === providerFilter);
function shiftCursor(delta) {
const next = new Date(cursor);
if (view === "day") next.setDate(next.getDate() + delta);
else if (view === "week") next.setDate(next.getDate() + delta * 7);
else next.setMonth(next.getMonth() + delta);
if (view === "week") setCursor(startOfWeek(next));
else if (view === "month") setCursor(startOfMonth(next));
else setCursor(startOfDay(next));
}
function goToday() {
const today = new Date();
if (view === "week") setCursor(startOfWeek(today));
else if (view === "month") setCursor(startOfMonth(today));
else setCursor(startOfDay(today));
}
function setViewKeepAnchor(next) {
setView(next);
if (next === "week") setCursor(c => startOfWeek(c));
else if (next === "month") setCursor(c => startOfMonth(c));
else setCursor(c => startOfDay(c));
}
function openBooking(draft) {
setBookingDraft(draft || null);
setBookingOpen(true);
}
useEffectS(() => {
if (!initialPatient) return;
openBooking({ patient: initialPatient });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialPatient && initialPatient.id]);
// Current header label / sub
let titleDate, headerSub;
if (view === "day") {
titleDate = cursor.toLocaleDateString(window.MSi18n && window.MSi18n.getLang() === "es" ? "es-ES" : "en-US", { month: "long", day: "numeric", year: "numeric" });
headerSub = cursor.toLocaleDateString(window.MSi18n && window.MSi18n.getLang() === "es" ? "es-ES" : "en-US", { weekday: "long", month: "long", day: "numeric" });
} else if (view === "week") {
const end = addDays(cursor, 6);
titleDate = `${cursor.toLocaleDateString(window.MSi18n && window.MSi18n.getLang() === "es" ? "es-ES" : "en-US", { month: "short", day: "numeric" })} – ${end.toLocaleDateString(window.MSi18n && window.MSi18n.getLang() === "es" ? "es-ES" : "en-US", { month: "short", day: "numeric", year: "numeric" })}`;
headerSub = t("sched.weekOf", { date: cursor.toLocaleDateString(window.MSi18n && window.MSi18n.getLang() === "es" ? "es-ES" : "en-US", { weekday: "long", month: "long", day: "numeric" }) });
} else {
titleDate = cursor.toLocaleDateString(window.MSi18n && window.MSi18n.getLang() === "es" ? "es-ES" : "en-US", { month: "long", year: "numeric" });
headerSub = titleDate;
}
// Day-bucket appointments (used by week + day for counts)
const dayApps = apps.filter(a => {
const t = new Date(a.scheduledAt);
if (isNaN(t)) return false;
if (view === "day") return sameDay(t, cursor);
if (view === "week") return t >= cursor && t < addDays(cursor, 7);
return t >= cursor && t < new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1);
});
return (
{t("sched.title")}
{headerSub} · {t("sched.sub", { n: dayApps.length })}
{titleDate}
{providers.map(p => (
))}
{visibleProviders.length === 0 ? (
) : view === "day" ? (
) : view === "week" ? (
) : (
{ setView("day"); setCursor(startOfDay(d)); }}/>
)}
{bookingOpen && (
{ setBookingOpen(false); setBookingDraft(null); }}
/>
)}
);
}
function BookingDrawer({ cursor, providers, initialDraft, onClose }) {
const store = window.MSStore.useStore();
const t = window.MSStore.useT();
const draftDate = initialDraft && initialDraft.scheduledAt ? new Date(initialDraft.scheduledAt) : null;
const initialDate = dateInputValue(draftDate || cursor || new Date());
const defaultProvider = (providers && providers[0] && providers[0].id) || "";
const initialPatient = initialDraft && initialDraft.patient;
const initialPatientId = initialPatient && initialPatient.id || "";
const [patientQuery, setPatientQuery] = useStateS(initialPatient ? `${initialPatient.first || ""} ${initialPatient.last || ""}`.trim() : "");
const [patientOptions, setPatientOptions] = useStateS([]);
const [patientLoading, setPatientLoading] = useStateS(false);
const [form, setForm] = useStateS({
patientMode: initialPatient ? "existing" : "existing",
selectedPatientId: initialPatientId,
patientName: initialPatient ? `${initialPatient.first || ""} ${initialPatient.last || ""}`.trim() : "",
patientPhone: initialPatient && initialPatient.phone || "",
patientEmail: initialPatient && initialPatient.email || "",
date: initialDate,
time: draftDate ? timeInputValue(draftDate) : "09:00",
duration: "30",
providerId: (initialDraft && initialDraft.providerId) || defaultProvider,
reason: "",
notes: "",
});
const [pending, setPending] = useStateS(false);
const [error, setError] = useStateS("");
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
function applySelectedPatient(patientId) {
const p = patientOptions.find(p => (p.id || p.name) === patientId);
const name = p ? (p.name || `${p.first || ""} ${p.last || ""}`.trim()) : "";
setForm(f => ({
...f,
selectedPatientId: patientId,
patientName: name,
patientPhone: p ? (p.phone || "") : "",
patientEmail: p ? (p.email || "") : "",
}));
if (name) setPatientQuery(name);
}
useEffectS(() => {
let cancelled = false;
setPatientLoading(true);
store.searchCalendarPatients(patientQuery).then(rows => {
if (cancelled) return;
setPatientOptions(Array.isArray(rows) ? rows : []);
}).catch(() => {
if (!cancelled) setPatientOptions([]);
}).finally(() => {
if (!cancelled) setPatientLoading(false);
});
return () => { cancelled = true; };
}, [patientQuery, store.version]);
useEffectS(() => {
if (!form.providerId && providers && providers[0]) {
set("providerId", providers[0].id);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [providers && providers.length]);
async function submit() {
if (pending) return;
const patientName = form.patientName.trim();
if (!patientName) {
setError(t("sched.errors.patient"));
return;
}
if (!form.date || !form.time) {
setError(t("sched.errors.time"));
return;
}
setPending(true);
setError("");
try {
const scheduledAt = new Date(`${form.date}T${form.time}:00`).toISOString();
const assignedProvider = form.providerId || (providers && providers[0] && providers[0].id) || "";
await store.createAppointment({
patient_name: patientName,
patient_phone: form.patientPhone.trim(),
patient_email: form.patientEmail.trim(),
doctor_id: assignedProvider,
scheduled_at: scheduledAt,
duration_minutes: Number(form.duration) || 30,
reason: form.reason.trim(),
notes: form.notes.trim(),
status: "confirmed",
});
onClose();
} catch (e) {
setError(e && e.message || t("sched.errors.create"));
} finally {
setPending(false);
}
}
return (
e.target === e.currentTarget && onClose()}>
{t("sched.bookTitle")}
{form.patientMode === "existing" ? (
{ setPatientQuery(e.target.value); setForm(f => ({...f, selectedPatientId: "", patientName: ""})); }} placeholder="Search patients from database"/>
{!patientLoading && patientQuery.trim() && patientOptions.length === 0 && (
)}
) : (
set("patientName", e.target.value)} placeholder={t("sched.patientNamePh")}/>
)}
set("reason", e.target.value)} placeholder={t("sched.reasonPh")}/>
{error &&
{error}
}
);
}
// ── Day view ────────────────────────────────────────────────────────────────
function timeToY(date) {
const d = new Date(date);
const minsFromMidnight = d.getHours() * 60 + d.getMinutes();
return Math.max(0, minsFromMidnight) * (64 / 60);
}
function DayGrid({ cursor, apps, providers, hours, bizStart, bizEnd, onOpenEncounter, onSlotSelect }) {
const t = window.MSStore.useT();
const isToday = sameDay(startOfDay(new Date()), cursor);
const bodyRef = React.useRef(null);
React.useEffect(() => {
if (bodyRef.current) {
// Scroll to 1 hour before business start
const scrollTo = Math.max(0, (bizStart - 1)) * 64;
bodyRef.current.scrollTop = scrollTo;
}
}, [bizStart]);
return (
{providers.map(p => (
))}
{hours.map(h => (
= bizEnd ? " is-off-hours" : "")}>
{h === 0 ? "12 AM" : h < 12 ? h + " AM" : h === 12 ? "12 PM" : (h - 12) + " PM"}
))}
{providers.map(p => (
onSlotSelect && onSlotSelect({ scheduledAt: slotDateFromPointer(cursor, e, hours), providerId: p.id })}
>
{hours.map(h =>
= bizEnd ? " is-off-hours" : "")}/>)}
{isToday &&
}
{apps.filter(a => a.providerId === p.id || (!a.providerId && p.id === providers[0]?.id)).map(a => {
const top = timeToY(a.scheduledAt);
const height = (a.duration || 30) * (64 / 60) - 2;
const isCurrent = a.status === "in-room";
return (
);
})}
))}
);
}
// ── Week view ────────────────────────────────────────────────────────────────
function WeekGrid({ cursor, apps, providers, hours, bizStart, bizEnd, onOpenEncounter, onSlotSelect }) {
const t = window.MSStore.useT();
const days = Array.from({length: 7}, (_, i) => addDays(cursor, i));
const today = startOfDay(new Date());
const lang = (window.MSi18n && window.MSi18n.getLang()) || "en";
const localeTag = lang === "es" ? "es-ES" : "en-US";
const provById = {};
for (const p of providers) provById[p.id] = p;
const weekBodyRef = React.useRef(null);
React.useEffect(() => {
if (weekBodyRef.current) {
weekBodyRef.current.scrollTop = Math.max(0, (bizStart - 1)) * 64;
}
}, [bizStart]);
return (
{/* Weekday header */}
{days.map(d => {
const isToday = sameDay(d, today);
return (
{d.toLocaleDateString(localeTag, { weekday: "short" })}
{d.getDate()}
);
})}
{/* Body: time gutter + 7 day columns */}
{hours.map(h => (
= bizEnd ? " is-off-hours" : "")}>
{h === 0 ? "12 AM" : h < 12 ? h + " AM" : h === 12 ? "12 PM" : (h - 12) + " PM"}
))}
{days.map(d => {
const dayStart = startOfDay(d);
const dayEnd = addDays(dayStart, 1);
const dayApps = apps.filter(a => {
const t = new Date(a.scheduledAt);
return !isNaN(t) && t >= dayStart && t < dayEnd;
});
const isToday = sameDay(d, today);
return (
onSlotSelect && onSlotSelect({ scheduledAt: slotDateFromPointer(dayStart, e, hours) })}
>
{hours.map(h =>
= bizEnd ? " is-off-hours" : "")}/>)}
{isToday &&
}
{dayApps.map(a => {
const top = timeToY(a.scheduledAt);
const height = Math.max(22, (a.duration || 30) * (64 / 60) - 2);
const provider = provById[a.providerId];
const color = (provider && provider.color) || "var(--primary)";
return (
);
})}
);
})}
);
}
// ── Month view ──────────────────────────────────────────────────────────────
function MonthGrid({ cursor, apps, providers, onOpenEncounter, onJumpToDay }) {
const t = window.MSStore.useT();
const lang = (window.MSi18n && window.MSi18n.getLang()) || "en";
const localeTag = lang === "es" ? "es-ES" : "en-US";
// 6 rows * 7 columns = 42 cells starting from the Monday of the week that
// contains the 1st of the month.
const monthStart = startOfMonth(cursor);
const gridStart = startOfWeek(monthStart);
const today = startOfDay(new Date());
const cells = Array.from({length: 42}, (_, i) => addDays(gridStart, i));
// Bucket appointments by ISO date.
const buckets = {};
for (const a of apps) {
const t = new Date(a.scheduledAt);
if (isNaN(t)) continue;
const key = startOfDay(t).toISOString().slice(0, 10);
(buckets[key] ||= []).push(a);
}
const provById = {};
for (const p of providers) provById[p.id] = p;
// Build localized weekday short labels (Mon-first), via the browser locale.
const headerLabels = (() => {
const out = [];
const ref = startOfWeek(new Date());
for (let i = 0; i < 7; i++) {
out.push(addDays(ref, i).toLocaleDateString(localeTag, { weekday: "short" }));
}
return out;
})();
return (
{headerLabels.map(l =>
{l}
)}
{cells.map(d => {
const inMonth = d.getMonth() === monthStart.getMonth();
const isToday = sameDay(d, today);
const key = d.toISOString().slice(0, 10);
const dayApps = (buckets[key] || []).slice().sort((a, b) => new Date(a.scheduledAt) - new Date(b.scheduledAt));
return (
);
})}
);
}
window.Schedule = Schedule;