diff --git a/frontend/src/app/(dashboard)/appointments/page.tsx b/frontend/src/app/(dashboard)/appointments/page.tsx index 1f6a85b..f66647d 100644 --- a/frontend/src/app/(dashboard)/appointments/page.tsx +++ b/frontend/src/app/(dashboard)/appointments/page.tsx @@ -14,7 +14,8 @@ import { AppointmentScheduleGrid } from '@/components/ui/appointments/Appointmen import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch'; import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend'; import { ScheduleDayPicker } from '@/components/ui/common/ScheduleDayPicker'; -import { Toast } from '@/components/ui/common/Toast'; +import { ToastStack } from '@/components/ui/common/Toast'; +import { useToast } from '@/lib/hooks/useToast'; import type { AppointmentPurpose } from '@/types/appointment'; import { formatApiErrorMessage } from '@/lib/formatApiError'; import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/lib/appointmentTime'; @@ -33,7 +34,7 @@ export default function AppointmentsPage() { const [providers, setProviders] = useState([]); const [appointments, setAppointments] = useState([]); const [loadingSchedule, setLoadingSchedule] = useState(false); - const [scheduleError, setScheduleError] = useState(''); + const toast = useToast(); const [search, setSearch] = useState(''); const [patients, setPatients] = useState([]); @@ -51,9 +52,6 @@ export default function AppointmentsPage() { const [editingAppointmentId, setEditingAppointmentId] = useState(null); const [savingAppointment, setSavingAppointment] = useState(false); - const [toastError, setToastError] = useState(''); - const [toastSuccess, setToastSuccess] = useState(''); - const [toastInfo, setToastInfo] = useState(''); const canManageAppointments = canEditAppointments(currentOrganization); const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); @@ -84,7 +82,7 @@ export default function AppointmentsPage() { } const gen = ++scheduleLoadGen.current; setLoadingSchedule(true); - setScheduleError(''); + toast.setError(''); try { const range = getLocalDayIsoRange(scheduleDate); const [pRes, aRes] = await Promise.all([ @@ -100,7 +98,7 @@ export default function AppointmentsPage() { if (gen !== scheduleLoadGen.current) { return; } - setScheduleError(formatApiErrorMessage(err, 'Failed to load schedule.')); + toast.showError(formatApiErrorMessage(err, 'Failed to load schedule.')); } finally { if (gen === scheduleLoadGen.current) { setLoadingSchedule(false); @@ -143,21 +141,20 @@ export default function AppointmentsPage() { async function handleCreatePatient() { setSavingPatient(true); - setToastError(''); - setToastSuccess(''); + toast.setError(''); try { const response = await patientsApi.create(patientForm); setIsCreateOpen(false); setPatientForm(EMPTY_PATIENT_FORM); await loadPatientsSearch(search); setSelectedPatient(response.data); - setToastSuccess(`Patient ${response.data.firstName} ${response.data.lastName} was saved.`); + toast.showSuccess(`Patient ${response.data.firstName} ${response.data.lastName} was saved.`); } catch (err: unknown) { const message = err && typeof err === 'object' && 'message' in err ? String((err as { message: unknown }).message) : 'Failed to save patient.'; - setToastError(message); + toast.showError(message); } finally { setSavingPatient(false); } @@ -165,15 +162,11 @@ export default function AppointmentsPage() { function handleSlotClick(hour: number, providerUserId: string, providerName: string) { if (isViewingPastDay) { - setToastSuccess(''); - setToastError(''); - setToastInfo('Past appointments are view-only.'); + toast.showInfo('Past appointments are view-only.'); return; } if (!selectedPatient) { - setToastSuccess(''); - setToastError(''); - setToastInfo('Select a patient before booking.'); + toast.showInfo('Select a patient before booking.'); return; } setBookingHour(hour); @@ -185,9 +178,7 @@ export default function AppointmentsPage() { function handleAppointmentClick(appointment: AppointmentRecord) { if (isViewingPastDay) { - setToastSuccess(''); - setToastError(''); - setToastInfo('Past appointments are view-only.'); + toast.showInfo('Past appointments are view-only.'); return; } const provider = providers.find((p) => p.userId === appointment.providerUserId); @@ -206,9 +197,7 @@ export default function AppointmentsPage() { purpose: AppointmentPurpose; }) { setSavingAppointment(true); - setToastError(''); - setToastSuccess(''); - setToastInfo(''); + toast.setError(''); try { if (activeEditingAppointment) { await appointmentsApi.update(activeEditingAppointment.id, payload); @@ -217,7 +206,7 @@ export default function AppointmentsPage() { } setBookingOpen(false); setEditingAppointmentId(null); - setToastSuccess(activeEditingAppointment ? 'Appointment updated.' : 'Appointment saved.'); + toast.showSuccess(activeEditingAppointment ? 'Appointment updated.' : 'Appointment saved.'); await loadSchedule(); } catch (err: unknown) { const message = @@ -226,7 +215,7 @@ export default function AppointmentsPage() { : activeEditingAppointment ? 'Could not update appointment.' : 'Could not save appointment.'; - setToastError(message); + toast.showError(message); } finally { setSavingAppointment(false); } @@ -236,57 +225,33 @@ export default function AppointmentsPage() { if (!window.confirm('Remove this appointment?')) { return; } - setToastError(''); - setToastSuccess(''); - setToastInfo(''); + toast.setError(''); try { await appointmentsApi.remove(id); - setToastSuccess('Appointment removed.'); + toast.showSuccess('Appointment removed.'); await loadSchedule(); } catch (err: unknown) { const message = err && typeof err === 'object' && 'message' in err ? String((err as { message: unknown }).message) : 'Could not delete appointment.'; - setToastError(message); + toast.showError(message); } } - useEffect(() => { - if (!toastSuccess) { - return; - } - const id = setTimeout(() => setToastSuccess(''), 3200); - return () => clearTimeout(id); - }, [toastSuccess]); - - useEffect(() => { - if (!toastError) { - return; - } - const id = setTimeout(() => setToastError(''), 4000); - return () => clearTimeout(id); - }, [toastError]); - - useEffect(() => { - if (!toastInfo) { - return; - } - const id = setTimeout(() => setToastInfo(''), 4000); - return () => clearTimeout(id); - }, [toastInfo]); - return ( -
+
+
+

Appointments

+

+ Search a patient, pick a date, then click a time slot under a provider to book. +

+
+ + +
-
-

Appointments

-

- Search a patient, pick a date, then click a time slot under a provider to book. -

-
- )} - {(scheduleError || toastError || toastSuccess || toastInfo) && ( -
-
- {scheduleError && {scheduleError}} - {toastError && {toastError}} - {toastInfo && {toastInfo}} - {toastSuccess && {toastSuccess}} -
-
- )}
); } diff --git a/frontend/src/components/ui/common/ScheduleDayPicker.tsx b/frontend/src/components/ui/common/ScheduleDayPicker.tsx index e174d21..99518f5 100644 --- a/frontend/src/components/ui/common/ScheduleDayPicker.tsx +++ b/frontend/src/components/ui/common/ScheduleDayPicker.tsx @@ -1,48 +1,299 @@ 'use client'; -import { ChevronLeft, ChevronRight } from 'lucide-react'; -import { addCalendarDays } from '@/lib/appointmentTime'; +import { useEffect, useId, useRef, useState } from 'react'; +import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react'; +import { + addCalendarDays, + compareLocalDayStart, + startOfLocalDay, +} from '@/lib/appointmentTime'; interface ScheduleDayPickerProps { value: Date; onChange: (day: Date) => void; - /** Optional lower bound; picker navigation is unrestricted for history browsing. */ + /** Optional lower bound for day selection and previous-day navigation. */ minDate?: Date; label?: string; } -export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }: ScheduleDayPickerProps) { - const labelText = value.toLocaleDateString(undefined, { +const MONTH_LABELS = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +] as const; + +function daysInMonth(year: number, month: number): number { + return new Date(year, month + 1, 0).getDate(); +} + +function buildLocalDay(year: number, month: number, day: number): Date { + return new Date(year, month, day, 0, 0, 0, 0); +} + +function clampToValidDay( + year: number, + month: number, + day: number, + min?: Date, +): Date { + const maxDay = daysInMonth(year, month); + let next = buildLocalDay(year, month, Math.min(Math.max(1, day), maxDay)); + if (min) { + const floor = startOfLocalDay(min); + if (compareLocalDayStart(next, floor) < 0) { + next = floor; + } + } + return next; +} + +function yearRange(min?: Date, anchor?: Date): number[] { + const now = new Date(); + const startYear = min ? min.getFullYear() : now.getFullYear() - 5; + const endYear = Math.max(now.getFullYear() + 2, anchor?.getFullYear() ?? now.getFullYear()); + const years: number[] = []; + for (let y = startYear; y <= endYear; y += 1) { + years.push(y); + } + return years; +} + +const selectClassName = ` + w-full appearance-none rounded-[var(--radius-sm)] border border-border + bg-background-card/90 text-text-primary text-sm + pl-2 pr-7 py-1.5 + focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong + disabled:opacity-50 disabled:cursor-not-allowed +`; + +export function ScheduleDayPicker({ + value, + onChange, + minDate, + label = 'Schedule date', +}: ScheduleDayPickerProps) { + const panelId = useId(); + const rootRef = useRef(null); + const [panelOpen, setPanelOpen] = useState(false); + + const normalizedValue = startOfLocalDay(value); + const normalizedMin = minDate ? startOfLocalDay(minDate) : undefined; + + const labelText = normalizedValue.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', }); + const previousDay = addCalendarDays(normalizedValue, -1); + const canGoPrevious = + !normalizedMin || compareLocalDayStart(previousDay, normalizedMin) >= 0; + + const years = yearRange(normalizedMin, normalizedValue); + const selectedYear = normalizedValue.getFullYear(); + const selectedMonth = normalizedValue.getMonth(); + const selectedDay = normalizedValue.getDate(); + const dayCount = daysInMonth(selectedYear, selectedMonth); + + function applyParts(year: number, month: number, day: number, closePanel = false) { + onChange(clampToValidDay(year, month, day, normalizedMin)); + if (closePanel) { + setPanelOpen(false); + } + } + + function handlePreviousDay() { + if (!canGoPrevious) return; + onChange(previousDay); + } + + useEffect(() => { + if (!panelOpen) return; + + function onPointerDown(event: MouseEvent) { + if (!rootRef.current?.contains(event.target as Node)) { + setPanelOpen(false); + } + } + + function onKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + setPanelOpen(false); + } + } + + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [panelOpen]); + return ( -
+

{label}

-
- {labelText} -
+ + +
+ + {panelOpen && ( + + )}
); }