From 94a39e9dd686b0f0e0d1ea0078a132a38a541033 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 20 Jun 2026 11:38:51 -0600 Subject: [PATCH] feat(frontend): PartiesGrid with billing provider/subscriber/payer cards --- .../ClaimDrawer/PartiesGrid.test.tsx | 281 ++++++++++++++++++ src/components/ClaimDrawer/PartiesGrid.tsx | 156 ++++++++++ 2 files changed, 437 insertions(+) create mode 100644 src/components/ClaimDrawer/PartiesGrid.test.tsx create mode 100644 src/components/ClaimDrawer/PartiesGrid.tsx diff --git a/src/components/ClaimDrawer/PartiesGrid.test.tsx b/src/components/ClaimDrawer/PartiesGrid.test.tsx new file mode 100644 index 0000000..db85c8d --- /dev/null +++ b/src/components/ClaimDrawer/PartiesGrid.test.tsx @@ -0,0 +1,281 @@ +// @vitest-environment happy-dom +// Pure prop-driven component; match the act() convention used by the +// other ClaimDrawer test files (ClaimDrawerHeader / ClaimDrawerSkeleton / +// ClaimDrawerError / ValidationPanel / ServiceLinesTable / DiagnosesList). +(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 } from "vitest"; +import { PartiesGrid } from "./PartiesGrid"; +import type { + ClaimDetailAddress, + ClaimDetailParties, +} 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(); + }, + }; +} + +function makeAddress( + overrides: Partial = {} +): ClaimDetailAddress { + return { + line1: "100 Main St", + line2: null, + city: "Springfield", + state: "IL", + zip: "62701", + ...overrides, + }; +} + +function makeParties( + overrides: Partial = {} +): ClaimDetailParties { + return { + billingProvider: { + name: "Acme Medical Group", + npi: "1234567890", + taxId: "12-3456789", + address: makeAddress(), + }, + subscriber: { + firstName: "Jane", + lastName: "Doe", + memberId: "MEM12345", + dob: "1980-05-15", + gender: "F", + }, + payer: { + name: "Aetna", + id: "PAYER01", + }, + ...overrides, + }; +} + +describe("PartiesGrid", () => { + it("test_section_label_is_parties", () => { + const { container, unmount } = renderIntoContainer( + + ); + + const label = container.querySelector('[data-testid="section-label"]'); + expect(label).not.toBeNull(); + + // `uppercase` is applied via Tailwind CSS, so textContent gives the + // source-case string "Parties"; assert case-insensitively and + // confirm the visual transform via the className sniff below. + const text = (label?.textContent ?? "").toLowerCase(); + expect(text).toContain("parties"); + + // Eyebrow style (uppercase + tracking) — sniff the className. + const cls = label?.className ?? ""; + expect(cls).toContain("uppercase"); + expect(cls).toContain("tracking-[0.18em]"); + + unmount(); + }); + + it("test_three_cards_rendered_for_each_party", () => { + const { container, unmount } = renderIntoContainer( + + ); + + const billing = container.querySelector('[data-testid="party-billing-provider"]'); + const subscriber = container.querySelector('[data-testid="party-subscriber"]'); + const payer = container.querySelector('[data-testid="party-payer"]'); + + expect(billing).not.toBeNull(); + expect(subscriber).not.toBeNull(); + expect(payer).not.toBeNull(); + + unmount(); + }); + + it("test_billing_provider_card_shows_name_npi_taxId_and_address", () => { + const { container, unmount } = renderIntoContainer( + + ); + + const card = container.querySelector( + '[data-testid="party-billing-provider"]' + ); + expect(card).not.toBeNull(); + + const text = card?.textContent ?? ""; + expect(text).toContain("Acme Medical Group"); + expect(text).toContain("1234567890"); + expect(text).toContain("12-3456789"); + // Address components. + expect(text).toContain("100 Main St"); + expect(text).toContain("Springfield"); + expect(text).toContain("IL"); + expect(text).toContain("62701"); + + unmount(); + }); + + it("test_subscriber_card_shows_first_last_memberId_and_gender", () => { + const { container, unmount } = renderIntoContainer( + + ); + + const card = container.querySelector('[data-testid="party-subscriber"]'); + expect(card).not.toBeNull(); + + const text = card?.textContent ?? ""; + expect(text).toContain("Jane"); + expect(text).toContain("Doe"); + expect(text).toContain("MEM12345"); + expect(text).toContain("F"); + // DOB is also present in this fixture. + expect(text).toContain("1980"); + + unmount(); + }); + + it("test_subscriber_card_omits_dob_when_null", () => { + const { container, unmount } = renderIntoContainer( + + ); + + const card = container.querySelector('[data-testid="party-subscriber"]'); + expect(card).not.toBeNull(); + + const text = (card?.textContent ?? "").toLowerCase(); + // Still renders the identity pieces. + expect(text).toContain("john"); + expect(text).toContain("smith"); + expect(text).toContain("mem99999"); + expect(text).toContain("m"); + // No "undefined" or "null" stringification for the absent DOB. + expect(text).not.toContain("undefined"); + expect(text).not.toContain("null"); + + unmount(); + }); + + it("test_payer_card_shows_name_and_id", () => { + const { container, unmount } = renderIntoContainer( + + ); + + const card = container.querySelector('[data-testid="party-payer"]'); + expect(card).not.toBeNull(); + + const text = card?.textContent ?? ""; + expect(text).toContain("Aetna"); + expect(text).toContain("PAYER01"); + + unmount(); + }); + + it("test_billing_provider_address_line2_rendered_when_present", () => { + const { container, unmount } = renderIntoContainer( + + ); + + const card = container.querySelector( + '[data-testid="party-billing-provider"]' + ); + const text = card?.textContent ?? ""; + expect(text).toContain("200 Oak Ave"); + expect(text).toContain("Suite 300"); + expect(text).toContain("Portland"); + expect(text).toContain("OR"); + expect(text).toContain("97201"); + + unmount(); + }); + + it("test_empty_address_object_does_not_render_garbage", () => { + // The backend can return `{}` for a missing address (per the + // ClaimDetailBillingProvider type's `Record` branch). + // The component must not stringify an empty object. + const { container, unmount } = renderIntoContainer( + + ); + + const card = container.querySelector( + '[data-testid="party-billing-provider"]' + ); + const text = (card?.textContent ?? "").toLowerCase(); + expect(text).toContain("addrless clinic"); + expect(text).toContain("2222222222"); + expect(text).not.toContain("[object object]"); + expect(text).not.toContain("undefined"); + expect(text).not.toContain("null"); + + unmount(); + }); + + it("test_responsive_grid_uses_3_columns_on_desktop", () => { + const { container, unmount } = renderIntoContainer( + + ); + + // The section follows the established `flex flex-col gap-3 px-6 py-4` + // pattern; the responsive grid is the inner wrapper that holds the + // three party cards. Sniff its className. + const grid = container.querySelector('[data-testid="parties-grid-inner"]'); + expect(grid).not.toBeNull(); + const cls = grid?.className ?? ""; + expect(cls).toContain("grid"); + expect(cls).toContain("grid-cols-1"); + expect(cls).toContain("md:grid-cols-3"); + expect(cls).toContain("gap-4"); + + unmount(); + }); +}); diff --git a/src/components/ClaimDrawer/PartiesGrid.tsx b/src/components/ClaimDrawer/PartiesGrid.tsx new file mode 100644 index 0000000..03d3e0a --- /dev/null +++ b/src/components/ClaimDrawer/PartiesGrid.tsx @@ -0,0 +1,156 @@ +import type { ClaimDetail } from "@/types"; + +type PartiesGridProps = { + parties: ClaimDetail["parties"]; +}; + +type AddressLike = ClaimDetail["parties"]["billingProvider"]["address"]; + +/** + * Address can come back as `ClaimDetailAddress` (line1/line2/city/...) or + * `{}` (the backend's `Record` shape for "missing"). The + * latter is structurally a regular object with no keys, so a single + * `Object.keys(addr).length === 0` check is the safe way to detect it + * without resorting to `'line1' in addr`. + */ +function isEmptyAddress(addr: AddressLike | null | undefined): boolean { + if (addr === null || addr === undefined) return true; + return Object.keys(addr).length === 0; +} + +function AddressBlock({ address }: { address: AddressLike }) { + if (isEmptyAddress(address)) return null; + // After the empty check the cast is safe; the alternative `ClaimDetailAddress` + // shape has all the fields we read. + const a = address as ClaimDetail["parties"]["billingProvider"]["address"] & { + line1: string; + line2: string | null; + city: string; + state: string; + zip: string; + }; + return ( +
+
{a.line1}
+ {a.line2 ?
{a.line2}
: null} +
+ {a.city}, {a.state} {a.zip} +
+
+ ); +} + +/** + * Single party card: eyebrow label, name, identity mono line(s), and + * an optional address block. The card is just a light surface with a + * border — no shadow, no hover; the drawer is read-only detail content. + */ +function PartyCard({ + testId, + label, + name, + identity, + address, +}: { + testId: string; + label: string; + name: string; + identity: React.ReactNode; + address?: AddressLike; +}) { + return ( +
+ + {label} + +
+ {name} +
+
+ {identity} +
+ {address ? : null} +
+ ); +} + +/** + * Parties section of the claim detail drawer (SP4). + * + * Three stacked cards on mobile (`grid-cols-1`), 3-up grid on desktop + * (`md:grid-cols-3`). Billing provider carries the full identity + tax + * id + address; subscriber shows the member record; payer is the slim + * name+id card. All optional fields (subscriber DOB, line2) are + * conditionally rendered so a missing value doesn't become "null" in + * the rendered text. + */ +export function PartiesGrid({ parties }: PartiesGridProps) { + const { billingProvider, subscriber, payer } = parties; + + return ( +
+

+ Parties +

+ +
+ +
NPI {billingProvider.npi}
+ {billingProvider.taxId ? ( +
Tax ID {billingProvider.taxId}
+ ) : null} + + } + address={billingProvider.address} + /> + + +
Member ID {subscriber.memberId}
+ {subscriber.gender ?
Gender {subscriber.gender}
: null} + {subscriber.dob ?
DOB {subscriber.dob}
: null} + + } + /> + + +
ID {payer.id}
+ + } + /> +
+
+ ); +}