import { useNavigate } from 'react-router-dom'; import { Item, ItemGroup } from "../../../../../components/ui/item-group"; import { FormProvider, useForm } from 'react-hook-form'; import { Icon } from "../../../../../components/ui/icon"; import { Flex, FlexItem, FlexBlock } from '../../../../../components/ui/flex'; import { Text } from '../../../../../components/ui/text'; import { Spacer } from '../../../../../components/ui/spacer'; import { Disabled } from '../../../../../components/ui/disabled'; import { Modal } from '../../../../../components/ui/wp-modal-shim'; // warning / check / commentEditLink are RETURNED BY getStatusIcon() and fed to the // WordPress icon component in the status row - a separate sweep, and invisible to a // naive per-icon grep because the reference is a `return`, not a JSX attribute. import { ExternalLink, Pencil, TriangleAlert as warning, Check as check, FilePen as commentEditLink } from 'lucide-react'; import { Questionnaire, QuestionnaireResponse } from 'fhir/r4'; import { useQuestionnaireServiceContext, useQuestionnaireResponseServiceContext, useAuthServiceContext } from '../../../../../services'; import { AppSpinner, ScrollableTab, FhirQuestionnaireResponseDisplay } from '../../../../../components'; import { ResourceListStatus } from '../../../../../components/ui/resource-list-status'; import { DismissibleAlert } from '../../../../../components/ui/dismissible-alert'; import { Button } from '../../../../../components/ui/button'; import { describeFetchFailure } from '../../../../../utils/fetchFailureMessage'; import { formatDate } from '../../../../../utils/dateUtils.ts'; import "./PatientQuestionnaireResponses.css"; import { useQuestionnaireTitles } from '../../../../../hooks/useQuestionnaireTitles'; import { ANSWERS_WITHHELD_MESSAGE, answersWithheld } from '../../../../../utils/answersWithheld'; import QuestionnairePreview from '../../../QuestionnairesPage/QuestionnairePage/QuestionnairePreview/QuestionnairePreview.tsx'; import { useFhirQuestionnaireResponseBroadcast, useFhirQuestionnaireResponses, useFhirQuestionnaireResponseTargetedBroadcast } from '../../../../../hooks'; import React, { useState } from 'react'; const QuestionnaireResponsesPage = () => { const navigate = useNavigate(); const { userRole, USER_ROLES } = useAuthServiceContext(); // A patient or helper reads "forms"; staff keep the record's own word. const formsReader = [USER_ROLES.PATIENT, USER_ROLES.RELATED_PERSON].includes(userRole as string); const { loading, error, clearError, questionnaireResponsesList, attempted, answersNotReadBack, retryAnswersNotReadBack } = useFhirQuestionnaireResponses(); const { fetchQuestionnaire, transformFromFhirQuestionnaire } = useQuestionnaireServiceContext(); const { getQuestionnaireResponseDisplay } = useQuestionnaireResponseServiceContext(); const formTitles = useQuestionnaireTitles(questionnaireResponsesList.map((response: QuestionnaireResponse) => response.questionnaire)); const [showResponse, setShowResponse] = useState(false); // - kept separate from the LIST's `error`: the list loaded fine, one row's viewer did not. const [viewError, setViewError] = useState(null); const [selectedQuestionnaireResponse, setSelectedQuestionnaireResponse] = useState(null); const [showDisplayModal, setShowDisplayModal] = useState(false); useFhirQuestionnaireResponseBroadcast(); useFhirQuestionnaireResponseTargetedBroadcast({ questionnaireResponse: selectedQuestionnaireResponse || undefined, isOpen: showDisplayModal, onUpdate: (updatedQuestionnaireResponse) => { setSelectedQuestionnaireResponse(updatedQuestionnaireResponse); }, onDelete: () => { setShowDisplayModal(false); setSelectedQuestionnaireResponse(null); } }); const methods = useForm({ defaultValues: {} }); const { reset, watch } = methods; const getStatusIcon = (status: string) => { switch (status) { case 'completed': return check; case 'in-progress': return commentEditLink; case 'entered-in-error': case 'stopped': return warning; default: return commentEditLink; } }; const getStatusDisplay = (questionnaireResponse: QuestionnaireResponse): { text: string, color?: string } => { if (!questionnaireResponse) return { text: 'Unknown' }; switch (questionnaireResponse.status) { case 'in-progress': return { text: 'In Progress', color: '#e39700' }; case 'completed': return { text: 'Completed', color: '#00a32a' }; case 'amended': return { text: 'Amended', color: '#4f94d4' }; case 'entered-in-error': return { text: 'Error', color: '#cc1818' }; case 'stopped': return { text: 'Stopped', color: '#cc1818' }; default: return { text: questionnaireResponse.status || 'Unknown' }; } }; const viewQuestionnaireResponse = (questionnaireResponse: QuestionnaireResponse) => { setSelectedQuestionnaireResponse(questionnaireResponse); setShowDisplayModal(true); }; const editQuestionnaireResponse = (event: React.MouseEvent, questionnaireResponse: QuestionnaireResponse) => { event.stopPropagation(); navigate(questionnaireResponse.id as string, { relative: 'path' }); }; /** * - the catch used to be `console.error` and nothing else, so a failure here was * INDISTINGUISHABLE FROM A DEAD BUTTON. A patient pressed View Answers, the request 404'd on a * doubled path, and the screen did not move: no dialog, no message, no way to tell whether the * record was empty, still loading, or broken. The URL is fixed, but a fetch that fails silently * would hide the next failure just as completely. * * `describeFetchFailure` rather than a hand-written string, because whether to say * *"please try again"* is a property of the RESPONSE - telling someone to retry a 403 is an * instruction that can never succeed. */ const viewResponse = async (event: any, questionnaireResponse: QuestionnaireResponse) => { event.preventDefault(); event.stopPropagation(); setViewError(null); // No questionnaire recorded at all is a different fact from a failed load, and it is not an // error - there is simply nothing to render the answers against. if (!questionnaireResponse.questionnaire) { setViewError('These answers are not linked to a questionnaire, so they cannot be displayed.'); return; } try { const questionnaire: Questionnaire = await fetchQuestionnaire(questionnaireResponse.questionnaire); const transformedQuestionnaire = transformFromFhirQuestionnaire(questionnaire, questionnaireResponse); reset(transformedQuestionnaire); setShowResponse(true); } catch (error) { console.error('Failed to load questionnaire:', error); setViewError(describeFetchFailure(error, 'We could not open these answers')); } }; const isAuthorizedToEdit = () => { return userRole && [USER_ROLES.PRACTITIONER].includes(userRole as string); }; return (
{/* * 🔻 THE "Add" BUTTON IS GONE, because the screen it opened could not create anything. * * It navigated to `new`, which the route bound as a record id, so the editor fetched a * QuestionnaireResponse literally named "new" and announced "Failed to load questionnaire * response. Please try again." Nothing there could have worked: creating one requires * both a `questionnaire` and a `subject`, the editor sends neither, its questionnaire * picker is permanently disabled, and Save called the update path — a PUT to * `/QuestionnaireResponse/undefined`. * * ⚖️ Answers come from the two paths that hold a questionnaire: a patient completing one * assigned to them, and the conversion of a submitted public form. Recording answers on a * patient's behalf at the front desk is a genuine and unbuilt feature — a control that * cannot save is not a stand-in for it, it just moves the failure later. */} {/* Card 523 — what View Answers says when it cannot open. */} {answersNotReadBack > 0 && ( {answersNotReadBack === 1 ? 'A form attached to this patient could not be loaded yet.' : answersNotReadBack + ' forms attached to this patient could not be loaded yet.'} )} {viewError && ( setViewError(null)} className="mb-4"> {viewError} )} <> {loading ? ( ) : ( {questionnaireResponsesList.length !== 0 && ( {questionnaireResponsesList.map((questionnaireResponse: QuestionnaireResponse) => { const form = formTitles[questionnaireResponse.questionnaire ?? '']; const displayTitle = getQuestionnaireResponseDisplay(questionnaireResponse, form?.state === 'ready' ? form.title : undefined); const { text: statusText, color: statusColor } = getStatusDisplay(questionnaireResponse); const authoredDate = questionnaireResponse.authored ? formatDate(questionnaireResponse.authored) : ''; /* * THE ROW IS NOT A BUTTON, and that is the fix rather than a * preference. * * `Item` renders a )} {/* WHAT THE ROW USED TO DO, now saying so. It opens who answered, when, and what the response is attached to - a real thing to want, and previously reachable only by pressing a row that gave no sign it would do that. */} {isAuthorizedToEdit() && !answersWithheld(questionnaireResponse) && ( )} ); })} )} )} {/* Response Modal */} {showResponse && ( setShowResponse(false)}>
)} {/* Display Modal */} {selectedQuestionnaireResponse && ( { setShowDisplayModal(false); setSelectedQuestionnaireResponse(null); }} /> )}
); }; export default QuestionnaireResponsesPage;