feat(frontend): ClaimDrawerHeader with state badge + close button

This commit is contained in:
Tyler
2026-06-20 11:15:49 -06:00
parent 67653f0da8
commit 8933596c77
2 changed files with 327 additions and 0 deletions
@@ -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> = {}): 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<ClaimDetail>,
onClose: () => void = vi.fn()
): { container: HTMLDivElement; unmount: () => void; onClose: () => void } {
const element = (
<ClaimDrawerHeader claim={makeDetail(claim)} onClose={onClose} />
);
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
// <header> 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();
});
});
@@ -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<string, BadgeProps["variant"]> = {
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 (
<header
className={cn(
"flex items-start justify-between gap-4 px-6 py-5",
"border-b border-[color:var(--m-border-heavy)]",
"bg-[color:var(--m-surface)]"
)}
data-testid="claim-drawer-header"
>
{/* Left: eyebrow + mono claim ID */}
<div className="flex flex-col gap-1 min-w-0">
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]">
Claim
</span>
<span
data-testid="header-id"
className="font-mono text-2xl font-semibold tracking-tight text-[color:var(--m-ink-primary)]"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{claim.id}
</span>
</div>
{/* Right: state badge + total amount + close */}
<div className="flex items-start gap-4">
<div className="flex flex-col items-end gap-1">
<Badge
variant={badgeVariantFor(claim.state)}
data-testid="header-state"
className="uppercase tracking-[0.14em]"
>
{claim.stateLabel}
</Badge>
<span
data-testid="header-amount"
className="font-mono text-lg tabular-nums text-[color:var(--m-ink-primary)]"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{fmt.usdPrecise(claim.billedAmount)}
</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={onClose}
aria-label="Close drawer"
data-testid="header-close"
>
<X className="h-4 w-4" strokeWidth={1.75} />
</Button>
</div>
</header>
);
}