// Appointments v2 — lean table + detail side panel + reschedule + reminder import { useEffect, useMemo, useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faMagnifyingGlass, faPenToSquare, faEye, faBell, faXmark, faCalendarCheck, faUserDoctor, faBuilding, faStethoscope, faNotesMedical, } from '@fortawesome/pro-duotone-svg-icons'; import { faIcon } from '@/lib/icon-wrapper'; const SearchLg = faIcon(faMagnifyingGlass); import { Badge } from '@/components/base/badges/badges'; import { Input } from '@/components/base/input/input'; import { Table } from '@/components/application/table/table'; import { PaginationCardDefault } from '@/components/application/pagination/pagination'; // TopBar replaced by inline header import { Button } from '@/components/base/buttons/button'; import { ModalOverlay, Modal, Dialog } from '@/components/application/modals/modal'; import { Select } from '@/components/base/select/select'; import { DatePicker } from '@/components/application/date-picker/date-picker'; import { parseDate, today, getLocalTimeZone } from '@internationalized/date'; import { PhoneActionCell } from '@/components/call-desk/phone-action-cell'; import { PageHeader } from '@/components/layout/page-header'; import { formatPhone, formatDateOnly, formatTimeOnly } from '@/lib/format'; import { apiClient } from '@/lib/api-client'; import { notify } from '@/lib/toast'; import { cx } from '@/utils/cx'; type AppointmentRecord = { id: string; scheduledAt: string | null; durationMin: number | null; appointmentType: string | null; status: string | null; doctorName: string | null; department: string | null; reasonForVisit: string | null; patient: { id: string; fullName: { firstName: string; lastName: string } | null; phones: { primaryPhoneNumber: string } | null; } | null; clinic: { id?: string; clinicName: string; } | null; doctor: { id: string; fullName?: { firstName: string; lastName: string } | null; } | null; }; type StatusTab = 'all' | 'SCHEDULED' | 'COMPLETED' | 'CANCELLED' | 'RESCHEDULED'; const STATUS_COLORS: Record = { SCHEDULED: 'brand', CONFIRMED: 'brand', COMPLETED: 'success', CANCELLED: 'error', NO_SHOW: 'warning', RESCHEDULED: 'warning', }; const STATUS_LABELS: Record = { SCHEDULED: 'Booked', CONFIRMED: 'Confirmed', COMPLETED: 'Completed', CANCELLED: 'Cancelled', NO_SHOW: 'No Show', RESCHEDULED: 'Rescheduled', }; const QUERY = `{ appointments(first: 200, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { id scheduledAt durationMin appointmentType status doctorName department reasonForVisit patient { id fullName { firstName lastName } phones { primaryPhoneNumber } } clinic { id clinicName } doctor { id fullName { firstName lastName } } } } } }`; const formatDateTime = (iso: string): string => `${formatDateOnly(iso)}, ${formatTimeOnly(iso)}`; const getPatientName = (appt: AppointmentRecord): string => { if (!appt.patient?.fullName) return 'Unknown'; return `${appt.patient.fullName.firstName} ${appt.patient.fullName.lastName}`.trim() || 'Unknown'; }; const getPhone = (appt: AppointmentRecord): string => appt.patient?.phones?.primaryPhoneNumber ?? ''; const isUpcoming = (appt: AppointmentRecord): boolean => { if (appt.status !== 'SCHEDULED' && appt.status !== 'CONFIRMED') return false; if (!appt.scheduledAt) return false; return new Date(appt.scheduledAt).getTime() >= Date.now(); }; // Can edit/reschedule: anything that isn't completed or cancelled const canEdit = (appt: AppointmentRecord): boolean => { return appt.status !== 'COMPLETED' && appt.status !== 'CANCELLED' && appt.status !== 'NO_SHOW'; }; const buildReminderMessage = (appt: AppointmentRecord): string => { const name = getPatientName(appt); const doctor = appt.doctorName ?? 'your doctor'; const date = appt.scheduledAt ? formatDateTime(appt.scheduledAt) : 'your scheduled time'; const branch = appt.clinic?.clinicName ?? 'our clinic'; return `Hi ${name}, this is a reminder for your appointment with ${doctor} on ${date} at ${branch}. Please confirm or call us to reschedule.`; }; // ── Detail Panel ───────────────────────────────────────────────── const DetailRow = ({ icon, label, value }: { icon: any; label: string; value: string }) => (

{label}

