Care Team Panel
StableCareTeamPatient identity · Clinical
The gap this closes is that the person on the record is frequently not the person to contact. A panel listing the assigned consultant at 2am with no indication they are off call is worse than no panel — it produces a confident call to a phone nobody is holding. So coverage sits beside the assignment rather than replacing it. Past members are kept as history, and caregivers, peer supports, and community health workers render with the same weight as clinicians, because in behavioral health and complex care they frequently are the team.
pnpm dlx shadcn@latest add @oxygenui/care-teamFirst install? Add "@oxygenui": "https://oxygenui.design/r/{name}.json" to the registries block of your components.json first — or skip the config and pass https://oxygenui.design/r/care-team.json directly.
Preview
Every state, switchable.
A. Bensouda, MDResponsible
Psychiatrist
Not on call — P. Ramanathan, PMHNP is covering until 07:00
J. Whitfield, LCSW
Therapist
M. Okonkwo
Spouse and caregiver
T. Alvarez
Peer support specialist
1 past team member
- S. Vukovic, MD — Prior psychiatrist
The person on the record is not always the person to contact. Both facts are shown.
Usage
Props are the FHIR resource.
import { CareTeamPanel } from "@/components/oxygen/care-team";
<CareTeamPanel
team={careTeam}
responsibleRef="Practitioner/bensouda"
coverage={{ "Practitioner/bensouda": { coveringName: "P. Ramanathan", until: "07:00" } }}
contacts={{ "Practitioner/bensouda": { phone: "+15551234567" } }}
/>Dependencies
- @oxygenui-design/fhir@^0.1.0
- lucide-react
- clsx
- tailwind-merge
| Prop | Type | Default |
|---|---|---|
| asOf | Date | undefined | new Date() |
| className | string | undefined | — |
| contacts Contact routes by member reference or name. | Record<string, { phone?: string; onMessage?: () => void; }> | undefined | {} |
| coverage Coverage by member reference or name. The record is not the roster. | Record<string, CoverageInfo> | undefined | {} |
| extraMembers Members not modelled in the CareTeam resource, e.g. community supports. | CareTeamMember[] | undefined | [] |
| label | string | undefined | "Care team" |
| responsibleRef Responsible clinician, pinned first. | string | undefined | — |
| showPast | boolean | undefined | true |
| team | CareTeam | undefined | — |
Guidance
When to use it, and when not to.
DECISION SURFACE / 6 RULES
Recommended context
Use it
- On any chart where someone might need to contact the team.
- With coverage supplied from the on-call schedule, not the assignment record.
- With community and caregiver members passed through extraMembers.
Guardrails
Don't
- Replacing the assigned clinician with the covering one. Both facts are needed.
- Deleting past members \u2014 reviews ask who was involved and when.
- Demoting non-clinical members to a footnote.
Quality
What was tested, and what is still missing.
- Named actions
- Contact buttons say the action and the person, not just an icon.
- Avatars decorative
- Initials are hidden from assistive technology; the name carries meaning.
- Coverage is text
- Off-call status is a sentence, not a colour or a dimmed row.
Open gaps
Known limitations
- Coverage is caller-supplied \u2014 there is no on-call schedule resolution here.
- Participants without a display name are skipped rather than shown as unknown.
- Does not model team hierarchy; order is source order with the responsible clinician pinned.
Source
Exactly what lands in your repository.
Read directly from the published registry, so this can never drift from what the CLI installs.
Show sourceHide source187 lines
"use client";
/**
* CareTeamPanel — everyone involved, and who is actually reachable now.
*
* The gap this closes: the person on the record is frequently not the person
* to contact. A panel that lists the assigned consultant at 2am, with no
* indication that they are not on call, is worse than no panel — it produces a
* confident call to a phone nobody is holding.
*
* So coverage is a first-class concept. The assigned clinician is shown, and
* the person covering them right now is shown next to it, rather than one
* silently replacing the other.
*
* Two further choices:
*
* - Past members are history, not deletions. "Who was looking after them in
* March" is a question reviews actually ask.
* - Caregivers, peer supports, and community health workers are rendered
* with the same weight as clinicians. In behavioral health and complex
* care they frequently are the team, and demoting them to a footnote
* misrepresents how the care actually happens.
*/
import * as React from "react";
import { Phone, Send, UserRound } from "lucide-react";
import {
careTeamMembers,
nameInitials,
type CareTeam,
type CareTeamMember,
} from "@oxygenui-design/fhir";
import { cn } from "@/lib/utils";
export interface CoverageInfo {
/** Who is actually reachable for this member right now. */
coveringName: string;
until?: string;
}
export interface CareTeamPanelProps {
team?: CareTeam;
/** Members not modelled in the CareTeam resource, e.g. community supports. */
extraMembers?: CareTeamMember[];
/** Coverage by member reference or name. The record is not the roster. */
coverage?: Record<string, CoverageInfo>;
/** Contact routes by member reference or name. */
contacts?: Record<string, { phone?: string; onMessage?: () => void }>;
/** Responsible clinician, pinned first. */
responsibleRef?: string;
showPast?: boolean;
asOf?: Date;
label?: string;
className?: string;
}
export function CareTeamPanel({
team,
extraMembers = [],
coverage = {},
contacts = {},
responsibleRef,
showPast = true,
asOf = new Date(),
label = "Care team",
className,
}: CareTeamPanelProps) {
const members = [...careTeamMembers(team, asOf), ...extraMembers];
const current = members.filter((m) => m.current);
const past = members.filter((m) => !m.current);
// The responsible clinician is pinned; everyone else keeps source order,
// because reordering a team by role implies a hierarchy the record does not
// state.
const ordered = [...current].sort((a, b) => {
const aResp = responsibleRef && (a.reference === responsibleRef || a.name === responsibleRef);
const bResp = responsibleRef && (b.reference === responsibleRef || b.name === responsibleRef);
return Number(bResp) - Number(aResp);
});
if (!members.length) {
return (
<section aria-label={label} className={className}>
<p className="text-[length:var(--ox-text-sm)] text-[var(--ox-text-subtle)]">
No care team recorded. This is not the same as having no care team — record who is
involved.
</p>
</section>
);
}
return (
<section aria-label={label} className={cn("flex flex-col gap-2", className)}>
<ul className="flex flex-col gap-1.5">
{ordered.map((member, index) => {
const key = member.reference ?? member.name;
const cover = coverage[key];
const contact = contacts[key];
const responsible =
responsibleRef &&
(member.reference === responsibleRef || member.name === responsibleRef);
return (
<li
key={`${key}-${index}`}
className="flex items-center gap-[var(--ox-density-gap)] rounded-[var(--ox-radius)] border border-[var(--ox-border)] bg-[var(--ox-surface)] px-[var(--ox-density-pad-x)] py-[var(--ox-density-pad-y)]"
>
<span
aria-hidden="true"
className="flex size-8 shrink-0 items-center justify-center rounded-[var(--ox-radius)] border border-[var(--ox-border)] bg-[var(--ox-bg-subtle)] text-[length:var(--ox-text-2xs)] font-semibold text-[var(--ox-text-muted)]"
>
{nameInitials(member.name) ?? <UserRound className="size-3.5" />}
</span>
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-center gap-1.5 text-[length:var(--ox-density-font)] font-semibold">
{member.name}
{responsible && (
<span className="rounded-[var(--ox-radius-sm)] border border-[var(--ox-accent-border)] bg-[var(--ox-accent-subtle)] px-1.5 py-0.5 text-[length:var(--ox-text-2xs)] font-bold uppercase tracking-wider text-[var(--ox-accent)]">
Responsible
</span>
)}
</p>
<p className="text-[length:var(--ox-text-xs)] text-[var(--ox-text-muted)]">
{member.role ?? "Role not recorded"}
</p>
{/* Coverage sits beside the assignment rather than replacing
it. Both facts are true and a caller needs both. */}
{cover && (
<p className="mt-0.5 text-[length:var(--ox-text-xs)] font-medium text-[var(--ox-status-high)]">
Not on call — {cover.coveringName} is covering
{cover.until ? ` until ${cover.until}` : ""}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{contact?.phone && (
<a
href={`tel:${contact.phone}`}
aria-label={`Call ${member.name}`}
className="rounded-[var(--ox-radius-sm)] border border-[var(--ox-border-strong)] p-1.5 text-[var(--ox-text-muted)] no-underline hover:bg-[var(--ox-bg-muted)]"
>
<Phone aria-hidden="true" className="size-3.5" />
</a>
)}
{contact?.onMessage && (
<button
type="button"
onClick={contact.onMessage}
// A bare icon is not a label. The action and its target
// are both in the accessible name.
aria-label={`Send a secure message to ${member.name}`}
className="rounded-[var(--ox-radius-sm)] border border-[var(--ox-border-strong)] p-1.5 text-[var(--ox-text-muted)] hover:bg-[var(--ox-bg-muted)]"
>
<Send aria-hidden="true" className="size-3.5" />
</button>
)}
</div>
</li>
);
})}
</ul>
{showPast && past.length > 0 && (
<details className="rounded-[var(--ox-radius)] border border-[var(--ox-border)] px-[var(--ox-density-pad-x)] py-1.5">
<summary className="cursor-pointer text-[length:var(--ox-text-xs)] font-semibold text-[var(--ox-text-muted)]">
{past.length} past team {past.length === 1 ? "member" : "members"}
</summary>
<ul className="mt-1.5 flex flex-col gap-1">
{past.map((member, index) => (
<li
key={`${member.reference ?? member.name}-${index}`}
className="text-[length:var(--ox-text-xs)] text-[var(--ox-text-subtle)]"
>
{member.name} — {member.role ?? "Role not recorded"}
</li>
))}
</ul>
</details>
)}
</section>
);
}