import { useEffect, useRef, useState } from "react"; import { Task } from "fhir/r4"; import { toast } from "sonner"; import { isOutstandingTask, useAuthServiceContext, usePatientServiceContext, useTaskServiceContext, } from "../../services"; import { Textarea } from "../ui/textarea.tsx"; import { relayServerFailure } from '../../utils/fetchFailureMessage'; import { patientHasLogin } from '../../utils/cognitoIdentity'; import { statusLabel } from '../../utils/fhirStatusVocabulary'; import { viewerEpoch } from '../../utils/writeThroughHold'; import { WithdrawalVersionMissing, type UnresolvedWithdrawal, type WithdrawalAttempt, type WithdrawalOutcome, } from "../../services/TaskService.tsx"; interface TaskWithdrawActionProps { task: Task; onWithdrawn?: (task: Task) => void; } // What the form shows after a send that proved nothing: the stored task as last read, or that it could not be read. type Pending = | { kind: "sending" } | { kind: "unconfirmed" } | { kind: "unreadable" } | { kind: "changed"; status: string } | { kind: "unchecked" }; const pendingText = (pending: Pending): string => { switch (pending.kind) { case "sending": return "A withdrawal of this task is still being sent. Check it before doing anything else."; case "unconfirmed": return "We could not confirm the withdrawal. It may still finish."; case "unreadable": return "We could not confirm the withdrawal. Check the task list before trying again."; case "changed": return "This task changed: it is now " + (statusLabel(pending.status) ?? pending.status).toLowerCase() + "."; case "unchecked": return "This task could not be checked. Reload it and try again."; } }; const taskTitle = (task: Task): string => task.code?.text || task.description || "this task"; type Step = (attempt: WithdrawalAttempt) => Promise; const pendingFor = (unresolved: UnresolvedWithdrawal): Pending => unresolved.standing === "unreadable" ? { kind: "unreadable" } : unresolved.standing === "sending" ? { kind: "sending" } : { kind: "unconfirmed" }; // Staff-only "Withdraw" (cancel) action for a task - e.g. a questionnaire assigned by mistake. // Self-gating: renders nothing unless the viewer can assign tasks (practitioner / receptionist) // AND the task is still open. Kind-agnostic (custom or questionnaire). Uses an INLINE reason form // rather than a modal, because it sits inside a task-detail Sheet/Modal and a nested overlay would // misbehave. The required reason doubles as the confirmation. const TaskWithdrawAction = ({ task, onWithdrawn }: TaskWithdrawActionProps) => { const { userRole, USER_ROLES, actingAsSessionRef } = useAuthServiceContext(); const { beginWithdrawal, sendWithdrawal, readBackWithdrawal, unresolvedWithdrawal, withdrawalLive } = useTaskServiceContext(); const { patientState } = usePatientServiceContext(); // Only the open record can say whether this task's patient signs in; any other answer is unknown. const taskPatient = patientState?.id && task.for?.reference === `Patient/${patientState.id}` ? patientState : undefined; const notice = !taskPatient ? "The task will show as \"Withdrawn.\" Tell the patient why it's being withdrawn." : patientHasLogin(taskPatient) ? "The patient will see this task as \"Withdrawn.\" Tell them why it's being withdrawn." : "This patient has no portal sign-in, so tell them directly that the task is withdrawn, and why."; const [open, setOpen] = useState(false); const [reason, setReason] = useState(""); const [submitting, setSubmitting] = useState(false); const [pending, setPending] = useState(); // The one attempt this form may act on; an answer for any other, or after unmount, only reports. const attemptRef = useRef(); const mountedRef = useRef(true); // Production mounts are not keyed by Task, so a new Task or viewer is a new opening of this same action: every // continuation of an earlier opening leaves this one's form, busy state and notices alone. const openingRef = useRef(0); const epoch = viewerEpoch(); const actingAsSession = actingAsSessionRef?.current; const unresolvedRef = useRef(unresolvedWithdrawal); unresolvedRef.current = unresolvedWithdrawal; useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); useEffect(() => { openingRef.current += 1; // An attempt still unknown for this Task is taken up again, so a reopened form checks it instead of sending anew. const unresolved = task.id ? unresolvedRef.current(task.id) : undefined; attemptRef.current = unresolved?.attempt; setOpen(false); setSubmitting(false); setReason(unresolved?.attempt.reason ?? ""); setPending(unresolved ? pendingFor(unresolved) : undefined); }, [task.id, epoch, actingAsSession]); const canAssignTasks = Boolean( userRole && [USER_ROLES.PRACTITIONER, USER_ROLES.RECEPTIONIST].includes( userRole as string, ), ); if (!canAssignTasks || (!isOutstandingTask(task) && !pending)) { return null; } const live = (opening: number, attempt: WithdrawalAttempt) => mountedRef.current && openingRef.current === opening && attempt.taskId === task.id && withdrawalLive(attempt); const owns = (opening: number, attempt: WithdrawalAttempt) => live(opening, attempt) && attemptRef.current === attempt; const clear = () => { attemptRef.current = undefined; setOpen(false); setReason(""); setPending(undefined); }; // Closing never drops an attempt whose outcome is unknown: the service keeps it, and the form only closes. const cancel = () => { if (task.id && unresolvedWithdrawal(task.id)) { setOpen(false); return; } clear(); }; // The service has already settled the list. A form that has gone only reports an unproved outcome; a later opening of // this same mounted action is never told about an earlier one. const settle = (opening: number, attempt: WithdrawalAttempt, outcome: WithdrawalOutcome) => { if (!owns(opening, attempt)) { if (!mountedRef.current && withdrawalLive(attempt) && !["confirmed", "cancelled-other", "retired"].includes(outcome.kind)) { toast.error("Could not confirm the withdrawal of " + taskTitle(attempt.task) + ". Check it in the task list."); } return; } switch (outcome.kind) { case "confirmed": clear(); toast.success("Task withdrawn."); onWithdrawn?.(outcome.task); return; case "cancelled-other": clear(); toast.success("This task is already withdrawn."); onWithdrawn?.(outcome.task); return; case "refused": attemptRef.current = undefined; console.error("Failed to withdraw task:", outcome.error); toast.error(relayServerFailure(outcome.error, "Could not withdraw the task.")); return; case "outstanding-same-version": setPending({ kind: "unconfirmed" }); return; case "changed": setPending({ kind: "changed", status: outcome.status }); return; case "unreadable": setPending({ kind: "unreadable" }); return; case "retired": return; } }; const run = async (attempt: WithdrawalAttempt, step: Step) => { const opening = openingRef.current; setSubmitting(true); try { settle(opening, attempt, await step(attempt)); } catch (error) { if (live(opening, attempt)) toast.error(relayServerFailure(error, "Could not withdraw the task.")); } finally { if (live(opening, attempt)) setSubmitting(false); } }; const handleConfirm = async () => { if (!reason.trim() || submitting) { return; } // An unknown attempt for this Task, even one from an earlier opening, is checked rather than answered anew. const unresolved = task.id ? unresolvedWithdrawal(task.id) : undefined; if (unresolved) { attemptRef.current = unresolved.attempt; setReason(unresolved.attempt.reason); setPending(pendingFor(unresolved)); return; } let attempt: WithdrawalAttempt; try { attempt = beginWithdrawal(task, reason); } catch (error) { if (error instanceof WithdrawalVersionMissing) { setPending({ kind: "unchecked" }); } else { toast.error(relayServerFailure(error, "Could not withdraw the task.")); } return; } attemptRef.current = attempt; setPending(undefined); await run(attempt, sendWithdrawal); }; const again = (step: Step) => { const attempt = attemptRef.current; if (!attempt || submitting) return; void run(attempt, step); }; // An unsettled attempt is never answered with a fresh one: the same attempt is checked or resent. const attemptUnsettled = Boolean(attemptRef.current && pending && pending.kind !== "unchecked"); return (
{!open ? ( ) : (

Withdraw this task

{notice}