marmar-context / web / context / src / clinician / patient / PatientRail.tsx
PatientRail.tsx
Raw
import type { KeyboardEvent, ReactNode } from "react";
import { useEffect, useId, useRef, useState } from "react";
import type { PatientDetail } from "../../api/types";
import { details } from "../../logic/patient-detail";
import { cn, Icon, type IconName, type Tone } from "../kit";
import { ActivityList } from "./Activity";

export type RailTab = "details" | "activity";
const TABS: { id: RailTab; label: string }[] = [
  { id: "details", label: "Details" },
  { id: "activity", label: "Activity" },
];

/** The right rail: Details (what is shared and on file) and Activity (who did what), as two tabs. */
export function PatientRail({ patient, initial = "details" }: { patient: PatientDetail; initial?: RailTab }) {
  const [tab, setTab] = useState<RailTab>(initial);
  const base = useId();
  const refs = useRef<Record<RailTab, HTMLButtonElement | null>>({ details: null, activity: null });
  const aside = useRef<HTMLElement>(null);
  // Opened on Activity while the rail sits under the page (a phone): bring it into view.
  useEffect(() => {
    const el = aside.current;
    if (initial === "activity" && el && el.getBoundingClientRect().top > window.innerHeight) el.scrollIntoView({ block: "start" });
  }, [initial]);
  const onKey = (e: KeyboardEvent) => {
    if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return;
    e.preventDefault();
    const i = TABS.findIndex((t) => t.id === tab);
    const next = e.key === "Home" ? 0 : e.key === "End" ? TABS.length - 1 : (i + (e.key === "ArrowRight" ? 1 : -1) + TABS.length) % TABS.length;
    setTab(TABS[next].id);
    refs.current[TABS[next].id]?.focus();
  };
  return (
    <aside
      ref={aside}
      aria-label="About this patient"
      className={cn(
        "flex min-w-0 flex-col gap-[18px] pt-6 shadow-[inset_0_1px_0_var(--color-hairline)]",
        "@min-[960px]/pd:sticky @min-[960px]/pd:top-[72px] @min-[960px]/pd:ml-7 @min-[960px]/pd:max-h-[calc(100dvh-96px)] @min-[960px]/pd:self-start @min-[960px]/pd:overflow-y-auto @min-[960px]/pd:pt-[46px] @min-[960px]/pd:pl-7 @min-[960px]/pd:shadow-[inset_1px_0_0_var(--color-hairline)]",
      )}
    >
      <div role="tablist" aria-label="About this patient" onKeyDown={onKey} className="flex gap-[22px] shadow-[inset_0_-1px_0_var(--color-hairline)]">
        {TABS.map((t) => {
          const on = tab === t.id;
          return (
            <button
              key={t.id}
              ref={(el) => {
                refs.current[t.id] = el;
              }}
              type="button"
              role="tab"
              id={`${base}-${t.id}-tab`}
              aria-selected={on}
              aria-controls={`${base}-${t.id}`}
              tabIndex={on ? 0 : -1}
              onClick={() => setTab(t.id)}
              className={cn(
                "relative h-[38px] cursor-pointer rounded-sm border-0 bg-transparent px-0.5 font-sans text-[14px] font-medium transition-colors outline-none focus-visible:shadow-[inset_0_0_0_2px_var(--color-brand)]",
                on ? "text-ink after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:rounded-full after:bg-ink" : "text-muted hover:text-ink",
              )}
            >
              {t.label}
            </button>
          );
        })}
      </div>
      <div role="tabpanel" id={`${base}-${tab}`} aria-labelledby={`${base}-${tab}-tab`} tabIndex={0} className="rounded-md outline-none focus-visible:shadow-[0_0_0_2px_var(--color-brand)]">
        {tab === "details" ? <DetailsList patient={patient} /> : <ActivityList patient={patient} />}
      </div>
    </aside>
  );
}