{value || '—'}

); const AppointmentDetailPanel = ({ appointment, onClose, onReschedule, }: { appointment: AppointmentRecord; onClose: () => void; onReschedule: () => void; }) => { const editable = canEdit(appointment); const phone = getPhone(appointment); const [reschedulePromptOpen, setReschedulePromptOpen] = useState(false); return (

Appointment Details

{editable && ( )}
{STATUS_LABELS[appointment.status ?? ''] ?? appointment.status ?? '—'}
{/* Date & Time — 2 lines */}

Date & Time

{appointment.scheduledAt ? ( <>

{formatDateOnly(appointment.scheduledAt)}

{formatTimeOnly(appointment.scheduledAt)}

) :

}

Patient

{getPatientName(appointment)}

{phone && (
)}
{/* Reschedule confirm modal — same pattern as call desk */} { if (!open) setReschedulePromptOpen(false); }} isDismissable > {() => (

Reschedule this appointment?

Choose "Yes, reschedule" to change the date, time, or doctor. Choose "No, just view" to see the details without changing anything.

)}
); }; // ── Reschedule Panel ───────────────────────────────────────────── // Dedicated form for rescheduling from the Appointments page. // No patient creation, no lead updates, no modal — just update the // existing appointment's doctor, date, time, and chief complaint. type Doctor = { id: string; name: string; department: string }; const DOCTORS_QUERY = `{ doctors(first: 50) { edges { node { id name fullName { firstName lastName } department } } } }`; const ReschedulePanel = ({ appointment, onClose, onSaved, }: { appointment: AppointmentRecord; onClose: () => void; onSaved: () => void; }) => { const [doctors, setDoctors] = useState([]); const [department, setDepartment] = useState(appointment.department ?? ''); const [doctor, setDoctor] = useState(appointment.doctor?.id ?? ''); const [date, setDate] = useState(() => appointment.scheduledAt?.split('T')[0] ?? ''); const [timeSlot, setTimeSlot] = useState(() => { if (!appointment.scheduledAt) return ''; const dt = new Date(appointment.scheduledAt); return `${String(dt.getHours()).padStart(2, '0')}:${String(dt.getMinutes()).padStart(2, '0')}`; }); const [slots, setSlots] = useState>([]); const [reason, setReason] = useState(appointment.reasonForVisit ?? ''); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [cancelConfirm, setCancelConfirm] = useState(false); // Fetch doctors once useEffect(() => { apiClient.graphql(DOCTORS_QUERY, undefined, { silent: true }) .then(data => { const docs = data.doctors.edges.map((e: any) => { const n = e.node; const name = n.fullName ? `Dr. ${n.fullName.firstName} ${n.fullName.lastName}`.trim() : n.name; return { id: n.id, name, department: n.department ?? '' }; }); setDoctors(docs); }) .catch(() => {}); }, []); // Departments derived from doctors const departments = useMemo(() => [...new Set(doctors.map(d => d.department).filter(Boolean))], [doctors]); const filteredDoctors = useMemo(() => department ? doctors.filter(d => d.department === department) : doctors, [doctors, department]); // Fetch slots when doctor + date change useEffect(() => { if (!doctor || !date) { setSlots([]); return; } apiClient.get>(`/api/masterdata/slots?doctorId=${doctor}&date=${date}`, { silent: true }) .then(s => setSlots(s.map(sl => ({ id: sl.time, label: sl.label })))) .catch(() => setSlots([])); }, [doctor, date]); const handleUpdate = async () => { if (!doctor || !date || !timeSlot) { setError('Please select doctor, date, and time slot'); return; } setSaving(true); setError(null); try { const scheduledAt = new Date(`${date}T${timeSlot}:00`).toISOString(); const selectedDoc = doctors.find(d => d.id === doctor); await apiClient.graphql( `mutation($id: UUID!, $data: AppointmentUpdateInput!) { updateAppointment(id: $id, data: $data) { id } }`, { id: appointment.id, data: { scheduledAt, doctorName: selectedDoc?.name ?? appointment.doctorName, department: department || appointment.department, reasonForVisit: reason || null, status: 'RESCHEDULED', doctorId: doctor, }, }, ); onSaved(); } catch (err: any) { setError(err.message ?? 'Failed to update appointment'); } finally { setSaving(false); } }; const handleCancel = async () => { setSaving(true); try { await apiClient.graphql( `mutation($id: UUID!, $data: AppointmentUpdateInput!) { updateAppointment(id: $id, data: $data) { id } }`, { id: appointment.id, data: { status: 'CANCELLED' } }, ); notify.success('Appointment Cancelled'); onSaved(); } catch { setError('Failed to cancel appointment'); } finally { setSaving(false); } }; return (

Reschedule Appointment

{/* Department */}
Department
{/* Doctor */}
Doctor *
{/* Date */}
Date * setDate(val ? val.toString() : '')} granularity="day" minValue={today(getLocalTimeZone())} isDisabled={!doctor} popoverPlacement="top start" />
{/* Time slots */} {doctor && date && slots.length > 0 && (
Time Slot *
{slots.map(s => ( ))}
)} {doctor && date && slots.length === 0 && (

No available slots for this date

)} {/* Chief Complaint */}
Chief Complaint