import { useCallback, useEffect, useMemo, useState } from 'react'; import { ActivityDefinition, Bundle, Extension, Questionnaire } from 'fhir/r4'; import { AlertCircle, ChevronDown, ChevronRight, ListChecks } from 'lucide-react'; import { useActivityDefinitionServiceContext } from '../../../services/ActivityDefinitionService.tsx'; import { useQuestionnaireServiceContext } from '../../../services/QuestionnaireService.tsx'; import { useOptionalPublicFormService } from '../../../services/PublicFormService.tsx'; import { Button } from '../../../components/ui/button.tsx'; import { Input } from '../../../components/ui/input.tsx'; import { Textarea } from '../../../components/ui/textarea.tsx'; import { Label } from '../../../components/ui/label.tsx'; import { AKASHIC_TASK_DOCUMENT_REQUEST_EXTENSION, getTaskDocumentRequest, withDocumentRequestExtension, } from '../../../utils/documentRequestTask'; import { Skeleton } from '../../../components/ui/skeleton.tsx'; import { EmptyState } from '../../../components/ui/empty-state.tsx'; import { DismissibleAlert } from '../../../components/ui/dismissible-alert'; import DiscardChangesDialog from '../../../components/DiscardChangesDialog/DiscardChangesDialog'; import { listReadState } from '../../../utils/listReadState'; import { sectionReadFailure, type SectionReadFailure } from '../../../utils/sectionReadRefusal'; import { describeServerRefusal } from '../../../utils/serverRefusalMessage'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../../../components/ui/select.tsx'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from '../../../components/ui/dialog.tsx'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '../../../components/ui/alert-dialog.tsx'; import './AutomatedTasksPage.css'; import { AUTO_ASSIGN_PRIORITY_URL, AUTO_ASSIGN_TRIGGER_OPTIONS, AUTO_ASSIGN_TRIGGER_URL, AutoAssignTrigger, } from '../../../config/autoAssign'; import { assignedWhen, getTrigger, questionnaireEligibility, triggerHelp } from './automatedTaskRules'; import { CheckboxControl } from '../../../components/ui/checkbox-control'; import { newChecklistStepId, readChecklist, withChecklistSteps, type ChecklistStep } from '../../../utils/taskChecklist'; type AutomatedTaskKind = 'custom' | 'questionnaire'; type QuestionnaireTrigger = Exclude; // An automated task carries no priority. Whoever wires the automation up is frequently not an employee // of the practice, and an urgency on something that reaches a patient before an appointment is a clinical // signal that is not theirs to set. FHIR always carries `routine`. const AUTOMATED_TASK_PRIORITY = 'routine'; interface AutomatedTaskRow { kind: AutomatedTaskKind; id: string; title: string; trigger: AutoAssignTrigger; resource: ActivityDefinition | Questionnaire; } interface CustomFormState { title: string; description: string; trigger: AutoAssignTrigger; /** What the patient is asked to send, or empty when this task asks for no file. */ documentRequest: string; steps: ChecklistStep[]; } /** * A new rule starts with no trigger, so the reader has to choose one. */ const UNCHOSEN_TRIGGER = '' as const; interface QuestionnaireFormState { questionnaireId: string; trigger: QuestionnaireTrigger | typeof UNCHOSEN_TRIGGER; } const DEFAULT_CUSTOM_FORM: CustomFormState = { title: '', description: '', trigger: 'off', documentRequest: '', steps: [], }; const DEFAULT_QUESTIONNAIRE_FORM: QuestionnaireFormState = { questionnaireId: '', trigger: UNCHOSEN_TRIGGER, }; const QUESTIONNAIRE_TRIGGER_OPTIONS = AUTO_ASSIGN_TRIGGER_OPTIONS.filter( (option) => option.value !== 'off' ) as { value: QuestionnaireTrigger; label: string }[]; // Priority is always routine: a questionnaire has no native priority field. const setQuestionnaireAutoAssign = ( questionnaire: Questionnaire, trigger: QuestionnaireTrigger ): Questionnaire => { const otherExtensions = (questionnaire.extension ?? []).filter( (ext) => ext.url !== AUTO_ASSIGN_TRIGGER_URL && ext.url !== AUTO_ASSIGN_PRIORITY_URL ); return { ...questionnaire, extension: [ ...otherExtensions, { url: AUTO_ASSIGN_TRIGGER_URL, valueCode: trigger }, { url: AUTO_ASSIGN_PRIORITY_URL, valueCode: AUTOMATED_TASK_PRIORITY }, ], }; }; const clearQuestionnaireAutoAssign = (questionnaire: Questionnaire): Questionnaire => { const remaining = (questionnaire.extension ?? []).filter( (ext) => ext.url !== AUTO_ASSIGN_TRIGGER_URL && ext.url !== AUTO_ASSIGN_PRIORITY_URL ); const updated: Questionnaire = { ...questionnaire }; if (remaining.length > 0) { updated.extension = remaining; } else { delete updated.extension; } return updated; }; type EditorStep = 'choose' | AutomatedTaskKind; interface EditorState { mode: 'create' | 'edit'; step: EditorStep; row: AutomatedTaskRow | null; } const questionnaireName = (questionnaire: Questionnaire) => questionnaire.title || questionnaire.name || 'Untitled'; const AutomatedTasksPage = () => { const { fetchActivityDefinitions, createActivityDefinition, updateActivityDefinition, deleteActivityDefinition, } = useActivityDefinitionServiceContext(); const { fetchQuestionnaires, updateQuestionnaire } = useQuestionnaireServiceContext(); const publicForms = useOptionalPublicFormService(); const listPublicForms = publicForms?.listPublicForms; const [rows, setRows] = useState([]); const [allQuestionnaires, setAllQuestionnaires] = useState([]); // Null while public forms are unread, so the picker offers nothing rather than guessing. const [publicQuestionnaireIds, setPublicQuestionnaireIds] = useState | null>(null); const [loading, setLoading] = useState(true); const [loadFailure, setLoadFailure] = useState(null); const [editor, setEditor] = useState(null); const [customForm, setCustomForm] = useState(DEFAULT_CUSTOM_FORM); // Separate from the label so the switch can be on with the field empty, which save refuses. const [asksForDocument, setAsksForDocument] = useState(false); // The optional sections start collapsed unless the task already uses them. const [documentOpen, setDocumentOpen] = useState(false); const [checklistOpen, setChecklistOpen] = useState(false); const [questionnaireForm, setQuestionnaireForm] = useState(DEFAULT_QUESTIONNAIRE_FORM); const [baseline, setBaseline] = useState(''); const [isSaving, setIsSaving] = useState(false); const [formError, setFormError] = useState(null); const [saveFailed, setSaveFailed] = useState(false); const [confirmDiscard, setConfirmDiscard] = useState(false); const [previewing, setPreviewing] = useState(false); // Said only once the server has confirmed the write. const [savedTitle, setSavedTitle] = useState(null); const [rowToDelete, setRowToDelete] = useState(null); const [isDeleting, setIsDeleting] = useState(false); const [deleteFailure, setDeleteFailure] = useState(null); const loadRows = useCallback(async () => { setLoading(true); setLoadFailure(null); try { const [adBundle, qBundle, published]: [Bundle, Bundle, ReadonlySet | null] = await Promise.all([ fetchActivityDefinitions(), fetchQuestionnaires(100), listPublicForms ? listPublicForms() .then((forms) => new Set(forms.map((form) => form.questionnaireId)) as ReadonlySet) .catch(() => null) : Promise.resolve(null), ]); const activityDefinitions = (adBundle.entry?.map((entry) => entry.resource).filter(Boolean) ?? []) as ActivityDefinition[]; const questionnaires = (qBundle.entry?.map((entry) => entry.resource).filter(Boolean) ?? []) as Questionnaire[]; const customRows: AutomatedTaskRow[] = activityDefinitions.map((resource) => ({ kind: 'custom', id: resource.id ?? '', title: resource.title || 'Untitled task', trigger: getTrigger(resource), resource, })); // Only questionnaires with a trigger are rows; the rest are candidates for the picker. const questionnaireRows: AutomatedTaskRow[] = questionnaires .filter((resource) => getTrigger(resource) !== 'off') .map((resource) => ({ kind: 'questionnaire', id: resource.id ?? '', title: questionnaireName(resource), trigger: getTrigger(resource), resource, })); setAllQuestionnaires(questionnaires); setPublicQuestionnaireIds(published); setRows([...customRows, ...questionnaireRows]); } catch (err) { console.error('Error loading automated tasks:', err); setLoadFailure(sectionReadFailure(err)); } finally { setLoading(false); } }, [fetchActivityDefinitions, fetchQuestionnaires, listPublicForms]); useEffect(() => { loadRows(); }, [loadRows]); const pickableQuestionnaires = useMemo( () => allQuestionnaires .filter((q) => !!q.id) .map((q) => ({ questionnaire: q, eligibility: questionnaireEligibility(q, publicQuestionnaireIds) })) .filter(({ eligibility }) => eligibility === 'eligible' || eligibility === 'draft'), [allQuestionnaires, publicQuestionnaireIds] ); const hasEligibleQuestionnaire = pickableQuestionnaires.some(({ eligibility }) => eligibility === 'eligible'); const snapshot = JSON.stringify({ customForm, asksForDocument, questionnaireForm }); const isDirty = editor !== null && editor.step !== 'choose' && snapshot !== baseline; const openEditor = ( next: EditorState, custom: CustomFormState, asks: boolean, questionnaire: QuestionnaireFormState, ) => { setCustomForm(custom); setAsksForDocument(asks); setDocumentOpen(asks); setChecklistOpen(custom.steps.some((step) => step.instruction.trim())); setQuestionnaireForm(questionnaire); setBaseline(JSON.stringify({ customForm: custom, asksForDocument: asks, questionnaireForm: questionnaire })); setFormError(null); setSaveFailed(false); setConfirmDiscard(false); setSavedTitle(null); setPreviewing(false); setEditor(next); }; const openCreate = () => openEditor( { mode: 'create', step: 'choose', row: null }, DEFAULT_CUSTOM_FORM, false, DEFAULT_QUESTIONNAIRE_FORM, ); const openEdit = (row: AutomatedTaskRow) => { if (row.kind === 'custom') { const resource = row.resource as ActivityDefinition; const request = getTaskDocumentRequest(resource); openEditor( { mode: 'edit', step: 'custom', row }, { title: resource.title || '', description: resource.description || '', trigger: row.trigger, // Read back so saving an untouched task does not drop the request it already had. documentRequest: request?.label ?? '', steps: readChecklist(resource), }, request !== undefined, DEFAULT_QUESTIONNAIRE_FORM, ); } else { openEditor( { mode: 'edit', step: 'questionnaire', row }, DEFAULT_CUSTOM_FORM, false, { questionnaireId: row.id, trigger: row.trigger === 'off' ? UNCHOSEN_TRIGGER : row.trigger }, ); } }; const closeEditor = () => { setConfirmDiscard(false); setPreviewing(false); setEditor(null); }; const requestClose = () => { if (isSaving) return; if (isDirty) { setConfirmDiscard(true); return; } closeEditor(); }; const handleSaveCustom = async () => { const editingRow = editor?.mode === 'edit' ? editor.row : null; const title = customForm.title.trim(); const description = customForm.description.trim(); if (!title) { setFormError('Task title is required.'); return; } if (!description) { setFormError('Instructions are required.'); return; } // A request with no label reaches the patient as a file control naming nothing. if (asksForDocument && !customForm.documentRequest.trim()) { setFormError('Say what the patient should send.'); return; } if (customForm.steps.some((step) => !step.instruction.trim())) { setFormError('Each checklist step needs an instruction.'); return; } setIsSaving(true); setFormError(null); setSaveFailed(false); try { const carriedOver: Extension[] = ( editingRow ? ((editingRow.resource as ActivityDefinition).extension ?? []) : [] ).filter((existing) => existing.url !== AUTO_ASSIGN_TRIGGER_URL && existing.url !== AKASHIC_TASK_DOCUMENT_REQUEST_EXTENSION); const extension: Extension[] = withChecklistSteps( withDocumentRequestExtension( [ ...carriedOver, ...(customForm.trigger !== 'off' ? [{ url: AUTO_ASSIGN_TRIGGER_URL, valueCode: customForm.trigger }] : []), ], customForm.documentRequest, ), customForm.steps, ); if (editingRow) { // Spread the existing resource so id and meta (the org tag) survive the PUT. // HealthLake refuses an empty array, so a rule with nothing left carries no extension key. const { extension: _previous, ...existing } = editingRow.resource as ActivityDefinition; await updateActivityDefinition({ ...existing, title, description, priority: AUTOMATED_TASK_PRIORITY, ...(extension.length > 0 ? { extension } : {}), }); } else { await createActivityDefinition({ resourceType: 'ActivityDefinition', status: 'active', title, description, priority: AUTOMATED_TASK_PRIORITY, ...(extension.length > 0 ? { extension } : {}), }); } closeEditor(); setSavedTitle(title); await loadRows(); } catch (err) { console.error('Error saving custom automated task:', err); setSaveFailed(true); } finally { setIsSaving(false); } }; const handleSaveQuestionnaire = async () => { const editingRow = editor?.mode === 'edit' ? editor.row : null; setFormError(null); if (questionnaireForm.trigger === UNCHOSEN_TRIGGER) { setFormError('Choose when this questionnaire should be assigned.'); return; } let target: Questionnaire | undefined; if (editingRow) { target = editingRow.resource as Questionnaire; } else { if (!questionnaireForm.questionnaireId) { setFormError('Select a questionnaire.'); return; } // Re-checked at save: a draft or public form never becomes a patient task. const selected = allQuestionnaires.find((q) => q.id === questionnaireForm.questionnaireId); if (!selected || questionnaireEligibility(selected, publicQuestionnaireIds) !== 'eligible') { setFormError('Only active clinical questionnaires without an existing rule can be assigned.'); return; } target = selected; } setIsSaving(true); setSaveFailed(false); try { await updateQuestionnaire(setQuestionnaireAutoAssign(target, questionnaireForm.trigger)); closeEditor(); setSavedTitle(questionnaireName(target)); await loadRows(); } catch (err) { console.error('Error saving questionnaire automated task:', err); setSaveFailed(true); } finally { setIsSaving(false); } }; const handleSave = () => { if (editor?.step === 'custom') { void handleSaveCustom(); } else if (editor?.step === 'questionnaire') { void handleSaveQuestionnaire(); } }; const confirmDelete = (row: AutomatedTaskRow) => { setDeleteFailure(null); setRowToDelete(row); }; const handleDelete = async () => { if (!rowToDelete) return; setIsDeleting(true); setDeleteFailure(null); try { if (rowToDelete.kind === 'custom') { await deleteActivityDefinition(rowToDelete.resource as ActivityDefinition); } else { // Clears the rule only; the questionnaire itself is never deleted. await updateQuestionnaire(clearQuestionnaireAutoAssign(rowToDelete.resource as Questionnaire)); } setRowToDelete(null); closeEditor(); setSavedTitle(null); await loadRows(); } catch (err) { console.error('Error removing automated task:', err); setDeleteFailure( describeServerRefusal( err, rowToDelete.kind === 'custom' ? 'The task was not deleted' : 'The assignment was not removed', ), ); } finally { setIsDeleting(false); } }; const readState = listReadState(loading, loadFailure, rows.length); // Nothing is changed from a list whose last read was not confirmed. const unverified = loading || loadFailure !== null; const rowList = ( <>
{rows.map((row) => ( ))}

