import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { CheckboxControl } from "../ui/checkbox-control"; import { SelectControl } from "../ui/select-control"; import { Spinner } from "../ui/spinner"; import { TextControl } from "../ui/text-control"; import { TextareaControl } from "../ui/textarea-control"; import { DismissibleAlert } from '../ui/dismissible-alert'; import { Flex, FlexItem } from '../ui/flex'; import { Modal } from '../ui/wp-modal-shim'; import { Appointment, Patient, Task } from 'fhir/r4'; import { operationStatusToast } from '../OperationStatusToast/OperationStatusToast.tsx'; import { TaskPrioritySelect, type TaskPriority } from '../TaskPrioritySelect/TaskPrioritySelect'; import { CustomTaskDueMode, CustomTaskRecurrenceFrequency, useTaskServiceContext } from '../../services/TaskService.tsx'; import { useAppointmentServiceContext } from '../../services/AppointmentService.tsx'; import { useFhirServiceContext } from '../../services/FhirService.tsx'; import { relayServerFailure } from '../../utils/fetchFailureMessage'; import { appointmentSubjectLine } from '../../utils/appointmentSubject'; import { patientHasLogin } from '../../utils/cognitoIdentity'; export type CustomTaskAppointmentSource = 'current' | 'existing'; export interface CustomTaskAssignmentDraft { /** The name staff gave the task; absent leaves the instructions as its headline. */ title?: string; instructions: string; dueMode: CustomTaskDueMode; dueDate?: string; recurrenceFrequency?: CustomTaskRecurrenceFrequency; appointment?: Appointment; appointmentSource?: CustomTaskAppointmentSource; appointmentDisplay?: string; /** What the patient is asked to send; absent when the task asks for no file. */ documentRequest?: string; priority?: TaskPriority; } interface CustomTaskAssignmentModalProps { isOpen: boolean; patient: Patient; onClose: () => void; onAssigned?: (task: Task) => void; onPrepared?: (draft: CustomTaskAssignmentDraft) => void | Promise; presentation?: 'modal' | 'inline'; submitLabel?: string; allowThisAppointment?: boolean; currentAppointment?: Appointment; appointmentClock?: AppointmentClock; soonestAppointmentHelpText?: string; initialDraft?: CustomTaskAssignmentDraft; formTitle?: string; formDescription?: string; /** @deprecated Use soonestAppointmentHelpText. */ nextAppointmentHelpText?: string; } const SCHEDULED_UPCOMING_STATUSES = new Set([ 'proposed', 'pending', 'booked', 'arrived', 'checked-in', 'waitlist' ]); const getTodayForDateInput = (): string => { const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; }; const getAppointmentStartTimestamp = (appointment?: Appointment): number | undefined => { if (!appointment?.start) { return undefined; } const timestamp = Date.parse(appointment.start); return Number.isFinite(timestamp) ? timestamp : undefined; }; const isScheduledUpcomingAppointment = ( appointment: Appointment, nowTimestamp = Date.now() ): appointment is Appointment & { id: string; start: string } => { const startTimestamp = getAppointmentStartTimestamp(appointment); return Boolean( appointment.id && appointment.start && startTimestamp !== undefined && startTimestamp >= nowTimestamp && SCHEDULED_UPCOMING_STATUSES.has(appointment.status) ); }; const getAppointmentPractitionerDisplay = (appointment: Appointment): string => appointment.participant?.find(participant => participant.actor?.reference?.startsWith('Practitioner/') || participant.actor?.reference?.startsWith('PractitionerRole/') )?.actor?.display?.trim() || 'Practitioner not specified'; // WHAT THE VISIT IS FOR, from the one shared helper - see utils/appointmentSubject. The third local copy // of the same idea. This one deliberately keeps returning `undefined` for a record with nothing to say, // because its caller builds a joined line and an "Appointment" filler would read as a real answer. const getAppointmentTypeDisplay = (appointment: Appointment): string | undefined => { const line = appointmentSubjectLine(appointment); return line === 'Appointment' ? undefined : line; }; // The clock a visit is read on. Where the booking panel names the practice's zone beside its times, the // task line names it too, so an 8:00 AM CDT slot is never shown beside a bare 9:00 AM. export type AppointmentClock = { timeZone: string; named: boolean }; export const formatCustomTaskAppointmentDisplay = (appointment: Appointment, clock?: AppointmentClock): string => { const startTimestamp = getAppointmentStartTimestamp(appointment); const practitioner = getAppointmentPractitionerDisplay(appointment); const appointmentType = getAppointmentTypeDisplay(appointment); if (startTimestamp === undefined) { return [practitioner, appointmentType] .filter((value): value is string => Boolean(value)) .join(' · '); } const start = new Date(startTimestamp); const date = start.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', timeZone: clock?.timeZone }); const time = start.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true, timeZone: clock?.timeZone, timeZoneName: clock?.named ? 'short' : undefined }); return [ `${date} at ${time}`, practitioner, appointmentType ] .filter((value): value is string => Boolean(value)) .join(' · '); }; type AppointmentResolution = { appointment: Appointment; source: CustomTaskAppointmentSource; }; export const CustomTaskAssignmentModal = ({ isOpen, patient, onClose, onAssigned, onPrepared, presentation = 'modal', submitLabel, allowThisAppointment = false, currentAppointment, appointmentClock, soonestAppointmentHelpText, initialDraft, formTitle, formDescription, nextAppointmentHelpText }: CustomTaskAssignmentModalProps) => { const { createCustomTask } = useTaskServiceContext(); const { fetchAppointments } = useAppointmentServiceContext(); const { unpackBundle } = useFhirServiceContext(); const showThisAppointmentOption = Boolean(allowThisAppointment && onPrepared); const defaultDueMode: CustomTaskDueMode = initialDraft?.dueMode || ( showThisAppointmentOption ? 'this-appointment' : 'next-appointment' ); const [title, setTitle] = useState(initialDraft?.title || ''); const [instructions, setInstructions] = useState(initialDraft?.instructions || ''); const [dueMode, setDueMode] = useState(defaultDueMode); const [dueDate, setDueDate] = useState(initialDraft?.dueDate || ''); const [recurrenceFrequency, setRecurrenceFrequency] = useState< CustomTaskRecurrenceFrequency | '' >(initialDraft?.recurrenceFrequency || ''); const [upcomingAppointments, setUpcomingAppointments] = useState([]); const [appointmentsLoading, setAppointmentsLoading] = useState(false); const [appointmentsError, setAppointmentsError] = useState(); const [selectedAppointmentId, setSelectedAppointmentId] = useState( initialDraft?.appointment?.id || '' ); // Kept apart from the label so the box can be ticked with nothing named yet, which submit refuses. const [asksForFile, setAsksForFile] = useState(Boolean(initialDraft?.documentRequest?.trim())); const [documentRequest, setDocumentRequest] = useState(initialDraft?.documentRequest || ''); const [priority, setPriority] = useState(initialDraft?.priority ?? 'routine'); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(); const resetForm = useCallback(() => { setPriority(initialDraft?.priority ?? 'routine'); setTitle(initialDraft?.title || ''); setInstructions(initialDraft?.instructions || ''); setAsksForFile(Boolean(initialDraft?.documentRequest?.trim())); setDocumentRequest(initialDraft?.documentRequest || ''); setDueMode(defaultDueMode); setDueDate(initialDraft?.dueDate || ''); setRecurrenceFrequency(initialDraft?.recurrenceFrequency || ''); setSelectedAppointmentId(initialDraft?.appointment?.id || ''); setError(undefined); }, [defaultDueMode, initialDraft]); const loadUpcomingAppointments = useCallback(async () => { if (!patient.id) { setUpcomingAppointments([]); setAppointmentsError('The patient must be saved before appointments can be loaded.'); return; } setAppointmentsLoading(true); setAppointmentsError(undefined); try { const searchStart = new Date(); searchStart.setMinutes(searchStart.getMinutes() - 1); const bundle = await fetchAppointments(patient, searchStart.toISOString()); const appointments = (unpackBundle(bundle) as Appointment[]) .filter(appointment => isScheduledUpcomingAppointment(appointment)) .sort((left, right) => (getAppointmentStartTimestamp(left) || 0) - (getAppointmentStartTimestamp(right) || 0) ); setUpcomingAppointments(appointments); } catch (appointmentError) { console.error('Failed to load upcoming appointments for task assignment:', appointmentError); setUpcomingAppointments([]); setAppointmentsError('Failed to load the patient\'s upcoming appointments.'); } finally { setAppointmentsLoading(false); } }, [fetchAppointments, patient, unpackBundle]); useEffect(() => { if (!isOpen) { resetForm(); return; } void loadUpcomingAppointments(); }, [isOpen, loadUpcomingAppointments, resetForm]); useEffect(() => { if ( selectedAppointmentId && !appointmentsLoading && upcomingAppointments.length > 0 && !upcomingAppointments.some(appointment => appointment.id === selectedAppointmentId) ) { setSelectedAppointmentId(''); } }, [appointmentsLoading, selectedAppointmentId, upcomingAppointments]); const currentAppointmentResolution = useMemo(() => { if (!showThisAppointmentOption || getAppointmentStartTimestamp(currentAppointment) === undefined) { return undefined; } return { appointment: currentAppointment as Appointment, source: 'current' }; }, [currentAppointment, showThisAppointmentOption]); const soonestAppointmentResolution = useMemo(() => { const candidates: AppointmentResolution[] = upcomingAppointments.map(appointment => ({ appointment, source: 'existing' })); if (currentAppointmentResolution) { candidates.push(currentAppointmentResolution); } return candidates.sort((left, right) => (getAppointmentStartTimestamp(left.appointment) || Number.MAX_SAFE_INTEGER) - (getAppointmentStartTimestamp(right.appointment) || Number.MAX_SAFE_INTEGER) )[0]; }, [currentAppointmentResolution, upcomingAppointments]); const selectedAppointment = useMemo( () => upcomingAppointments.find(appointment => appointment.id === selectedAppointmentId), [selectedAppointmentId, upcomingAppointments] ); const selectedAppointmentResolution = useMemo(() => { if (!selectedAppointment) { return undefined; } return { appointment: selectedAppointment, source: 'existing' }; }, [selectedAppointment]); if (!isOpen) { return null; } const handleClose = () => { if (submitting) { return; } resetForm(); onClose(); }; const getDueAppointmentResolution = (): AppointmentResolution | undefined => { if (dueMode === 'this-appointment') { return currentAppointmentResolution; } if (dueMode === 'next-appointment') { return soonestAppointmentResolution; } if (dueMode === 'specific-appointment') { return selectedAppointmentResolution; } return undefined; }; const handleSubmit = async (event: FormEvent) => { event.preventDefault(); const trimmedInstructions = instructions.trim(); if (!trimmedInstructions) { setError('Enter what the patient needs to do.'); return; } if (asksForFile && !documentRequest.trim()) { setError('Say what the patient should send.'); return; } if ((dueMode === 'date' || dueMode === 'recurring') && !dueDate) { setError( dueMode === 'recurring' ? 'Select the first due date for the recurring task.' : 'Select a due date for the task.' ); return; } if (dueMode === 'recurring' && !recurrenceFrequency) { setError('Select how often the task should recur.'); return; } if ( (dueMode === 'next-appointment' || dueMode === 'specific-appointment') && appointmentsLoading ) { setError('Wait for the patient\'s appointments to finish loading.'); return; } if ( (dueMode === 'next-appointment' || dueMode === 'specific-appointment') && appointmentsError ) { setError('Reload the patient\'s appointments before assigning this task.'); return; } if (dueMode === 'this-appointment' && !currentAppointmentResolution) { setError('Complete the appointment date and time before adding this task.'); return; } if (dueMode === 'specific-appointment' && !selectedAppointmentResolution) { setError('Select the appointment this task belongs to.'); return; } const appointmentResolution = getDueAppointmentResolution(); const draft: CustomTaskAssignmentDraft = { title: title.trim() || undefined, instructions: trimmedInstructions, dueMode, dueDate: dueMode === 'date' || dueMode === 'recurring' ? dueDate : undefined, recurrenceFrequency: dueMode === 'recurring' && recurrenceFrequency ? recurrenceFrequency : undefined, appointment: appointmentResolution?.appointment, appointmentSource: appointmentResolution?.source, appointmentDisplay: appointmentResolution ? formatCustomTaskAppointmentDisplay(appointmentResolution.appointment, appointmentClock) : undefined, documentRequest: asksForFile ? documentRequest.trim() : undefined, priority }; if (onPrepared) { setSubmitting(true); setError(undefined); try { await onPrepared(draft); resetForm(); onClose(); } catch (err) { console.error('Failed to save Custom Task:', err); setError(relayServerFailure(err, 'Failed to save Custom Task.')); } finally { setSubmitting(false); } return; } setSubmitting(true); setError(undefined); const operationToastId = operationStatusToast.saving(); let createdTask: Task | undefined; try { createdTask = await createCustomTask({ patient, title: draft.title, instructions: draft.instructions, dueMode: draft.dueMode, dueDate: draft.dueDate, recurrenceFrequency: draft.recurrenceFrequency, documentRequest: draft.documentRequest, priority: draft.priority, appointment: draft.appointmentSource === 'existing' ? draft.appointment : undefined }); } catch (err) { console.error('Failed to assign Custom Task:', err); setError(relayServerFailure(err, 'Failed to assign Custom Task.')); } finally { operationStatusToast.dismiss(operationToastId); setSubmitting(false); } if (createdTask) { onAssigned?.(createdTask); resetForm(); onClose(); } }; const requiresDate = dueMode === 'date' || dueMode === 'recurring'; const requiresAppointmentLoad = dueMode === 'next-appointment' || dueMode === 'specific-appointment'; const appointmentSelectionIsValid = dueMode !== 'specific-appointment' || Boolean(selectedAppointmentResolution); const currentAppointmentIsValid = dueMode !== 'this-appointment' || Boolean(currentAppointmentResolution); const canSubmit = Boolean( instructions.trim() && (!asksForFile || documentRequest.trim()) && (!requiresDate || dueDate) && (dueMode !== 'recurring' || recurrenceFrequency) && (!requiresAppointmentLoad || (!appointmentsLoading && !appointmentsError)) && appointmentSelectionIsValid && currentAppointmentIsValid ); const resolvedSubmitLabel = submitLabel || (onPrepared ? 'Add Custom Task' : 'Assign Task'); const resolvedFormTitle = formTitle || (presentation === 'modal' ? 'Assign Custom Task' : 'Custom Task'); const resolvedFormDescription = formDescription || 'Configure the task now. It will be assigned only after the appointment is booked.'; const resolvedSoonestHelpText = soonestAppointmentHelpText || nextAppointmentHelpText || "Due at the patient's next scheduled appointment. If none is scheduled, the task waits until one is booked."; const todayForDateInput = getTodayForDateInput(); const minimumDueDate = initialDraft?.dueDate && initialDraft.dueDate < todayForDateInput ? initialDraft.dueDate : todayForDateInput; const dueOptions = [ ...(showThisAppointmentOption ? [ { label: 'This appointment', value: 'this-appointment' } ] : []), { label: 'Soonest upcoming appointment', value: 'next-appointment' }, { label: 'Specific appointment', value: 'specific-appointment' }, { label: 'Specific date', value: 'date' }, { label: 'Recurring', value: 'recurring' } ]; const appointmentLoadErrorNotice = appointmentsError ? (

