import { useNavigate, useSearchParams } from "react-router-dom"; import { ChevronRight, ClipboardList } from "lucide-react"; import { useEffect, useMemo } from "react"; import type { Appointment, Task } from "fhir/r4"; import { useFhirAppointments, useFhirCarePlans, useFhirCareTeams, useFhirGoals, useFhirObservations, useFhirTasks, } from "../../../../hooks"; import { usePracticeToday } from "../../../../hooks/usePracticeToday"; import { useFhirReferenceNames } from "../../../../hooks/useFhirReferenceNames"; import { planDueCounts, planDueLine, planSteps } from "../../../../utils/carePlanSteps"; import { EmptyState } from "../../../../components/ui/empty-state"; import { OrgBadge } from "../../../../components/ui/org-badge"; import { Skeleton } from "../../../../components/ui/skeleton"; import { relayServerFailure } from "../../../../utils/fetchFailureMessage"; import { buildPlanCards, buildTeam, initialsOf, plansSummary, type PlanCard, type TeamMember } from "./carePlanRows"; import CarePlanDialog, { type PlanStepsRead } from "./CarePlanDialog"; import { RecordPracticeNote, useRecordPracticeView, useRetireOnPracticeChange } from "../../../../contexts/RecordPracticeContext"; import "./CarePlansOverview.css"; /** The practice a plan was written at, as its card names it. */ const planCardPractice = (card: PlanCard) => card.organizationId; /** * Every plan the patient is on, what it asks of them, and who is looking after them. * * A PLAN A PATIENT CAN ONLY READ IS A DOCUMENT. The approved deck's ruling, and the reason each * activity inside a plan is a button rather than a line of text - it opens where you do it. The list * shows each plan as a card that opens the plan itself, over the list, as the deck draws it. * * DIET, GLASSES AND RISK ASSESSMENTS ARE NOT NAMED HERE ANY MORE. Spencer removed the "Also on file" * section on 2026-09-02; they are record DESTINATIONS and the record's navigation already lists all * three. This page answers "what am I meant to be doing", and they are not that. * * SO NOTHING ON THIS PAGE MAY STATE WHAT A PATIENT HAS WITHOUT ASKING. Every claim below comes from * a hook and falls back to an empty state; that is the property to preserve. */ /** The card's due line on the practice calendar, silent until the tasks and that clock are known. */ const PlanDue = ({ card, tasks, appointments }: { card: PlanCard; tasks: Task[] | undefined; appointments: Appointment[] }) => { const practice = usePracticeToday(card.organizationId); if (!tasks || practice.state !== "ready") return null; const line = planDueLine(planDueCounts(card.id, tasks, appointments, practice.today, practice.zone)); return line ? {line} : null; }; /** One clinician: a name still being read is a placeholder, and one that could not be read says so. */ const TeamRow = ({ member, names }: { member: TeamMember; names: ReturnType }) => { const name = member.name ?? (member.ref ? names.names[member.ref] : undefined); const pending = !name && member.ref !== undefined && !names.attempted.has(member.ref); return (
{pending ? : {name ? initialsOf(name) : ""}} {member.organization && ( {member.organization} )} {pending ? : name ? {name} : A clinician whose name could not be read just now} {member.role && {member.role}}
); }; const CarePlansOverview = () => { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const plans = useFhirCarePlans(); const goals = useFhirGoals(); const teams = useFhirCareTeams(); // Progress on a goal is measured, and both the practice and the patient supply readings. const observations = useFhirObservations(); // What each plan asks of the patient: the tasks assigned under it, and the visits they are due at. const tasks = useFhirTasks(); const { appointmentsList } = useFhirAppointments(); const appointments = useMemo(() => appointmentsList ?? [], [appointmentsList]); const stepsRead: PlanStepsRead = { answered: tasks.answered, failed: Boolean(tasks.error), stepsFor: (planId) => planSteps(planId, tasks.tasksList ?? [], appointments, observations.observationsList ?? []), }; // A read that has not answered is still loading, never an empty list. const loading = plans.loading || goals.loading || teams.loading || !plans.answered || !goals.attempted || !teams.answered; // A care team that could not be read is said in its own card; it is no reason to withhold the plans. const failure = plans.error || goals.error; // A 403 cannot be retried into a list, so it is named rather than offered again. const refused = Boolean((plans.error && plans.refused) || (goals.error && goals.refused)); const wholeRecord = buildPlanCards(plans.carePlansList, goals.goalsList, observations.observationsList); const allCards = [...wholeRecord.active, ...wholeRecord.finished]; // The record's practice context: its own plans under a named practice; the empty state speaks for the whole record. const practiceView = useRecordPracticeView(allCards, planCardPractice); const active = practiceView.shown.filter((card) => !card.finished); const finished = practiceView.shown.filter((card) => card.finished); const team = useMemo(() => buildTeam(teams.careTeamsList), [teams.careTeamsList]); // Only a member the team did not name is read, and only through the shared resolver. const unnamed = useMemo(() => team.filter((member) => !member.name).map((member) => member.ref), [team]); const names = useFhirReferenceNames(unnamed); // The open plan lives in the address, so it can be linked to and Back closes it. const openId = searchParams.get("plan") ?? undefined; const openPlan = (id: string) => { const next = new URLSearchParams(searchParams); next.set("plan", id); setSearchParams(next); }; const closePlan = () => { const next = new URLSearchParams(searchParams); next.delete("plan"); setSearchParams(next, { replace: true }); }; useRetireOnPracticeChange(() => { if (openId) closePlan(); }); // A plan opened by its address that is not in the practice in view is not shown under that practice's name. const outOfView = Boolean(openId) && allCards.some((card) => card.id === openId) && !practiceView.shown.some((card) => card.id === openId); useEffect(() => { if (outOfView) closePlan(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [outOfView]); const planCard = (card: PlanCard) => { return ( ); }; if (failure) { return ( ); } return (

Care plans

Your plans

{plansSummary(active)}
{active.length > 0 ? ( active.map(planCard) ) : loading ? (
) : wholeRecord.active.length === 0 && (
{/* THE DECK'S COPY, AND THE FIRST SENTENCE IS THE ONE DOING THE WORK. Every other list in this portal is one a patient can add to, so an empty one here invites them to look for the button that starts a care plan - and there is none, because a plan is written by a clinician. THE SECOND SENTENCE USED TO NAME THE WRONG TRIGGER: "When a practice starts one it appears here". A plan is invisible until the clinician who wrote it gives it to the patient, so a patient with two drafts on their record read that a practice had not started one. It mirrors the clinician's own control, which reads "Give it to the patient", and it asserts nothing: telling them a plan exists is the disclosure the draft rule is there to prevent. */}
)} {finished.length > 0 && ( <>

Finished

{finished.map(planCard)} )}

{/* The qualifier that used to sit here - "across every practice" - said in the heading what every row below it already says, since each one names the practice it comes from. */} Your clinical care team

{/* Loading is not the same as nobody, and the fetch polls - saying "no care team yet" while it is still arriving is a claim about the patient's practice that is untrue. */} {teams.error ? (

Your clinical care team could not be loaded just now. Please try again.

) : team.length === 0 && loading ? (
) : team.length > 0 ? ( team.map((member) => ) ) : ( )}
navigate(`care-plans/${card.id}`)} onOpenStep={(taskId) => navigate(`/dashboard/home/task/${taskId}`)} />
); }; export default CarePlansOverview;