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} + + + + + + ); +}