feat(frontend): PartiesGrid with billing provider/subscriber/payer cards
This commit is contained in:
@@ -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> = {}
|
||||||
|
): ClaimDetailAddress {
|
||||||
|
return {
|
||||||
|
line1: "100 Main St",
|
||||||
|
line2: null,
|
||||||
|
city: "Springfield",
|
||||||
|
state: "IL",
|
||||||
|
zip: "62701",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeParties(
|
||||||
|
overrides: Partial<ClaimDetailParties> = {}
|
||||||
|
): 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(
|
||||||
|
<PartiesGrid parties={makeParties()} />
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<PartiesGrid parties={makeParties()} />
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<PartiesGrid parties={makeParties()} />
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<PartiesGrid parties={makeParties()} />
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<PartiesGrid
|
||||||
|
parties={makeParties({
|
||||||
|
subscriber: {
|
||||||
|
firstName: "John",
|
||||||
|
lastName: "Smith",
|
||||||
|
memberId: "MEM99999",
|
||||||
|
dob: null,
|
||||||
|
gender: "M",
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<PartiesGrid parties={makeParties()} />
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<PartiesGrid
|
||||||
|
parties={makeParties({
|
||||||
|
billingProvider: {
|
||||||
|
name: "Suite Clinic",
|
||||||
|
npi: "1111111111",
|
||||||
|
taxId: "99-9999999",
|
||||||
|
address: makeAddress({
|
||||||
|
line1: "200 Oak Ave",
|
||||||
|
line2: "Suite 300",
|
||||||
|
city: "Portland",
|
||||||
|
state: "OR",
|
||||||
|
zip: "97201",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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<string, never>` branch).
|
||||||
|
// The component must not stringify an empty object.
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<PartiesGrid
|
||||||
|
parties={makeParties({
|
||||||
|
billingProvider: {
|
||||||
|
name: "Addrless Clinic",
|
||||||
|
npi: "2222222222",
|
||||||
|
taxId: "11-1111111",
|
||||||
|
address: {},
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<PartiesGrid parties={makeParties()} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, never>` 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 (
|
||||||
|
<div
|
||||||
|
className="text-xs leading-relaxed text-[color:var(--m-ink-secondary)]"
|
||||||
|
data-testid="party-address"
|
||||||
|
>
|
||||||
|
<div>{a.line1}</div>
|
||||||
|
{a.line2 ? <div>{a.line2}</div> : null}
|
||||||
|
<div>
|
||||||
|
{a.city}, {a.state} {a.zip}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<div
|
||||||
|
data-testid={testId}
|
||||||
|
className="flex flex-col gap-2 rounded-lg border border-[color:var(--m-border-heavy)]/15 bg-[color:var(--m-surface)] px-4 py-3"
|
||||||
|
>
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<div className="text-base font-medium text-[color:var(--m-ink-primary)]">
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="font-mono text-[13px] text-[color:var(--m-ink-secondary)] tabular-nums"
|
||||||
|
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||||
|
>
|
||||||
|
{identity}
|
||||||
|
</div>
|
||||||
|
{address ? <AddressBlock address={address} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<section
|
||||||
|
className="flex flex-col gap-3 px-6 py-4"
|
||||||
|
data-testid="parties-grid"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
data-testid="section-label"
|
||||||
|
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
|
||||||
|
>
|
||||||
|
Parties
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-testid="parties-grid-inner"
|
||||||
|
className="grid grid-cols-1 md:grid-cols-3 gap-4"
|
||||||
|
>
|
||||||
|
<PartyCard
|
||||||
|
testId="party-billing-provider"
|
||||||
|
label="Billing provider"
|
||||||
|
name={billingProvider.name}
|
||||||
|
identity={
|
||||||
|
<>
|
||||||
|
<div>NPI {billingProvider.npi}</div>
|
||||||
|
{billingProvider.taxId ? (
|
||||||
|
<div>Tax ID {billingProvider.taxId}</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
address={billingProvider.address}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PartyCard
|
||||||
|
testId="party-subscriber"
|
||||||
|
label="Subscriber"
|
||||||
|
name={`${subscriber.firstName} ${subscriber.lastName}`}
|
||||||
|
identity={
|
||||||
|
<>
|
||||||
|
<div>Member ID {subscriber.memberId}</div>
|
||||||
|
{subscriber.gender ? <div>Gender {subscriber.gender}</div> : null}
|
||||||
|
{subscriber.dob ? <div>DOB {subscriber.dob}</div> : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PartyCard
|
||||||
|
testId="party-payer"
|
||||||
|
label="Payer"
|
||||||
|
name={payer.name}
|
||||||
|
identity={
|
||||||
|
<>
|
||||||
|
<div>ID {payer.id}</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user