diff --git a/src/components/ClaimDrawer/ClaimDrawerHeader.test.tsx b/src/components/ClaimDrawer/ClaimDrawerHeader.test.tsx new file mode 100644 index 0000000..5b6a2aa --- /dev/null +++ b/src/components/ClaimDrawer/ClaimDrawerHeader.test.tsx @@ -0,0 +1,226 @@ +// @vitest-environment happy-dom +// React Query isn't used by this component (it receives a resolved +// ClaimDetail as a prop), but the surrounding test harness convention +// sets IS_REACT_ACT_ENVIRONMENT to suppress noisy act() warnings. +// Mirror the convention from ClaimDrawerSkeleton.test.tsx. +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { describe, expect, it, vi } from "vitest"; +import { ClaimDrawerHeader } from "./ClaimDrawerHeader"; +import type { ClaimDetail } from "@/types"; + +function renderIntoContainer(element: React.ReactElement): { + container: HTMLDivElement; + unmount: () => void; +} { + const container = document.createElement("div"); + document.body.appendChild(container); + const root: Root = createRoot(container); + act(() => { + root.render(element); + }); + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +/** + * Minimal valid ClaimDetail fixture — every required key present so the + * component typechecks. Mirrors the SAMPLE_DETAIL in + * useClaimDetail.test.ts; only the fields the header actually reads + * (id, state, stateLabel, billedAmount) vary between tests. + */ +function makeDetail(overrides: Partial = {}): ClaimDetail { + return { + id: "CLM-1", + batchId: "B-1", + state: "submitted", + stateLabel: "Submitted", + billedAmount: 123.45, + patientName: "Jane Doe", + providerNpi: "1234567890", + providerName: "Acme Clinic", + payerName: "Medicaid", + payerId: "MEDCO", + submissionDate: "2026-01-15T00:00:00Z", + serviceDateFrom: "2026-01-10", + serviceDateTo: "2026-01-10", + parsedAt: "2026-01-15T00:00:01Z", + diagnoses: [{ code: "E11.9", qualifier: "ABK" }], + serviceLines: [ + { + lineNumber: 1, + procedureQualifier: "HC", + procedureCode: "99213", + modifiers: [], + charge: 100.0, + units: 1, + unitType: "UN", + serviceDate: "2026-01-10", + }, + ], + parties: { + billingProvider: { + name: "Acme Clinic", + npi: "1234567890", + taxId: "12-3456789", + address: { + line1: "123 Main St", + line2: null, + city: "Denver", + state: "CO", + zip: "80202", + }, + }, + subscriber: { + firstName: "Jane", + lastName: "Doe", + memberId: "M-1", + dob: null, + gender: "F", + }, + payer: { name: "Medicaid", id: "MEDCO" }, + }, + validation: { passed: true, errors: [], warnings: [] }, + rawSegments: [["ISA*00*"]], + matchedRemittance: null, + stateHistory: [ + { + kind: "claim_submitted", + ts: "2026-01-15T00:00:00Z", + batchId: "B-1", + remittanceId: null, + }, + ], + ...overrides, + }; +} + +function renderHeader( + claim: Partial, + onClose: () => void = vi.fn() +): { container: HTMLDivElement; unmount: () => void; onClose: () => void } { + const element = ( + + ); + const { container, unmount } = renderIntoContainer(element); + return { container, unmount, onClose }; +} + +describe("ClaimDrawerHeader", () => { + it("test_renders_claim_id_label_and_value", () => { + const { container, unmount } = renderHeader({ id: "CLM-42" }); + + // The eyebrow label "Claim" is rendered as uppercase text. + const text = (container.textContent ?? "").toLowerCase(); + expect(text).toContain("claim"); + + // The claim id sits in a node tagged with data-testid="header-id". + const idEl = container.querySelector('[data-testid="header-id"]'); + expect(idEl).not.toBeNull(); + expect(idEl?.textContent).toBe("CLM-42"); + + unmount(); + }); + + it("test_renders_state_badge_with_label", () => { + const { container, unmount } = renderHeader({ + state: "submitted", + stateLabel: "Submitted", + }); + + const badge = container.querySelector('[data-testid="header-state"]'); + expect(badge).not.toBeNull(); + expect(badge?.textContent).toBe("Submitted"); + + unmount(); + }); + + it("test_state_badge_variant_matches_state", () => { + // Each state must surface the right Badge variant. We sniff the + // rendered className for the per-variant token (defined in + // badge.tsx's cva config) so the assertion is independent of the + // surrounding wrapper classes. + const cases: Array<{ + state: string; + stateLabel: string; + expectedToken: string; + }> = [ + // secondary → bg-muted text-muted-foreground + { state: "submitted", stateLabel: "Submitted", expectedToken: "bg-muted text-muted-foreground" }, + // destructive → bg-destructive/15 + { state: "denied", stateLabel: "Denied", expectedToken: "bg-destructive/15" }, + // success → bg-[hsl(var(--success)/0.15)] + { state: "paid", stateLabel: "Paid", expectedToken: "bg-[hsl(var(--success)/0.15)]" }, + // warning → bg-[hsl(var(--warning)/0.15)] + { state: "pending", stateLabel: "Pending", expectedToken: "bg-[hsl(var(--warning)/0.15)]" }, + ]; + + for (const c of cases) { + const { container, unmount } = renderHeader({ + state: c.state, + stateLabel: c.stateLabel, + }); + const badge = container.querySelector('[data-testid="header-state"]'); + expect(badge, `badge missing for state=${c.state}`).not.toBeNull(); + const cls = badge?.className ?? ""; + expect( + cls.includes(c.expectedToken), + `expected badge className to include "${c.expectedToken}" for state=${c.state}, got "${cls}"` + ).toBe(true); + unmount(); + } + }); + + it("test_renders_total_amount_formatted", () => { + // 123.45 → "$123.45" (the project's fmt.usdPrecise helper, which + // uses minimumFractionDigits:2 / maximumFractionDigits:2). + const { container, unmount } = renderHeader({ billedAmount: 123.45 }); + + const amountEl = container.querySelector('[data-testid="header-amount"]'); + expect(amountEl).not.toBeNull(); + expect(amountEl?.textContent).toBe("$123.45"); + + unmount(); + }); + + it("test_close_button_calls_onClose", () => { + const onClose = vi.fn(); + const { container, unmount } = renderHeader({}, onClose); + + const closeBtn = container.querySelector( + '[data-testid="header-close"]' + ) as HTMLButtonElement | null; + expect(closeBtn).not.toBeNull(); + expect(onClose).not.toHaveBeenCalled(); + + act(() => { + closeBtn?.click(); + }); + expect(onClose).toHaveBeenCalledTimes(1); + + unmount(); + }); + + it("test_uses_modern_palette_surface", () => { + // The drawer header anchors itself on the light surface palette + // token (matches ClaimDrawerSkeleton / ClaimDrawerError). The root + //
element is tagged with data-testid="claim-drawer-header" + // so we can sniff its className without coupling to the badge + // or close-button wrappers. + const { container, unmount } = renderHeader({}); + + const root = container.querySelector('[data-testid="claim-drawer-header"]'); + expect(root).not.toBeNull(); + const cls = root?.className ?? ""; + expect(cls).toContain("bg-[color:var(--m-surface)]"); + + unmount(); + }); +}); diff --git a/src/components/ClaimDrawer/ClaimDrawerHeader.tsx b/src/components/ClaimDrawer/ClaimDrawerHeader.tsx new file mode 100644 index 0000000..3be96b2 --- /dev/null +++ b/src/components/ClaimDrawer/ClaimDrawerHeader.tsx @@ -0,0 +1,101 @@ +import { X } from "lucide-react"; +import { Badge, type BadgeProps } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { fmt } from "@/lib/format"; +import { cn } from "@/lib/utils"; +import type { ClaimDetail } from "@/types"; + +type ClaimDrawerHeaderProps = { + claim: ClaimDetail; + onClose: () => void; +}; + +/** + * State → Badge variant. The state machine value (see + * `cyclone.db.ClaimState`) drives a color-coded chip so the user can + * scan the drawer's status at a glance: + * + * submitted → secondary (neutral, in-flight) + * accepted → default (brand-colored, accepted by payer) + * matched → success (paired with an ERA) + * paid → success (funds received) + * denied → destructive (needs attention) + * pending → warning (waiting on something) + * anything else → muted + */ +const STATE_VARIANT: Record = { + submitted: "secondary", + accepted: "default", + matched: "success", + paid: "success", + denied: "destructive", + pending: "warning", +}; + +function badgeVariantFor(state: string): BadgeProps["variant"] { + return STATE_VARIANT[state] ?? "muted"; +} + +/** + * Header band for the claim detail drawer (SP4). + * + * Top-left: instrument-style "Claim" eyebrow + large mono ID. + * Top-right: state badge + total billed amount + close button. The + * badge color encodes state so the user can read the drawer's status + * at a glance without scrolling. + */ +export function ClaimDrawerHeader({ claim, onClose }: ClaimDrawerHeaderProps) { + return ( +
+ {/* Left: eyebrow + mono claim ID */} +
+ + Claim + + + {claim.id} + +
+ + {/* Right: state badge + total amount + close */} +
+
+ + {claim.stateLabel} + + + {fmt.usdPrecise(claim.billedAmount)} + +
+ +
+
+ ); +}