Skip to content
All components

Care Team Panel

Stable

CareTeamPatient 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-team

First 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.

Live · synthetic data
  • 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, MDPrior psychiatrist

The person on the record is not always the person to contact. Both facts are shown.

Current teamCovered memberPast membersNon-clinical membersNo team recordedRole not recorded

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
Care Team Panel props
PropTypeDefault
asOf

Date | undefinednew 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 | undefinedtrue
team

CareTeam | undefined

Guidance

When to use it, and when not to.

DECISION SURFACE / 6 RULES

Recommended context

Use it

03
  • 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

03
  • 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

03
  • 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 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>
  );
}