import { EarlierSaveNotice } from '../../../../components/EarlierSaveNotice/EarlierSaveNotice'; import { useEffect, useRef, useState } from "react"; import { isRecordChanged } from '../../../../utils/recordChanged'; import { outcomeUnknown, type SaveOperation } from '../../../../utils/saveOperation'; import { useNavigate, useSearchParams } from "react-router-dom"; import { Goal } from "fhir/r4"; import { FhirGoalDisplay } from "../../../../components"; import { useFhirGoalBroadcast, useFhirGoals, useFhirGoalTargetedBroadcast, } from "../../../../hooks"; import { ChevronLeft, Edit, FileText, Plus } from "lucide-react"; import { Skeleton } from "../../../../components/ui/skeleton.tsx"; import { Sheet, SheetContent } from "../../../../components/ui/sheet"; import { Button } from "../../../../components/ui/button.tsx"; import { useAuthServiceContext } from "../../../../services"; import { useViewerIsPatient } from "../../../../hooks"; import { GoalRowCard } from "./GoalRowCard"; import { GoalDetail } from "./GoalDetail"; import { SetYourOwnGoal } from "./SetYourOwnGoal"; import { useGoalRows, type GoalListItem } from "./useGoalRows"; import { mayEditRecord } from '../../../../utils/recordEditability'; import { useSeatStanding } from '../../../../utils/seatStanding'; import { useIsPhoneWidth } from '../../../../hooks/useIsPhoneWidth'; import "./Goals.css"; const Goals = () => { const navigate = useNavigate(); const { userRole, USER_ROLES, practitionerRoles } = useAuthServiceContext(); const seatStanding = useSeatStanding(); const viewerIsPatient = useViewerIsPatient(); const { loading: goalsLoading, error, attempted, goalsList, loadGoals, uncheckedGoals = [], recheckGoal } = useFhirGoals(); // A read that has not answered yet is still loading, never an empty list. const loading = goalsLoading || !attempted; const [selectedGoal, setSelectedGoal] = useState(null); const [showDisplayModal, setShowDisplayModal] = useState(false); useFhirGoalBroadcast(); useFhirGoalTargetedBroadcast({ goal: selectedGoal || undefined, isOpen: showDisplayModal, onUpdate: (updatedGoal) => { setSelectedGoal(updatedGoal); }, onDelete: () => { setShowDisplayModal(false); setSelectedGoal(null); }, }); // A patient on a phone drills into a goal rather than opening a sheet over the list (the goals // deck). The goal rides in the URL so the phone's own Back returns to the list. const phoneWidth = useIsPhoneWidth(); const drillDown = phoneWidth && viewerIsPatient; // THE PATIENT'S LAPTOP AND TABLET. The deck opens a goal beside the list (under it on a tablet) rather // than in a sheet over it, and filters the list by a pair of pills instead of two stacked sections. const paneMode = viewerIsPatient && !phoneWidth; const [showing, setShowing] = useState<'working' | 'reached'>('working'); const [searchParams, setSearchParams] = useSearchParams(); const goalParam = drillDown ? searchParams.get("goal") : null; const pushedGoal = useRef(false); const viewGoal = (goal: Goal) => { setSelectedGoal(goal); setShowDisplayModal(true); if (drillDown && goal.id) { pushedGoal.current = true; setSearchParams((params) => { params.set("goal", goal.id as string); return params; }); } }; const leaveGoal = () => { if (!goalParam) { setShowDisplayModal(false); setSelectedGoal(null); } else if (pushedGoal.current) { navigate(-1); } else { setSearchParams((params) => { params.delete("goal"); return params; }, { replace: true }); } }; useEffect(() => { if (!drillDown) return; if (!goalParam) { pushedGoal.current = false; setSelectedGoal(null); setShowDisplayModal(false); return; } const linked = goalsList.find((goal) => goal.id === goalParam); if (!linked) return; setSelectedGoal((current) => (current?.id === linked.id ? current : linked)); setShowDisplayModal(true); }, [drillDown, goalParam, goalsList]); // A receptionist too, because the server already admits one on every Goal write. The two gates // disagreeing is its own defect: a role the API allows and the screen does not offer looks to the // person holding it like a permission they were promised and denied. const roleAllowsEdit = () => { return userRole && [USER_ROLES.PRACTITIONER, USER_ROLES.RECEPTIONIST].includes(userRole as string); }; /* * AND THE ROLE IS NOT THE RULE THE SERVER APPLIES. * * Writing a Goal needs a paid clinician seat, resolved per request by `callerMayWrite`. The role * check above passes for an unseated practitioner - a medical assistant, a nurse rooming patients, * somebody whose seat was turned off this morning - so the page offered Add and Edit, and the save * came back refused. A refusal that arrives after the typing has already spent the work. */ const seat = mayEditRecord({ seatStanding, resourceType: 'Goal', viewerIsStaff: Boolean(userRole) && ![USER_ROLES.PATIENT, USER_ROLES.RELATED_PERSON].includes(userRole as string), practitionerRoles, }); const isAuthorizedToEdit = () => roleAllowsEdit() && seat.mayEdit; const reader = viewerIsPatient ? "patient" : "staff"; // Every goal in one place, whether a practitioner attached it to a care plan or the patient set it // themselves, with progress worked out from the readings that exist rather than read off the goal. const { working, reached, observations, tasks, loadingProgress, progressFailed, setOwnGoal, editOwnGoal, logReading, readingEarlier, checkReading, finishReading, newReading, startOpening, recoveries, recover, } = useGoalRows(goalsList); const [settingOwnGoal, setSettingOwnGoal] = useState(false); // The goal being changed. Held separately from `settingOwnGoal` so the same modal serves both // without either state having to mean two things. const [editingOwnGoal, setEditingOwnGoal] = useState(null); // Each opening of a goal sheet is its own: a late answer never closes or speaks into a later one. // Each opening also owns its requests: closing it retires them, whatever stage they are at. const sheetOpening = useRef(0); const openingOperation = useRef(); const startSheet = (open: boolean) => { sheetOpening.current += 1; openingOperation.current?.retire(); openingOperation.current = open ? startOpening() : undefined; }; const openEditor = (goal: Goal | null) => { startSheet(Boolean(goal)); setEditingOwnGoal(goal); }; const openCreator = (open: boolean) => { startSheet(open); setSettingOwnGoal(open); }; // A goal being read back after a refused or unconfirmed save is not opened until it is. const recoveryOf = (goal: Goal) => (goal.id ? recoveries[goal.id] : undefined); const editControl = (goal: Goal, className: string, withIcon: boolean) => { const state = recoveryOf(goal); if (state === "failed") { return ( We couldn’t check what is saved for this goal.{" "} ); } return ( ); }; // The patient edits their own and nobody else's, the rule the server enforces on the write. Staff // edit any goal their practice may write, except one written at another practice. const mayEditGoal = (item: GoalListItem) => viewerIsPatient ? Boolean(item.source?.selfSet) : Boolean(isAuthorizedToEdit()) && !item.fromAnotherPractice; // The retired goal page's addresses land here with the modal open, so old links still work. const editParam = searchParams.get("edit"); useEffect(() => { if (!editParam || loading) return; setSearchParams((params) => { params.delete("edit"); return params; }, { replace: true }); if (editParam === "new") { if (viewerIsPatient || isAuthorizedToEdit()) openCreator(true); return; } const item = [...working, ...reached].find((candidate) => candidate.goal.id === editParam); const goal = item?.goal ?? goalsList.find((candidate) => candidate.id === editParam); if (!goal) return; if (item && mayEditGoal(item)) { if (!recoveryOf(goal)) openEditor(goal); } else viewGoal(goal); // Runs once per arrival; the param is removed as it is read. // eslint-disable-next-line react-hooks/exhaustive-deps }, [editParam, loading]); // The opened goal, with its worked-out progress - not the raw resource, which knows nothing about how // it is going. Looked up rather than stored beside `selectedGoal` so a reading logged in the sheet // moves the numbers in it, since the readings reload and this recomputes from them. const selectedItem = [...working, ...reached] .find((item) => item.goal.id === selectedGoal?.id); // In the pane the newest working goal is already open, as the deck draws it, until another is chosen. const shownItems = showing === 'reached' ? reached : working; const paneItem = paneMode ? selectedItem ?? shownItems[0] ?? working[0] ?? reached[0] : undefined; // TWO SECTIONS, because a reached goal is not one you are working on. It retires - it leaves the // working list and nothing un-reaches it - and leaving it mixed in would draw the patient more // outstanding work than they actually have. const renderSection = (heading: string, items: GoalListItem[]) => { if (items.length === 0) return null; return (
{heading && ( viewerIsPatient ? (

{heading}

) : (

{heading}

) )}
{items.map((item) => { const { goal, row, source } = item; return (
{/* The row IS the control, so it must be reachable without a pointer. Enter and Space both, because role="button" promises both. The role sits on the CARD and not on the wrapper holding Edit - a button inside something that is itself a button is a control a screen reader cannot describe, and it is why the old row needed `stopPropagation` to work at all. */}
viewGoal(goal)} onKeyDown={(event: React.KeyboardEvent) => { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); viewGoal(goal); }} >
{mayEditGoal(item) && (viewerIsPatient ? editControl(goal, "btn btn-blue-ghost-sm ak-gl__edit", true) : editControl(goal, "btn btn-blue-ghost-sm ak-gl-staff__edit", false))}
); })}
); }; // KEYED BY THE GOAL. Moving from one goal to the next remounts the detail, so a reading half-typed // for the last goal, or anything read for it, can never stand under the next one's title. const detailFor = (item: GoalListItem) => ( logReading(item.goal, value, opening), startOpening, readingNotice: readingEarlier && ( void checkReading()} onFinish={() => void finishReading()} onStartNew={newReading} /> ), } : {})} /> ); const goalDetail = selectedItem && detailFor(selectedItem); // A goal that changed and could not be read again stays shown; this says so and offers the read again. const uncheckedNotice = (goal: Goal) => (goal.id && uncheckedGoals.includes(goal.id) ? (

