marmar-context / web / context / src / clinician / check / FindingCard.tsx
FindingCard.tsx
Raw
import { useState, type ReactNode } from "react";
import { clinicianApi } from "../../api/client";
import type { CheckRun, FindingMark, FindingMarkKind, PatientDetail } from "../../api/types";
import { formatDate } from "../../flow";
import { markLabels } from "../../logic/checks";
import { draftWordingPill } from "../../logic/pills";
import { allergyReactionLine, findingExtras, findingWording, ruleReviewLabel } from "../../logic/engine-extras";
import { tierOf, whenLabel } from "../../logic/format";
import { lookOf } from "../charts";
import { Button, cn, DialogActions, Drawer, FieldError, Icon, type IconName, labelClass, Modal, StatusPill, Tag, Textarea, type Tone } from "../kit";
import { AskYasmar } from "../patient/links";
import { Pill } from "../Pill";
import type { SourceRef } from "../patient/SourceDrawer";
import { errorText } from "../ui";

type Finding = CheckRun["assessment"]["contextual"]["findings"][number];

const markLook: Record<FindingMarkKind, { tone: Tone; icon: IconName }> = {
  reviewed: { tone: "green", icon: "checkbox-circle-fill" },
  not_relevant: { tone: "grey", icon: "forbid-fill" },
  problem: { tone: "red", icon: "flag-fill" },
};
const chip =
  "inline-flex max-w-full cursor-pointer flex-wrap items-center gap-x-1.5 gap-y-0.5 rounded-md border-0 bg-page px-2 py-1 text-left font-sans text-[12.5px] leading-[18px] text-body shadow-[inset_0_0_0_1px_var(--color-line)] outline-none hover:bg-tile focus-visible:shadow-[0_0_0_2px_var(--color-brand)]";

const Term = ({ term, children }: { term: string; children: ReactNode }) => (
  <div className="flex flex-col gap-1 py-2.5 shadow-[inset_0_-1px_0_var(--color-hairline)]">
    <dt className="text-[12.5px] text-muted">{term}</dt>
    <dd className="m-0 text-[13.5px] leading-5 text-ink">{children}</dd>
  </div>
);
const DrawerSub = ({ children }: { children: ReactNode }) => <h4 className="m-0 mt-5 mb-1 text-[13px] font-medium text-muted">{children}</h4>;
const refRow = "flex flex-col gap-1 py-2.5 text-[13px] leading-[19px] shadow-[inset_0_-1px_0_var(--color-hairline)]";

