diff --git a/src/components/inbox/InboxRow.test.tsx b/src/components/inbox/InboxRow.test.tsx
new file mode 100644
index 0000000..67c735c
--- /dev/null
+++ b/src/components/inbox/InboxRow.test.tsx
@@ -0,0 +1,90 @@
+// @vitest-environment happy-dom
+import { describe, expect, it, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { InboxRow } from "./InboxRow";
+import type { InboxClaimRow, InboxCandidateRow } from "@/lib/inbox-api";
+
+const baseClaim: InboxClaimRow = {
+ id: "CLP-7741",
+ kind: "claim",
+ patient_control_number: "PCN-001",
+ charge_amount: 1240,
+ payer_id: "CO-MCD",
+ provider_npi: "1234567890",
+ state: "rejected",
+ rejection_reason: "999 AK9 R",
+ rejected_at: "2026-06-20T09:12:00Z",
+ service_date_from: null,
+ score: null,
+ score_tier: null,
+ score_breakdown: null,
+};
+
+describe("InboxRow", () => {
+ it("renders the claim id, payer, and formatted charge", () => {
+ const { container } = render(
+
,
+ );
+ expect(container.textContent).toContain("CLP-7741");
+ expect(container.textContent).toContain("CO-MCD");
+ expect(container.textContent).toContain("$1,240.00");
+ });
+
+ it("renders the rejection reason when present", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.textContent).toContain("999 AK9 R");
+ });
+
+ it("calls onClick when the row is clicked", () => {
+ const onClick = vi.fn();
+ const { container } = render(
+ ,
+ );
+ const row = container.querySelector("tr");
+ expect(row).not.toBeNull();
+ fireEvent.click(row!);
+ expect(onClick).toHaveBeenCalledOnce();
+ });
+
+ it("renders the top candidate score for remit rows", () => {
+ const candidateRow: InboxCandidateRow = {
+ id: "R-1",
+ kind: "remit",
+ payer_claim_control_number: "PCN-X",
+ charge_amount: 500,
+ payer_id: "AETNA",
+ rendering_provider_npi: "1234567890",
+ service_date: null,
+ candidates: [
+ {
+ claim_id: "C-1",
+ score: 92,
+ tier: "strong",
+ breakdown: { patient: 1, date: 1, amount: 1, provider: 1 },
+ },
+ ],
+ };
+ const { container } = render(
+ ,
+ );
+ expect(container.textContent).toContain("92");
+ });
+});
diff --git a/src/components/inbox/InboxRow.tsx b/src/components/inbox/InboxRow.tsx
new file mode 100644
index 0000000..afc4ca7
--- /dev/null
+++ b/src/components/inbox/InboxRow.tsx
@@ -0,0 +1,109 @@
+import type { InboxClaimRow, InboxCandidateRow, ScoreBreakdown } from "@/lib/inbox-api";
+
+export type Accent = "oxblood" | "amber" | "ink-blue" | "muted";
+
+const ACCENT_VAR: Record = {
+ oxblood: "var(--tt-oxblood)",
+ amber: "var(--tt-amber)",
+ "ink-blue": "var(--tt-ink-blue)",
+ muted: "var(--tt-muted)",
+};
+
+function fmtMoney(n: number | null | undefined): string {
+ if (n == null) return "—";
+ return n.toLocaleString("en-US", { style: "currency", currency: "USD" });
+}
+
+function fmtAgo(iso: string | null | undefined): string {
+ if (!iso) return "";
+ const ms = Date.now() - new Date(iso).getTime();
+ if (Number.isNaN(ms)) return "";
+ const mins = Math.round(ms / 60_000);
+ if (mins < 60) return `${mins}m`;
+ const hrs = Math.round(mins / 60);
+ if (hrs < 24) return `${hrs}h`;
+ const days = Math.round(hrs / 24);
+ return `${days}d`;
+}
+
+function Sparkline({ breakdown }: { breakdown: ScoreBreakdown | null }) {
+ if (!breakdown) return null;
+ const fields: Array = ["patient", "date", "amount", "provider"];
+ return (
+
+ {fields.map((k) => {
+ const v = breakdown[k] as number;
+ const h = Math.max(2, Math.round(v * 12));
+ return (
+ = 0.5 ? "var(--tt-amber)" : "var(--tt-muted)",
+ }}
+ />
+ );
+ })}
+
+ );
+}
+
+type Props =
+ | { row: InboxClaimRow; accent: Accent; onClick: () => void }
+ | { row: InboxCandidateRow; accent: Accent; onClick: () => void };
+
+export function InboxRow({ row, accent, onClick }: Props) {
+ const isCandidate = row.kind === "remit";
+ const id = isCandidate ? (row as InboxCandidateRow).payer_claim_control_number : row.id;
+ const payer = (isCandidate ? (row as InboxCandidateRow).payer_id : (row as InboxClaimRow).payer_id) ?? "";
+ const charge = fmtMoney(row.charge_amount);
+ const reason = !isCandidate ? (row as InboxClaimRow).rejection_reason : null;
+ const rejectedAt = !isCandidate ? (row as InboxClaimRow).rejected_at : null;
+ const topScore = isCandidate && row.candidates.length > 0 ? row.candidates[0].score : null;
+ const filled = isCandidate && row.candidates.length > 0 ? row.candidates[0].breakdown : null;
+
+ return (
+
+ |
+
+ {id}
+ |
+
+ {reason ?? (topScore != null ? `score ${topScore}` : "")}
+ |
+
+ {fmtAgo(rejectedAt)}
+ |
+
+ {charge}
+ |
+
+ {payer}
+ |
+
+
+ |
+
+ );
+}