feat(frontend): ClaimDrawerHeader with state badge + close button
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user