{rows.length} {rows.length === 1 ? 'task' : 'tasks'} · Select a row to view or edit its setup.

); const renderList = () => { switch (readState) { case 'loading': return (
{[...Array(3)].map((_, index) => (
))}
); // No retry: the read was refused and pressing anything again produces the same refusal. case 'denied': return (
You do not have access to this practice’s automated tasks. That is a refusal rather than a loading problem, so trying again will not change it. Ask an administrator if you need it.
); case 'failed': return (
Unable to load automated tasks We couldn’t confirm your saved tasks. Nothing has been changed.
); case 'empty': return ( ); case 'stale': return ( <> {loadFailure === 'denied' ? 'These automated tasks could not be refreshed, so what is shown may be out of date. The refresh was refused rather than slow, so trying again will not update it.' : 'These automated tasks could not be refreshed, so what is shown may be out of date.'} {loadFailure !== 'denied' && ( )} {rowList} ); default: return rowList; } }; const editorTitle = !editor ? '' : previewing ? 'Preview checklist' : editor.step === 'choose' ? 'New automated task' : editor.step === 'custom' ? (editor.mode === 'edit' ? 'Edit custom task' : 'New custom task') : (editor.mode === 'edit' ? 'Edit questionnaire assignment' : 'Assign a questionnaire'); const updateStep = (index: number, patch: Partial) => setCustomForm((prev) => ({ ...prev, steps: prev.steps.map((step, at) => (at === index ? { ...step, ...patch } : step)) })); const moveStep = (index: number, by: -1 | 1) => setCustomForm((prev) => { const steps = [...prev.steps]; const [moved] = steps.splice(index, 1); steps.splice(index + by, 0, moved); return { ...prev, steps }; }); const removeStep = (index: number) => setCustomForm((prev) => ({ ...prev, steps: prev.steps.filter((_, at) => at !== index) })); const addStep = () => setCustomForm((prev) => ({ ...prev, steps: [...prev.steps, { id: newChecklistStepId(), instruction: '', timing: '' }] })); // What the patient will see, read-only: staff never tick a patient step. const checklistPreview = (

{customForm.title.trim() || 'Untitled task'}

{customForm.steps.filter((step) => step.instruction.trim()).map((step) => (
))}
); const customFields = ( <>
setCustomForm((prev) => ({ ...prev, title: event.target.value }))} placeholder="e.g. Bring insurance card" />