diff --git a/src/components/application/date-picker/date-picker.tsx b/src/components/application/date-picker/date-picker.tsx index 3753aa7..347e807 100644 --- a/src/components/application/date-picker/date-picker.tsx +++ b/src/components/application/date-picker/date-picker.tsx @@ -19,9 +19,12 @@ interface DatePickerProps extends AriaDatePickerProps { onApply?: () => void; /** The function to call when the cancel button is clicked. */ onCancel?: () => void; + /** Override popover placement — use "top start" in narrow panels + * where "bottom start" would overflow the viewport. */ + popoverPlacement?: 'bottom start' | 'top start' | 'top end' | 'bottom end'; } -export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply, onCancel, ...props }: DatePickerProps) => { +export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply, onCancel, popoverPlacement, ...props }: DatePickerProps) => { const formatter = useDateFormatter({ month: "short", day: "numeric", @@ -40,7 +43,7 @@ export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply, cx( diff --git a/src/main.tsx b/src/main.tsx index 51bf649..bfdcc3f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -49,7 +49,8 @@ import { IntegrationsPage } from "@/pages/integrations"; import { AgentDetailPage } from "@/pages/agent-detail"; import { SettingsPage } from "@/pages/settings"; import { MyPerformancePage } from "@/pages/my-performance"; -import { AppointmentsPage } from "@/pages/appointments"; +// v2 appointments — testing locally via Tauri before replacing v1 +import { AppointmentsPageV2 as AppointmentsPage } from "@/pages/appointments-v2"; import { TeamPerformancePage } from "@/pages/team-performance"; import { LiveMonitorPage } from "@/pages/live-monitor"; import { CallRecordingsPage } from "@/pages/call-recordings"; diff --git a/src/pages/appointments-v2.tsx b/src/pages/appointments-v2.tsx new file mode 100644 index 0000000..ee5f984 --- /dev/null +++ b/src/pages/appointments-v2.tsx @@ -0,0 +1,714 @@ +// 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'; +import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs'; +// 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 { 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 +