import { useEffect, useMemo, useRef, useState } from 'react'; import { Attachment, Organization, Task } from 'fhir/r4'; import { InputControl } from "../ui/input-control"; import { DismissibleAlert } from '../ui/dismissible-alert'; import { formatDateForDisplay } from '../../lib/utils.ts'; import { DUE_BY_UNSCHEDULED_NEXT_APPOINTMENT, getCustomTaskDueDisplay, getCustomTaskDueMode, getTaskAppointmentReference, getTaskStatusDisplay, resolveTaskAppointment, useTaskServiceContext } from '../../services/TaskService.tsx'; import { toast } from 'sonner'; import { operationStatusToast } from '../OperationStatusToast/OperationStatusToast.tsx'; import { useFhirAppointments } from '../../hooks'; import { useOptionalAuthServiceContext } from '../../services/AuthService.tsx'; import { readsForAnother, visitListProvesNoneBooked } from '../../utils/visitListProvesNoneBooked'; import TaskWithdrawAction from '../TaskWithdrawAction/TaskWithdrawAction.tsx'; import { relayServerFailure } from '../../utils/fetchFailureMessage'; import { getTaskMeasurement } from '../../utils/measurementTask'; import { answerScopeFor, getTaskDocumentRequest, KEPT_ANSWER_LABEL, NOTHING_SAVED_YET, pendingAnswerFor, SAVED_NOT_SETTLED, SEND_UNCONFIRMED, } from '../../utils/documentRequestTask'; import { onViewerChange, viewerEpoch, writeThroughViewer } from '../../utils/writeThroughHold'; import { getTaskCarePlanId, getTaskCarePlanReference } from '../../utils/planItem'; import { patientCompletionMessage } from '../../utils/taskCompletionMessage'; import { formatDateTime } from '../../utils/dateUtils'; import FileAnswer from '../FileAnswer/FileAnswer'; import { organizationNameOf } from '../../utils/patientOrganizations'; import { TaskStatusRow, type TaskStatusTone } from '../tasks/TaskStatusRow'; import { TaskFacts, dueFactValue, type TaskFact } from '../tasks/TaskFacts'; import { TaskOutcomeDialog } from '../tasks/TaskOutcomeDialog'; import { everyStepTicked, readChecklist } from '../../utils/taskChecklist'; import TaskChecklist from './TaskChecklist'; import { taskName } from '../../utils/taskName'; import { isSaveRetired, useSaveScope, type SaveTransport } from '../../utils/saveOperation'; type PendingTaskAction = 'complete' | 'tick' | 'unable' | null; type OpenOutcome = 'complete' | 'unable' | null; interface CustomTaskDetailsProps { task: Task; allowPatientActions?: boolean; onTaskUpdated?: (task: Task) => void; /** Opens the plan a plan item came from. Absent on surfaces that have nowhere to open it. */ onOpenCarePlan?: (carePlanId: string) => void; /** Closes the dialog this task is open in; the close sits beside the status. */ onClose?: () => void; /** The patient's practices, so an owner carried only as a reference is still named. */ organizations?: Organization[]; } const actionableStatuses: Task['status'][] = [ 'requested', 'received', 'accepted', 'ready', 'in-progress', 'on-hold' ]; const formatOptionalDate = (value: string | undefined): string => { if (!value) { return 'Not specified'; } try { return formatDateForDisplay(value); } catch { return value; } }; const statusTone = (task: Task): TaskStatusTone => { if (task.status === 'completed') return 'done'; if (task.status === 'failed') return 'bad'; if (task.status === 'cancelled') return 'ended'; return 'open'; }; export const CustomTaskDetails = ({ task, allowPatientActions = false, onTaskUpdated, onOpenCarePlan, onClose, organizations = [], }: CustomTaskDetailsProps) => { const { completeCustomTask, tickChecklistStep, completeMeasurementTask, completeDocumentRequestTask, settleDocumentRequestTask, markCustomTaskUnable } = useTaskServiceContext(); const appointmentRead = useFhirAppointments(); const { appointmentsList } = appointmentRead; const resolvedAppointment = resolveTaskAppointment(task, appointmentsList); // With no auth to say who is reading, an empty visit list proves nothing. const auth = useOptionalAuthServiceContext(); const noneBookedIsKnown = visitListProvesNoneBooked({ ...appointmentRead, // Failed refreshes and invalidation retain the previous empty bundle. error: appointmentRead.error || appointmentRead.refreshFailed || appointmentRead.stale, }, !auth || readsForAnother(auth)); const dueLine = getCustomTaskDueDisplay(task, resolvedAppointment, Date.now(), noneBookedIsKnown); const nextVisitUnscheduled = dueLine === `Due by: ${DUE_BY_UNSCHEDULED_NEXT_APPOINTMENT}`; const [submitting, setSubmitting] = useState(false); const [pendingAction, setPendingAction] = useState(null); const [openOutcome, setOpenOutcome] = useState(null); const [explanation, setExplanation] = useState(''); const [completionMessage, setCompletionMessage] = useState(''); const [reading, setReading] = useState(''); // Who is answering, as whom and at which practice: a send is kept and checked only under the same viewer. const [viewer, setViewer] = useState(writeThroughViewer); useEffect(() => onViewerChange(() => setViewer(writeThroughViewer())), []); // Each showing of a task, patient and viewer is a new opening, counted in the render that shows it. A file // belongs to the opening that chose it: returning to this task later starts empty, and a late upload is dropped. const openingKey = (task.id ?? '') + '|' + (task.for?.reference ?? '') + '|' + (viewer ?? ''); const openingRef = useRef({ key: '', number: 0, epoch: 0 }); if (openingRef.current.key !== openingKey) { openingRef.current = { key: openingKey, number: openingRef.current.number + 1, epoch: viewerEpoch() }; } const opening = openingRef.current.number; const stillOpen = (number: number) => openingRef.current.number === number && openingRef.current.epoch === viewerEpoch(); // Sent the moment it is chosen, by the same control a form uses; Send it files it on the task. const [chosen, setChosen] = useState<{ opening: number; attachment: Attachment } | undefined>(); const attachment = chosen?.opening === opening ? chosen.attachment : undefined; const setAttachment = (next: Attachment | undefined) => setChosen(next ? { opening, attachment: next } : undefined); const answerScope = useMemo(() => answerScopeFor(task, viewer), [task.id, task.for?.reference, viewer]); // A send this viewer made whose request is not closed yet: the request is checked, not answered again. const [pendingAnswer, setPendingAnswer] = useState(() => pendingAnswerFor(answerScope)); useEffect(() => { setPendingAnswer(pendingAnswerFor(answerScope)); }, [answerScope?.key]); const [error, setError] = useState(); // Updates belong to this task, its patient and the person signed in; another of any retires them. const taskScope = useSaveScope(`${task.id ?? ''}|${task.for?.reference ?? ''}|${viewer ?? ''}`, () => { setSubmitting(false); setPendingAction(null); setError(undefined); }); // A measurement asks for a NUMBER; an instruction does not. Reading it once here is what keeps // the field off a standing instruction, which must never be drawn as something to tick off. const measurement = getTaskMeasurement(task); // What the practice asked the patient to send, in the practice's own words, or undefined when // this task asks for no file. const documentRequest = getTaskDocumentRequest(task); // Without this line a plan item reads like any other reminder, with no sign it belongs to the plan // the clinician built. const carePlan = getTaskCarePlanReference(task); const hasChecklist = readChecklist(task).length > 0; // A plain checklist closes on its last tick; one that also asks for a file or a reading waits for it. const checklistCloses = hasChecklist && !documentRequest && !measurement; const checklistOutstanding = hasChecklist && !everyStepTicked(task); const isActionable = actionableStatuses.includes(task.status); const isCompleted = task.status === 'completed'; const isUnable = task.status === 'failed'; // The one ended state nobody chose from this screen: the server voids the task when its // appointment is cancelled, and writes why. const isCancelled = task.status === 'cancelled'; const completedAt = isCompleted ? formatDateTime(task.executionPeriod?.end ?? task.lastModified) : ''; const completionNote = patientCompletionMessage(task); // Named from the patient's own practices when the owner carries only a reference, as the list does. const ownerName = organizationNameOf(task.owner, organizations); const practiceName = ownerName || 'your practice'; const runTaskUpdate = async ( action: Exclude, update: (transport: SaveTransport) => Promise, afterOwned?: (saved: boolean) => void, ) => { // This task's update only: once another task (or person) is shown, nothing more is sent for it, and its // answer neither reports nor clears what came after. const operation = taskScope.start(); setSubmitting(true); setPendingAction(action); setError(undefined); const operationToastId = operationStatusToast.saving(); let saved = false; try { const updatedTask = await operation.step(() => update(operation.transport), true); onTaskUpdated?.(updatedTask); saved = true; return true; } catch (err) { if (isSaveRetired(err) || !operation.belongs()) return false; console.error('Failed to update Custom Task:', err); setError(relayServerFailure(err, 'Failed to update Custom Task.')); return false; } finally { operationStatusToast.dismiss(operationToastId); if (operation.belongs()) { setSubmitting(false); setPendingAction(null); afterOwned?.(saved); } } }; const handleSendDocument = async () => { if (!documentRequest || submitting || !isActionable) { return; } // Refused rather than closed with nothing sent: a request ticked off with no document reads // as done to the practice. if (!attachment) { setError(`Choose ${documentRequest.label.toLowerCase()} to send.`); return; } // Captured before any wait, so the send is kept for who started it. const scope = answerScope; await runTaskUpdate('complete', (transport) => completeDocumentRequestTask(task, attachment, { transport, scope, onSaved: (message) => toast.success(message), }), (saved) => { setPendingAnswer(pendingAnswerFor(scope)); if (saved) setAttachment(undefined); }); }; // A kept send is checked whatever the request now says: a completion whose answer was lost is found this way. const handleCheckRequest = async () => { if (submitting || !pendingAnswer) { return; } const scope = answerScope; await runTaskUpdate('complete', async (transport) => { const settled = await settleDocumentRequestTask(task, { transport, scope }); if (!settled) throw new Error(NOTHING_SAVED_YET); return settled; }, () => setPendingAnswer(pendingAnswerFor(scope))); }; const handleLogReading = async () => { if (!measurement || submitting || !isActionable) { return; } // Refused rather than closed without a number: completing a measurement with nothing to // record closes the activity and moves no goal. const value = Number(reading.trim()); if (!reading.trim() || !Number.isFinite(value)) { setError(`Enter your ${measurement.code.text || 'reading'} in ${measurement.unit}.`); return; } const saved = await runTaskUpdate('complete', (transport) => completeMeasurementTask(task, value, undefined, transport)); if (saved) { setReading(''); } }; const handleCompleteSubmit = async () => { const saved = await runTaskUpdate('complete', () => completeCustomTask(task, completionMessage.trim() || undefined) ); if (saved) { setOpenOutcome(null); setCompletionMessage(''); } }; const handleTick = async (stepId: string) => { if (submitting || !isActionable) { return; } await runTaskUpdate('tick', () => tickChecklistStep(task, stepId)); }; const handleUnableSubmit = async () => { const trimmedExplanation = explanation.trim(); if (!trimmedExplanation) { setError('Explain why you are unable to complete this task.'); return; } const saved = await runTaskUpdate('unable', () => markCustomTaskUnable(task, trimmedExplanation) ); if (saved) { setOpenOutcome(null); setExplanation(''); } }; const openOutcomeDialog = (outcome: Exclude) => { setError(undefined); setOpenOutcome(outcome); }; const closeOutcomeDialog = () => { if (submitting) { return; } setOpenOutcome(null); setExplanation(''); setCompletionMessage(''); setError(undefined); }; const facts: TaskFact[] = [ { label: 'Assigned by', value: task.requester?.display || task.owner?.display || 'Care team' }, { label: 'Assigned', value: formatOptionalDate(task.authoredOn) }, { label: 'Due by', value: dueFactValue(dueLine) }, ...(ownerName ? [{ label: 'Organization', value: ownerName }] : []), ...(carePlan ? [{ label: 'Care plan', testId: 'task-care-plan', value: ( <> {carePlan.display || 'A plan from your care team'} {onOpenCarePlan && ( )} ), }] : []), ]; // Each kind of task names its two outcomes for what the patient actually does. const unableLabel = measurement ? 'Unable to today' : documentRequest ? 'I don’t have it' : 'Unable to complete'; const completeLabel = measurement ? 'Log it' : documentRequest ? 'Send it' : 'Task complete'; const onCompletePress = measurement ? handleLogReading : documentRequest ? handleSendDocument : () => openOutcomeDialog('complete'); return (
{/* The patient's overlay names an open task by what it is to them - ready to do - as the task deck draws it; staff keep the workflow word. */}

{taskName(task) || task.description || 'Custom Task'}

{taskName(task) && task.description &&

{task.description}

} {/* A closed task is past doing; its outcome is stated below, so the patient is asked nothing. */} {(!allowPatientActions || isActionable) && (

{allowPatientActions ? measurement ? 'Take the reading, then enter it below.' : checklistCloses ? 'Tick each step as you do it. The task is complete once every step is ticked.' : 'Complete the instruction above, then mark the task complete.' : `Assigned to ${task.for?.display || 'the patient'}.`}

)}
{getCustomTaskDueMode(task) === 'next-appointment' && !getTaskAppointmentReference(task) && !resolvedAppointment && !task.restriction?.period?.end && isActionable && ( {/* Says the visit is unscheduled only when the due line above can say so too. */} {allowPatientActions ? (nextVisitUnscheduled ? 'Due at your next appointment. The date will update when it is scheduled.' : 'Due at your next appointment.') : (nextVisitUnscheduled ? "This task is waiting for the patient's soonest upcoming appointment to be scheduled. Its due date will update automatically." : "This task is due at the patient's next appointment.")} )} {error && !openOutcome && ( setError(undefined)}> {error} )} {isCompleted && (
{completedAt ? `Task was completed on ${completedAt}.` : 'This task has been completed.'}
{completionNote &&
Message: {completionNote}
}
)} {isCancelled && (
This task was cancelled.
{/* The practice's own words, verbatim; neither kind of void reason is rephrased. */} {task.statusReason?.text &&
Reason: {task.statusReason.text}
}
)} {isUnable && (
Unable to complete
{task.statusReason?.text || 'No explanation was provided.'}
)} {allowPatientActions && isActionable && (
{measurement && ( { setError(undefined); setReading(value ?? ''); }} disabled={submitting} /> )} {pendingAnswer && (

{documentRequest?.label ?? KEPT_ANSWER_LABEL}

{pendingAnswer === 'saved' ? SAVED_NOT_SETTLED : SEND_UNCONFIRMED}

)} {documentRequest && !pendingAnswer && (

{documentRequest.label}

{/* Said before they send it: where a document goes is what the patient is deciding about. */} { if (!stillOpen(opening)) return; setError(undefined); setAttachment(next); }} />
)}
{/* Present but unavailable while there is nothing to send, so the patient can see what choosing a file is for. */} {!checklistCloses && ( )}
)} {/* A send kept for this request is checked even after its request stopped asking for the file. */} {allowPatientActions && !isActionable && pendingAnswer && (

{documentRequest?.label ?? KEPT_ANSWER_LABEL}

{pendingAnswer === 'saved' ? SAVED_NOT_SETTLED : SEND_UNCONFIRMED}

)}
); };