import React, { useCallback, useEffect, useRef, useState } from 'react'; import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { Button, ButtonSet, Form, InlineLoading, NumberInput, Select, SelectItem, Stack, TextArea, TimePicker, TimePickerSelect, Search, } from '@carbon/react'; import { zodResolver } from '@hookform/resolvers/zod'; import { ExtensionSlot, OpenmrsDatePicker, ResponsiveWrapper, showSnackbar, translateFrom, useConfig, useLayoutType, useLocations, usePatient, useSession, type DefaultWorkspaceProps, } from '@openmrs/esm-framework'; import { z } from 'zod'; import { type ConfigObject } from '../config-schema'; import { saveAppointment, useAppointmentService, useMutateAppointments, useAppointments, editAppointment, } from './appointments-form.resource'; import { dateFormat, moduleName } from '../constants'; import { useProviders } from '../hooks/useProviders'; import type { Appointment, RecurringPattern } from '../types'; import { useSelectedDateContext } from '../hooks/selected-date-context'; import styles from './appointments-form.scss'; import { useConceptAnswers } from '../hooks/useConceptAnswers'; import { useSearchableLocations } from '../hooks/useSearchableLocations'; // Enable dayjs UTC plugin for timezone handling dayjs.extend(utc); interface AppointmentsFormProps { appointment?: Appointment; recurringPattern?: RecurringPattern; patientUuid?: string; context: string; } const AppointmentsForm: React.FC = ({ appointment, patientUuid, context, closeWorkspace, promptBeforeClosing, }) => { const { patient } = usePatient(patientUuid); const { mutateAppointments } = useMutateAppointments(); const appointmentDateTime = appointment?.startDateTime || appointment?.appointmentDate; const editedAppointmentTimeFormat = appointmentDateTime ? new Date(appointmentDateTime).getHours() >= 12 ? 'PM' : 'AM' : new Date().getHours() >= 12 ? 'PM' : 'AM'; const defaultTimeFormat = appointmentDateTime ? editedAppointmentTimeFormat : new Date().getHours() >= 12 ? 'PM' : 'AM'; const [searchTerm, setSearchTerm] = useState(appointment?.location?.name || ''); const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(appointment?.location?.name || ''); const searchInputRef = useRef(null); const { t } = useTranslation(); const isTablet = useLayoutType() === 'tablet'; const locations = useLocations(null); // Get all locations for creating appointments const searchableLocations = useSearchableLocations(debouncedSearchTerm); // Get filtered locations for search const providers = useProviders(); const session = useSession(); const { selectedDate } = useSelectedDateContext(); const { data: servicesRaw, isLoading } = useAppointmentService(); const services = servicesRaw || []; const { appointmentReasonConceptUuid } = useConfig(); const { answers: reasonOptions, isLoading: isLoadingReasons } = useConceptAnswers(appointmentReasonConceptUuid); const appointmentsAbortController = new AbortController(); const { data: appointmentsData, mutate: mutatePatientAppointments } = useAppointments( patientUuid, dayjs(selectedDate).format('YYYY-MM-DDTHH:mm:ss.SSSZZ'), appointmentsAbortController, ); const [isSuccessful, setIsSuccessful] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); useEffect(() => { const timeoutId = setTimeout(() => { setDebouncedSearchTerm(searchTerm); }, 300); return () => clearTimeout(timeoutId); }, [searchTerm]); const defaultStartDate = appointmentDateTime ? new Date(appointmentDateTime) : selectedDate ? new Date(selectedDate) : new Date(); const defaultStartDateText = appointmentDateTime ? dayjs(new Date(appointmentDateTime)).format(dateFormat) : selectedDate ? dayjs(selectedDate).format(dateFormat) : dayjs(new Date()).format(dateFormat); const defaultAppointmentStartTime = appointmentDateTime ? dayjs(new Date(appointmentDateTime)).format('hh:mm') : dayjs(new Date()).format('hh:mm'); const appointmentsFormSchema = z .object({ location: z.string().refine((value) => value !== '', { message: translateFrom(moduleName, 'locationRequired', 'Location is required'), }), selectedService: z.string().refine((value) => value !== '', { message: translateFrom(moduleName, 'serviceRequired', 'Service is required'), }), provider: z.string().refine((value) => value !== '', { message: translateFrom(moduleName, 'providerRequired', 'Provider is required'), }), reason: z.string().refine((value) => value !== '', { message: translateFrom(moduleName, 'reasonRequired', 'Reason is required'), }), appointmentDateTime: z.object({ startDate: z.date(), startDateText: z.string(), }), startTime: z.string().regex(/^(1[0-2]|0?[1-9]):[0-5][0-9]$/, 'Invalid time format'), timeFormat: z.enum(['AM', 'PM']), appointmentNote: z.string().optional(), nextVisitDate: z.string().optional(), }) .superRefine((val, ctx) => { try { const [hours12Str, minutesStr] = (val.startTime || '').split(':'); const hours12 = Number(hours12Str); const minutes = Number(minutesStr); if (Number.isNaN(hours12) || Number.isNaN(minutes)) { return; } let hours24 = hours12; if (val.timeFormat === 'PM' && hours12 !== 12) { hours24 = hours12 + 12; } else if (val.timeFormat === 'AM' && hours12 === 12) { hours24 = 0; } const candidate = dayjs(val.appointmentDateTime.startDate) .hour(hours24) .minute(minutes) .second(0) .millisecond(0); if (candidate.isBefore(dayjs())) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['appointmentDateTime', 'startDate'], message: translateFrom( moduleName, 'appointmentMustBeFuture', 'Appointment date & time must be in the future', ), }); } } catch { // no-op; other validators will surface errors } }); type AppointmentFormData = z.infer; const { control, getValues, setValue, watch, handleSubmit, reset, formState: { errors, isDirty }, } = useForm({ mode: 'all', resolver: zodResolver(appointmentsFormSchema), defaultValues: { location: appointment?.location?.uuid ?? session?.sessionLocation?.uuid ?? '', selectedService: appointment?.service?.serviceId?.toString() || appointment?.appointmentService?.serviceId?.toString() || '', reason: appointment?.reason?.value?.uuid || '', startTime: defaultAppointmentStartTime, timeFormat: defaultTimeFormat, // Added default timeFormat appointmentDateTime: { startDate: defaultStartDate, startDateText: defaultStartDateText, }, appointmentNote: appointment?.note || '', provider: appointment?.provider?.uuid || '', }, }); useEffect(() => { if (isSuccessful) { reset(); promptBeforeClosing(() => false); if (typeof closeWorkspace === 'function') { closeWorkspace(); } return; } promptBeforeClosing(() => isDirty); }, [isDirty, promptBeforeClosing, isSuccessful, reset, closeWorkspace]); useEffect(() => { if (appointment && context === 'editing') { if (appointment.location?.uuid) { setValue('location', appointment.location.uuid); if (appointment.location?.name) { setSearchTerm(appointment.location.display); } } if (appointment.service?.serviceId || appointment.appointmentService?.serviceId) { const serviceId = appointment.service?.serviceId || appointment.appointmentService?.serviceId; setValue('selectedService', serviceId.toString()); } if (appointment.reason?.value?.uuid) { setValue('reason', appointment.reason.value.uuid); } if (appointment.provider?.uuid) { setValue('provider', appointment.provider.uuid); } if (appointment.note) { setValue('appointmentNote', appointment.note); } // Handle appointment date and time const appointmentDateTimeValue = appointment.startDateTime || appointment.appointmentDate; if (appointmentDateTimeValue) { const appointmentDate = new Date(appointmentDateTimeValue); setValue('appointmentDateTime', { startDate: appointmentDate, startDateText: dayjs(appointmentDate).format(dateFormat), }); // Set time fields const timeString = dayjs(appointmentDate).format('hh:mm'); const timeFormat = appointmentDate.getHours() >= 12 ? 'PM' : 'AM'; setValue('startTime', timeString); setValue('timeFormat', timeFormat); } } }, [appointment, context, setValue]); const handleSaveAppointment = async (data: AppointmentFormData) => { setIsSubmitting(true); const serviceObj = services?.find((service) => String(service.serviceId) === String(data.selectedService)); if (!serviceObj) { setIsSubmitting(false); showSnackbar({ isLowContrast: true, kind: 'error', title: t('serviceRequired', 'Service is required'), }); return; } const createAppointmentDateTime = (formData: AppointmentFormData): string => { // Convert 12-hour time to 24-hour format const [hours12, minutes] = formData.startTime.split(':').map(Number); let hours24 = hours12; if (formData.timeFormat === 'PM' && hours12 !== 12) { hours24 = hours12 + 12; } else if (formData.timeFormat === 'AM' && hours12 === 12) { hours24 = 0; } const appointmentDateTime = dayjs(formData.appointmentDateTime.startDate) .hour(hours24) .minute(minutes) .second(0) .millisecond(0); return appointmentDateTime.format('YYYY-MM-DDTHH:mm:ss.SSS'); }; const appointmentDate = createAppointmentDateTime(data); const payload = { appointmentDate, reason: { person: patientUuid, concept: appointmentReasonConceptUuid, value: data.reason, obsDatetime: appointmentDate, }, nextVisitDate: data.nextVisitDate || undefined, note: data.appointmentNote, encounter: null, location: data.location, provider: data.provider, service: { serviceId: serviceObj.serviceId }, patient: patientUuid, }; // Frontend conflict check: same patient, exact time match const existingAppointments = appointmentsData?.upcomingAppointments || []; const hasConflict = existingAppointments.some((existing) => { // Compare the appointment times for exact match const existingDateTime = existing.appointmentDate || existing.startDateTime; // Normalize both times to the same format for comparison const existingTime = dayjs(existingDateTime).format('YYYY-MM-DDTHH:mm:ss'); const newTime = dayjs(appointmentDate).format('YYYY-MM-DDTHH:mm:ss'); return existingTime === newTime; }); if (hasConflict) { setIsSubmitting(false); showSnackbar({ isLowContrast: true, kind: 'error', title: t('patientDoubleBooking', 'Patient already booked for an appointment at this time'), }); return; } const abortController = new AbortController(); try { // Create optimistic appointment object for immediate UI update const newAppointment = { uuid: `temp-${Date.now()}`, startDateTime: appointmentDate, endDateTime: appointmentDate, appointmentDate: appointmentDate, status: 'Scheduled', service: { name: serviceObj.name, serviceId: serviceObj.serviceId, }, location: { uuid: data.location, display: locations?.find((loc) => loc.uuid === data.location)?.display || data.location, name: locations?.find((loc) => loc.uuid === data.location)?.display || data.location, }, provider: { uuid: data.provider, display: providers?.providers?.find((p) => p.uuid === data.provider)?.display || data.provider, }, reason: { person: patientUuid, concept: appointmentReasonConceptUuid, value: data.reason, obsDatetime: appointmentDate, }, note: data.appointmentNote, comments: data.appointmentNote, patient: { uuid: patientUuid, }, }; // Optimistically update the appointments cache if (appointmentsData) { mutatePatientAppointments((currentData) => { if (!currentData) { return currentData; } const currentResults = (currentData as any)?.data?.results || []; const updatedResults = [...currentResults, newAppointment]; return { ...currentData, data: { ...(currentData as any).data, results: updatedResults, }, }; }, false); } const response = appointment?.appointmentId ? await editAppointment(String(appointment.appointmentId), payload, abortController) : await saveAppointment(payload, abortController); if (response.status === 200 || response.status === 201) { setIsSubmitting(false); setIsSuccessful(true); // Revalidate all appointment-related queries mutatePatientAppointments(); mutateAppointments(); showSnackbar({ isLowContrast: true, kind: 'success', subtitle: t('appointmentNowVisible', 'It is now visible on the Appointments page'), title: context === 'editing' ? t('appointmentEdited', 'Appointment edited') : t('appointmentScheduled', 'Appointment scheduled'), }); } else if (response.status === 204) { setIsSubmitting(false); mutatePatientAppointments(); mutateAppointments(); showSnackbar({ title: context === 'editing' ? t('appointmentEditError', 'Error editing appointment') : t('appointmentFormError', 'Error scheduling appointment'), kind: 'error', isLowContrast: false, subtitle: t('noContent', 'No Content'), }); } else { setIsSubmitting(false); mutatePatientAppointments(); showSnackbar({ title: context === 'editing' ? t('appointmentEditError', 'Error editing appointment') : t('appointmentFormError', 'Error scheduling appointment'), kind: 'error', isLowContrast: false, subtitle: `Unexpected response status: ${response.status}`, }); } } catch (error) { setIsSubmitting(false); mutatePatientAppointments(); showSnackbar({ title: context === 'editing' ? t('appointmentEditError', 'Error editing appointment') : t('appointmentFormError', 'Error scheduling appointment'), kind: 'error', isLowContrast: false, subtitle: error?.message || 'An unexpected error occurred', }); } }; const handleSearchTermChange = useCallback( (event: React.ChangeEvent) => { const newSearchTerm = event.target.value ?? ''; setSearchTerm(newSearchTerm); }, [setSearchTerm], ); if (isLoading) { return ( ); } return (
{patient && ( )}
{t('location', 'Location')} {appointment ? ( // Show search input when editing an appointment <>
{searchTerm && debouncedSearchTerm !== searchTerm && (
{t('searching', 'Searching...')}
)} {searchTerm && debouncedSearchTerm === searchTerm && searchableLocations.locations?.length === 0 && (
{t('noLocationsFound', 'No locations found matching your search')}
)} {searchTerm && debouncedSearchTerm === searchTerm && searchableLocations.locations?.length > 0 && !locations?.find((loc) => loc.uuid === watch('location'))?.display?.includes(searchTerm) && (
{t('searchResults', 'Search results:')}
{searchableLocations.locations.map((location) => (
{ setValue('location', location.uuid); setSearchTerm(location.display); // Show selected location name in search input }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { setValue('location', location.uuid); setSearchTerm(location.display); } }} style={{ padding: '0.75rem', cursor: 'pointer', borderBottom: '1px solid #e0e0e0', backgroundColor: 'white', transition: 'background-color 0.2s', outline: 'none', }} onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#f0f8ff')} onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'white')} >
{location.display}
{location.description && (
{location.description}
)}
))}
)}
) : ( // Show only dropdown when creating a new appointment ( )} /> )}
{t('service', 'Service')} ( )} />
{t('reason', 'Reason')} ( )} />
{t('dateTime', 'Date & Time')} ( { field.onChange({ ...field.value, startDate: date, }); }} id="datePickerInput" data-testid="datePickerInput" labelText={t('date', 'Date')} style={{ width: '100%' }} invalid={Boolean(fieldState?.error?.message)} invalidText={fieldState?.error?.message} minDate={dayjs().startOf('day').toDate()} /> )} /> ( onChange(event.target.value.replace(/\s/g, ''))} value={value} invalid={!!errors?.startTime} invalidText={errors?.startTime?.message} > ( timeFormatOnChange(event.target.value as 'AM' | 'PM')} value={timeFormatValue} aria-label={t('timeFormat', 'Time Format')} > )} /> )} />
{t('note', 'Note')} (