“{goal.description?.text ?? "This goal"}” changed and could not be checked again, so it may be out of date. {" "}

) : null); if (drillDown && selectedGoal && selectedItem) { return (

Goals

{/* The same order as the laptop: the drawn view, then the fields and their history. */} {uncheckedNotice(selectedGoal)} {goalDetail}
); } return (
{/* The staff chart's band and trail already name the page, so its deck draws no title here. */} {viewerIsPatient ?

Goals

:

Goals

}
{/* SAID, NOT WITHHELD. A control that simply disappears reads as a broken screen or as a permission the reader has lost, and both send them to an administrator to check permissions that are already correct. A seat is nameable, and a practice can enable one in a minute. */} {roleAllowsEdit() && !seat.mayEdit && (

{seat.reason}

)} {/* One goal modal for both readers, opened over the list. */} {viewerIsPatient ? ( ) : isAuthorizedToEdit() && ( )}
{settingOwnGoal && ( { const opening = sheetOpening.current; await setOwnGoal(input, openingOperation.current); if (sheetOpening.current === opening) openCreator(false); }} onCancel={() => openCreator(false)} /> )} {editingOwnGoal && ( { const opening = sheetOpening.current; const editing = editingOwnGoal; try { await editOwnGoal(editing, input, openingOperation.current); } catch (failure) { // A sheet closed meanwhile is told nothing and starts nothing; the one now open is not its to answer. if (sheetOpening.current !== opening) return; // Refused, or its answer lost: the goal is read back by id before it can be opened again. if ((isRecordChanged(failure) || outcomeUnknown(failure)) && editing.id) recover(editing.id); throw failure; } if (sheetOpening.current === opening) openEditor(null); }} onCancel={() => openEditor(null)} /> )} {error ? (
{" "}

Could not load Goals

Try again later.

) : !loading && goalsList.length === 0 ? (

No goals found

{viewerIsPatient ? 'You have no active health goals.' : "No health goals yet. Add a goal for this patient's care."}

) : loading ? (
{[...Array(7)].map((_, index) => (
))}
) : ( goalsList.length !== 0 && (
{progressFailed && (

Readings could not be loaded just now, so progress may be out of date.

)} {goalsList.map(uncheckedNotice)} {paneMode ? (
{(['working', 'reached'] as const).map((which) => ( ))}
{shownItems.length > 0 ? renderSection('', shownItems) :

{showing === 'reached' ? 'No goal has been reached yet.' : 'Nothing you are working towards right now.'}

}
{paneItem && ( <> {detailFor(paneItem)} setSelectedGoal(null)} inPage /> )}
) : ( <> {renderSection('Working towards', working)} {renderSection('Reached and retired', reached)} )}
) )} {selectedGoal && !drillDown && !paneMode && ( { if (!open) setSelectedGoal(null); }} > {/* The opened goal and the fields together are taller than a phone, so the sheet scrolls. */} {/* The drawn view first - where it stands and the readings that moved it, which is what makes the bar checkable rather than something to take on trust. The full field view stays underneath it: it carries the per-field history every other record surface has, and dropping it would take that away from staff to gain nothing. */} {uncheckedNotice(selectedGoal)} {goalDetail} { setShowDisplayModal(false); setSelectedGoal(null); }} /> )}
); }; export default Goals; // import { useNavigate } from 'react-router-dom'; // import { Goal } from 'fhir/r4'; // import { AppSpinner, FhirGoalDisplay, ScrollableTab } from '../../../../components'; // import { formatFhirCodeableConcept } from '../../../../utils/fhirCodeableConcept.tsx'; // import { useFhirGoalBroadcast, useFhirGoals, useFhirGoalTargetedBroadcast } from '../../../../hooks'; // import { useAuthServiceContext, useGoalServiceContext } from '../../../../services'; // const Goals = () => { // const navigate = useNavigate(); // const { userRole, USER_ROLES } = useAuthServiceContext(); // const { getGoalDisplay } = useGoalServiceContext(); // const { loading, error, clearError, goalsList } = useFhirGoals(); // const [isGuideOpen, setIsGuideOpen] = useState(false); // const [selectedGoal, setSelectedGoal] = useState(null); // const [showDisplayModal, setShowDisplayModal] = useState(false); // const spacerMargin = '24px'; // useFhirGoalBroadcast(); // useFhirGoalTargetedBroadcast({ // goal: selectedGoal || undefined, // isOpen: showDisplayModal, // onUpdate: (updatedGoal) => { // setSelectedGoal(updatedGoal); // }, // onDelete: () => { // setShowDisplayModal(false); // setSelectedGoal(null); // } // }); // const viewGoal = (goal: Goal) => { // setSelectedGoal(goal); // setShowDisplayModal(true); // }; // const editGoal = (event: React.MouseEvent, goal: Goal) => { // event.stopPropagation(); // navigate(goal.id as string, { relative: 'path' }); // }; // const addGoal = () => { // navigate('new', { relative: 'path' }); // }; // const isAuthorizedToEdit = () => { // return userRole && [USER_ROLES.PRACTITIONER].includes(userRole as string); // }; // const getStatusDisplay = (goal: Goal): { text: string, color?: string } => { // if (!goal) return { text: 'Unknown' }; // switch (goal.lifecycleStatus) { // case 'proposed': // return { text: 'Proposed', color: '#757575' }; // case 'planned': // return { text: 'Planned', color: '#4f94d4' }; // case 'accepted': // return { text: 'Accepted', color: '#4f94d4' }; // case 'active': // return { text: 'Active', color: '#00a32a' }; // case 'on-hold': // return { text: 'On Hold', color: '#e39700' }; // case 'completed': // return { text: 'Completed', color: '#00a32a' }; // case 'cancelled': // return { text: 'Cancelled', color: '#cc1818' }; // case 'entered-in-error': // return { text: 'Error', color: '#cc1818' }; // case 'rejected': // return { text: 'Rejected', color: '#cc1818' }; // default: // return { text: goal.lifecycleStatus?.charAt(0).toUpperCase() + goal.lifecycleStatus?.slice(1) || 'Unknown' }; // } // }; // const getAchievementStatusDisplay = (goal: Goal): string => { // if (!goal.achievementStatus) return ''; // return formatFhirCodeableConcept(goal.achievementStatus); // }; // const getCategoryDisplay = (goal: Goal): string => { // if (!goal.category || goal.category.length === 0) { // return ''; // } // return goal.category.map(cat => formatFhirCodeableConcept(cat)).join(', '); // }; // const getTargetCount = (goal: Goal): number => { // return goal.target?.length || 0; // }; // return ( // // setIsGuideOpen(false)} // resourceType="Goal"/> // // // // // // // {isAuthorizedToEdit() && ( // // // // )} // // // {error && ( // <> // // {error} // // // // )} // {!loading && goalsList.length === 0 && ( // <> // // No goals found. // // // // )} // <> // {loading ? ( // // ): ( // // {goalsList.length !== 0 && ( // // {goalsList.map((goal: Goal) => { // const displayTitle = getGoalDisplay(goal); // const { text: statusText, color: statusColor } = getStatusDisplay(goal); // const achievementStatus = getAchievementStatusDisplay(goal); // const category = getCategoryDisplay(goal); // const targetCount = getTargetCount(goal); // return ( // viewGoal(goal)}> // // // // // // // // // {displayTitle} // // {targetCount > 0 && ( // // {targetCount} target{targetCount !== 1 ? 's': ''} // // )} // {achievementStatus && ( // // Achievement: {achievementStatus} // // )} // {category && ( // // Category: {category} // // )} // // {isAuthorizedToEdit() && ( // // // // )} // // // // // ); // })} // // )} // // )} // // {selectedGoal && ( // { // setShowDisplayModal(false); // setSelectedGoal(null); // }} // /> // )} // // ); // }; // export default Goals;