// Literal strings so Tailwind finds every class.
const tile: Record<Tone, string> = {
  red: "bg-red-fill text-red-icon",
  amber: "bg-amber-fill text-amber-icon",
  green: "bg-green-fill text-green-icon",
  blue: "bg-blue-fill text-blue-icon",
  indigo: "bg-indigo-fill text-indigo-icon",
  purple: "bg-purple-fill text-purple-icon",
  pink: "bg-pink-fill text-pink-icon",
  grey: "bg-grey-fill text-grey-icon",
};

/** One detail: its name, a small tinted icon coloured by what it means, and the value with a quiet line under it. */
function Row({ icon, tone, label, children }: { icon: IconName; tone: Tone; label: string; children: ReactNode }) {
  return (
    <div className="grid grid-cols-[96px_minmax(0,1fr)] items-start gap-2 py-[9px]">
      <dt className="text-[13.5px] leading-[22px] text-muted">{label}</dt>
      <dd className="m-0 flex min-w-0 items-start gap-2">
        <span aria-hidden="true" className={cn("flex size-[22px] shrink-0 items-center justify-center rounded-md", tile[tone])}>
          <Icon name={icon} size={14} />
        </span>
        <span className="flex min-w-0 flex-col gap-0.5 pt-px text-[14px] leading-5 font-medium text-ink">{children}</span>
      </dd>
    </div>
  );
}

const Sub = ({ children }: { children: ReactNode }) => <small className="text-[12.5px] leading-[18px] font-normal text-subtle">{children}</small>;

function DetailsList({ patient }: { patient: PatientDetail }) {
  const d = details(patient);
  const s = patient.sharing;
  const shareTone: Tone = !s ? "grey" : s.status === "connected" ? "green" : s.status === "paused" ? "amber" : "grey";
  const callTone: Tone = d.calls === "Yasmar may call" ? "green" : "grey";
  return (
    <>
      <dl className="m-0 flex flex-col">
        <Row icon={s ? "user-heart-line" : "hospital-line"} tone={shareTone} label={d.sharedBy.label}>
          <span>{d.sharedBy.value}</span>
          <Sub>{d.sharedBy.note}</Sub>
        </Row>
        <Row icon="link" tone="blue" label="What is shared">
          {d.scopes ? (
            <ul className="m-0 flex list-none flex-col gap-1 p-0">
              {d.scopes.map((sc) => (
                <li key={sc.scope} className={cn("flex items-center gap-1.5", !sc.shared && "font-normal text-muted")}>
                  <Icon name={sc.shared ? "check-line" : "subtract-line"} size={14} className={sc.shared ? "text-green-icon" : "text-subtle"} />
                  <span>
                    {sc.label}
                    {!sc.shared && <span className="text-subtle"> · Not shared</span>}
                  </span>
                </li>
              ))}
            </ul>
          ) : (
            <span>{s ? "Nothing now" : "Nothing from the app"}</span>
          )}
          {d.scopesNote && <Sub>{d.scopesNote}</Sub>}
        </Row>
        <Row icon="phone-line" tone={callTone} label="Check-in calls">
          <span>{d.calls}</span>
        </Row>
        <Row icon="moon-line" tone="indigo" label="Quiet hours">
          <span>{d.quietHours}</span>
        </Row>
        <Row icon="id-card-line" tone={patient.identityOnFile === "none" ? "amber" : "pink"} label="Identity on file">
          <span>{d.identity}</span>
        </Row>
        {d.withheldSome && (
          <Row icon="eye-off-line" tone="amber" label="Held back">
            <span>Some medicines or conditions were not shared.</span>
          </Row>
        )}
      </dl>
      <p className="m-0 mt-4 pt-3 text-[12px] leading-[18px] text-muted shadow-[inset_0_1px_0_var(--color-hairline)]">
        What comes from MARMAR Health is read-only here. Your notes and checks stay in Context.
      </p>
    </>
  );
}