{appointmentsError}

) : null; const form = (
{error && ( {error} )}
{ setAsksForFile(checked); if (!checked) { setDocumentRequest(''); } setError(undefined); }} disabled={submitting} /> {asksForFile && ( )}
{ const nextDueMode = value as CustomTaskDueMode; setDueMode(nextDueMode); if (nextDueMode !== 'date' && nextDueMode !== 'recurring') { setDueDate(''); } if (nextDueMode !== 'recurring') { setRecurrenceFrequency(''); } if (nextDueMode !== 'specific-appointment') { setSelectedAppointmentId(''); } setError(undefined); }} disabled={submitting} /> {dueMode === 'this-appointment' && (

This task will be attached to this appointment and will be due when it begins.

{currentAppointmentResolution && (

This appointment:{' '} {formatCustomTaskAppointmentDisplay( currentAppointmentResolution.appointment, appointmentClock )}

)}
)} {dueMode === 'next-appointment' && (

{resolvedSoonestHelpText}

{appointmentsLoading ? (
Checking upcoming appointments…
) : appointmentsError ? (

{appointmentsError}

) : soonestAppointmentResolution ? (

Currently:{' '} {formatCustomTaskAppointmentDisplay( soonestAppointmentResolution.appointment, appointmentClock )} {soonestAppointmentResolution.source === 'current' ? ' (this appointment)' : ''}

) : (

Currently: No upcoming appointment is scheduled. The task will wait until one is booked.

)}
)} {dueMode === 'specific-appointment' && (
{appointmentsLoading ? (
Loading the patient's upcoming appointments…
) : appointmentLoadErrorNotice ? ( appointmentLoadErrorNotice ) : upcomingAppointments.length === 0 ? ( This patient does not have a scheduled upcoming appointment to select. ) : ( ({ label: formatCustomTaskAppointmentDisplay(appointment, appointmentClock), value: appointment.id as string })) ]} onChange={(value) => { setSelectedAppointmentId(value); setError(undefined); }} disabled={submitting} /> )}
)} {requiresDate && ( )} {dueMode === 'recurring' && ( <> { setRecurrenceFrequency( value as CustomTaskRecurrenceFrequency | '' ); setError(undefined); }} disabled={submitting} />

One occurrence stays active at a time. The next occurrence is created after the current task is completed or marked unable to complete.

)} {/* Cancel removed: the Modal's onRequestClose IS `handleClose`, the same function reference this called. */}
); if (presentation === 'inline') { return (

{resolvedFormTitle}

{resolvedFormDescription}

{form}
); } return ( {form} ); };