/** FND-1 to FND-3, FND-9: tier with its time, clinician wording, sources, why, marks, Ask Yasmar. */
export function FindingCard({
  finding,
  check,
  patient,
  mark,
  canAskYasmar,
  onSource,
  onMarked,
}: {
  finding: Finding;
  check: CheckRun;
  patient: PatientDetail;
  mark: FindingMark | undefined;
  canAskYasmar: boolean;
  onSource: (s: SourceRef) => void;
  onMarked: (m: FindingMark) => void;
}) {
  const detail = check.details[finding.id];
  const tier = tierOf(finding.tier);
  const look = lookOf(finding.tier);
  const isNew = check.assessment.addedRuleIds.includes(finding.ruleId);
  const [why, setWhy] = useState(false);
  const [marking, setMarking] = useState<FindingMarkKind | null>(null);
  const wording = findingWording(finding, detail);
  const title = wording.title;
  const review = ruleReviewLabel(finding, detail?.ruleReviewed);
  const reaction = allergyReactionLine(finding);
  const specific = findingExtras(finding).specific_references ?? [];
  const urgent = finding.tier === "get_help_now";
  return (
    <article
      aria-label={title}
      className={cn("flex flex-col gap-3 rounded-2xl bg-page p-5 shadow-card", urgent && "shadow-[inset_3px_0_0_var(--color-red-icon),0_0_0_1px_var(--color-red-line)]")}
    >
      <div className="flex flex-wrap items-center gap-2">
        <StatusPill tone={look.tone} icon={look.icon}>
          {tier.label}
        </StatusPill>
        <Tag lead="time-line">{tier.when}</Tag>
        {isNew && (
          <StatusPill tone="blue" size="sm" icon="sparkling-line">
            New with patient context
          </StatusPill>
        )}
        <Tag>For clinician review</Tag>
        {review && <Tag>{review}</Tag>}
        {wording.draft && <Pill pill={draftWordingPill} />}
      </div>
      <h3 className="m-0 text-[16px] leading-6 font-semibold tracking-[-0.01em] text-ink">{title}</h3>
      <p className="m-0 text-[13.5px] leading-5 text-body">{wording.why}</p>
      {reaction && <p className="m-0 text-[13px] leading-[19px] text-muted">{reaction}</p>}
      {detail && detail.factsUsed.length > 0 && (
        <div className="flex flex-wrap items-center gap-2">
          <small className="text-[12.5px] text-muted">Based on</small>
          {detail.factsUsed.map((f) => (
            <button key={f.factId} type="button" className={chip} onClick={() => onSource({ recordId: f.recordId, title: f.recordTitle, lines: [f.line] })}>
              <Icon name="file-text-line" size={14} className="text-muted" />
              <span className="font-medium text-ink">{f.label}</span>
              <span className="font-mono text-[11.5px] text-muted">
                {f.recordTitle} · line {f.line}
              </span>
            </button>
          ))}
        </div>
      )}
      {mark && (
        <p className="m-0 flex flex-wrap items-center gap-2">
          <StatusPill tone={markLook[mark.kind].tone} icon={markLook[mark.kind].icon} size="sm">
            {markLabels[mark.kind]}
          </StatusPill>
          <small className="text-[12.5px] text-muted">
            {mark.by} · {whenLabel(mark.at)}
            {mark.reason ? ` · ${mark.reason}` : ""}
            {mark.note ? ` · ${mark.note}` : ""}
          </small>
        </p>
      )}
      <div className="mt-1 flex flex-wrap items-center gap-2 pt-3 shadow-[inset_0_1px_0_var(--color-hairline)]">
        <Button onClick={() => setWhy(true)}>
          <Icon name="book-open-line" /> Why this finding
        </Button>
        <Button aria-pressed={mark?.kind === "reviewed"} className="aria-pressed:bg-green-fill aria-pressed:text-green-text" onClick={() => setMarking("reviewed")}>
          <Icon name="check-line" /> Reviewed
        </Button>
        <Button aria-pressed={mark?.kind === "not_relevant"} className="aria-pressed:bg-quiet-hover aria-pressed:text-ink" onClick={() => setMarking("not_relevant")}>
          <Icon name="forbid-line" /> Not relevant
        </Button>
        <Button variant="ghost" onClick={() => setMarking("problem")}>
          <Icon name="flag-fill" className="text-muted" /> Report a problem
        </Button>
        {canAskYasmar ? <AskYasmar patient={patient} findingId={finding.id} checkId={check.id} /> : null}
      </div>
      <Drawer open={why} title="Why this finding" onClose={() => setWhy(false)}>
        <div className="flex flex-col">
          <StatusPill tone={look.tone} icon={look.icon} size="sm" className="self-start">
            {tier.label}
          </StatusPill>
          <h3 className="m-0 mt-3 text-[16px] leading-6 font-semibold text-ink">{title}</h3>
          <dl className="m-0 mt-2">
            <Term term="Rule">
              <code className="font-mono text-[12.5px]">{finding.ruleId}</code> · {review ? review.replace(/^Rule /, "").toLowerCase() : "signed off by the clinical safety lead"}
              {wording.draft ? ". The clinician wording is not yet signed off for clinicians." : ""}
            </Term>
            <Term term="Tier">
              {tier.label}: {tier.when.toLowerCase()}.
            </Term>
            <Term term="What the rule says">{finding.clinical || wording.why}</Term>
            {reaction && <Term term="Reaction">{reaction}</Term>}
          </dl>
          <DrawerSub>References</DrawerSub>
          <ul className="m-0 list-none p-0">
            {specific.map((r) => (
              <li key={`${r.sourceId}-${r.section}`} className={refRow}>
                <a href={r.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 font-medium text-ink underline decoration-line underline-offset-4 hover:decoration-ink">
                  {r.document} <Icon name="external-link-line" size={13} className="text-subtle" />
                </a>
                <small className="font-mono text-[11.5px] text-muted">
                  Section {r.section.replace(/^Section\s+/i, "")} · {r.version} · read {formatDate(r.checkedOn)}
                </small>
                <q className="text-body">{r.says}</q>
              </li>
            ))}
            {(detail?.references ?? []).filter((r) => !specific.length || !r.url?.includes("search")).map((r) => (
              <li key={r.name} className={refRow}>
                {r.url ? (
                  <a href={r.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 font-medium text-ink underline decoration-line underline-offset-4 hover:decoration-ink">
                    {r.name} <Icon name="external-link-line" size={13} className="text-subtle" />
                  </a>
                ) : (
                  <span className="font-medium text-ink">{r.name}</span>
                )}
                <small className="font-mono text-[11.5px] text-muted">
                  {[r.version, r.section, r.date && formatDate(r.date)].filter(Boolean).join(" · ") || "Version and section not recorded yet"}
                </small>
              </li>
            ))}
          </ul>
          <DrawerSub>Facts it used</DrawerSub>
          <ul className="m-0 list-none p-0">
            {(detail?.factsUsed ?? []).map((f) => (
              <li key={f.factId} className={refRow}>
                <button
                  type="button"
                  className={cn(chip, "self-start")}
                  onClick={() => {
                    setWhy(false);
                    onSource({ recordId: f.recordId, title: f.recordTitle, lines: [f.line] });
                  }}
                >
                  <Icon name="file-text-line" size={14} className="text-muted" />
                  {f.label} · {f.recordTitle}, line {f.line}
                </button>
                <q className="text-body">{f.quote}</q>
              </li>
            ))}
          </ul>
          <p className="m-0 mt-4 text-[12.5px] leading-[18px] text-muted">The finding is information for your review. It is not a decision and not an instruction.</p>
        </div>
      </Drawer>
      <MarkDialog
        kind={marking}
        checkId={check.id}
        findingId={finding.id}
        title={title}
        onClose={() => setMarking(null)}
        onSaved={(m) => {
          setMarking(null);
          onMarked(m);
        }}
      />
    </article>
  );
}

const markCopy: Record<FindingMarkKind, { title: string; field: string; required: boolean; help: string }> = {
  reviewed: { title: "Mark as reviewed", field: "Note (optional)", required: false, help: "This records your review. It does not change the rule." },
  not_relevant: { title: "Mark as not relevant", field: "Why is it not relevant?", required: true, help: "A reason is required. The clinical safety lead reviews these each month." },
  problem: { title: "Report a problem", field: "What is wrong?", required: true, help: "This goes to MARMAR's clinical team. It does not change the rule." },
};

/** FND-3: Reviewed (optional note), Not relevant (reason required), Report a problem. */
function MarkDialog({ kind, checkId, findingId, title, onClose, onSaved }: { kind: FindingMarkKind | null; checkId: string; findingId: string; title: string; onClose: () => void; onSaved: (m: FindingMark) => void }) {
  const [text, setText] = useState("");
  const [error, setError] = useState("");
  const [busy, setBusy] = useState(false);
  const copy = kind ? markCopy[kind] : null;
  const save = async () => {
    if (!kind || !copy) return;
    if (copy.required && text.trim().length < 3) return setError(kind === "not_relevant" ? "Say why this finding is not relevant." : "Describe the problem.");
    setBusy(true);
    try {
      const m = await clinicianApi.markFinding(checkId, findingId, {
        kind,
        reason: kind === "not_relevant" ? text.trim() : null,
        note: kind === "not_relevant" ? null : text.trim() || null,
      });
      setText("");
      setError("");
      onSaved(m);
    } catch (e) {
      setError(errorText(e));
    } finally {
      setBusy(false);
    }
  };
  return (
    <Modal open={!!kind} title={copy?.title ?? ""} onClose={onClose}>
      <p className="m-0 text-[14px] font-semibold text-ink">{title}</p>
      <p className="m-0 text-[13px] text-muted">{copy?.help}</p>
      <label className={labelClass}>
        {copy?.field}
        <Textarea rows={3} maxLength={1000} value={text} required={copy?.required} aria-invalid={error ? true : undefined} onChange={(e) => setText(e.target.value)} />
      </label>
      {error && <FieldError>{error}</FieldError>}
      <DialogActions>
        <Button onClick={onClose}>Cancel</Button>
        <Button variant="primary" disabled={busy} onClick={() => void save()}>
          {busy ? "Saving…" : copy?.title}
        </Button>
      </DialogActions>
    </Modal>
  );
}