feat(drill): PayerPeekContent + usePayerSummary + api.getPayerSummary

This commit is contained in:
Tyler
2026-06-21 13:05:38 -06:00
parent e6ae364dad
commit 50dc0b2fb3
4 changed files with 256 additions and 0 deletions
@@ -0,0 +1,80 @@
// @vitest-environment happy-dom
// PayerPeekContent uses useQuery internally via usePayerSummary. We mock
// that hook here so the component can be rendered without standing up a
// real QueryClient — same pattern as useClaimDetail.test.ts.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { afterEach, describe, it, expect, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import type { ReactNode } from "react";
import { PayerPeekContent } from "@/components/drill/PayerPeekContent";
// Mock the hook BEFORE the import above is resolved (vitest hoists
// `vi.mock` to the top of the file regardless of where it appears
// syntactically).
vi.mock("@/hooks/usePayerSummary", () => ({
usePayerSummary: vi.fn(),
}));
// Importing after vi.mock so we get the mocked reference.
import { usePayerSummary } from "@/hooks/usePayerSummary";
// happy-dom keeps `document.body` between tests; without cleanup,
// `screen.getByText(...)` would find nodes from earlier renders.
afterEach(() => cleanup());
// The component renders <Link>, which requires a router context. A bare
// MemoryRouter with no initialEntries is enough — the test asserts on the
// generated href, not on navigation.
function withRouter(node: ReactNode) {
return <MemoryRouter>{node}</MemoryRouter>;
}
const SAMPLE_PAYER = {
payer_id: "SKCO0",
name: "CO Medicaid",
claim_count: 1247,
billed_total: 548000,
received_total: 521000,
denial_rate: 0.042,
top_providers: [{ npi: "1881068062", count: 184 }],
} as const;
describe("PayerPeekContent", () => {
it("renders loading skeleton while fetching", () => {
// Idle / in-flight state — `data` undefined, `isLoading` true.
// The component renders <Skeleton variant="row" /> rows and no text,
// so the regex match for /claims/i must come back null (the
// "View all claims" link only renders once data is present).
(usePayerSummary as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
data: undefined,
isLoading: true,
});
render(withRouter(<PayerPeekContent payerId="SKCO0" />));
expect(screen.queryByText(/claims/i)).toBeNull();
});
it("renders summary stats when data loads", () => {
(usePayerSummary as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
data: SAMPLE_PAYER,
isLoading: false,
});
render(withRouter(<PayerPeekContent payerId="SKCO0" />));
expect(screen.getByText("CO Medicaid")).toBeTruthy();
// fmt.num(1247) === "1,247" — the "184 claims" line also matches this
// regex, but getByText with a regex is fine because we only assert
// existence.
expect(screen.getByText(/1,247/)).toBeTruthy();
expect(screen.getByText("$548,000")).toBeTruthy();
// denial_rate is a fraction (0.042); fmt.pct(payer.denial_rate * 100)
// yields "4.2%".
expect(screen.getByText("4.2%")).toBeTruthy();
const link = screen.getByRole("link", { name: /view all claims/i });
expect(link.getAttribute("href")).toBe("/claims?payer=SKCO0");
});
});
+118
View File
@@ -0,0 +1,118 @@
import { Link } from "react-router-dom";
import { Skeleton } from "@/components/ui/skeleton";
import { fmt } from "@/lib/format";
import { usePayerSummary } from "@/hooks/usePayerSummary";
import type { PayerSummary } from "@/lib/api";
interface Props {
payerId: string;
}
/**
* Peek body for a payer — aggregate stats card shown inside the
* centered PeekModal (SP21 universal drill-down).
*
* Owns its own fetch via `usePayerSummary`; the parent PeekModal only
* concerns itself with open/close + title. We deliberately do NOT show
* an error state here — the peek is a low-stakes summary, so a silent
* retry + skeleton on failure is acceptable (the parent modal still
* closes correctly).
*
* `fmt.pct` does not multiply by 100 (it's just `n.toFixed(d)%`), so the
* API's fraction `denial_rate` (01) needs `* 100` before formatting —
* otherwise the UI would render "0.0%" for everything.
*/
export function PayerPeekContent({ payerId }: Props) {
const { data, isLoading } = usePayerSummary(payerId);
if (isLoading || !data) {
return (
<div className="space-y-2" aria-busy="true">
<Skeleton variant="row" />
<Skeleton variant="row" />
<Skeleton variant="row" />
</div>
);
}
return <Loaded payer={data} />;
}
function Loaded({ payer }: { payer: PayerSummary }) {
return (
<div className="space-y-4">
<div className="flex items-baseline justify-between gap-3">
<div className="display text-[15px] text-foreground truncate">
{payer.name}
</div>
<div className="mono text-[11px] text-muted-foreground">
{payer.payer_id}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<Stat label="Claims" value={fmt.num(payer.claim_count)} />
<Stat
label="Denial rate"
value={fmt.pct(payer.denial_rate * 100)}
/>
<Stat
label="Billed"
value={fmt.usd(payer.billed_total)}
accent="accent"
/>
<Stat
label="Received"
value={fmt.usd(payer.received_total)}
accent="success"
/>
</div>
{payer.top_providers.length > 0 ? (
<div>
<div className="eyebrow mb-1.5">Top providers</div>
<ul className="text-[12.5px] space-y-1">
{payer.top_providers.slice(0, 3).map((p) => (
<li
key={p.npi}
className="flex justify-between gap-3"
>
<span className="mono">{p.npi}</span>
<span className="mono text-muted-foreground">
{fmt.num(p.count)} claims
</span>
</li>
))}
</ul>
</div>
) : null}
<Link
to={`/claims?payer=${encodeURIComponent(payer.payer_id)}`}
className="text-[12.5px] text-accent hover:underline"
>
View all claims
</Link>
</div>
);
}
function Stat({
label,
value,
accent,
}: {
label: string;
value: string;
accent?: "accent" | "success" | "warning";
}) {
const color =
accent === "success"
? "text-[hsl(var(--success))]"
: accent === "warning"
? "text-[hsl(var(--warning))]"
: accent === "accent"
? "text-accent"
: "text-foreground";
return (
<div>
<div className="eyebrow">{label}</div>
<div className={`display mono text-[16px] mt-1 ${color}`}>{value}</div>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { useQuery } from "@tanstack/react-query";
import { api, type PayerSummary } from "@/lib/api";
/**
* Fetch the aggregate payer stats shown in the payer peek modal
* (SP21 universal drill-down).
*
* Caches for 60s — the backend caches the same way, so re-asks inside
* that window are free. `retry: 1` because peek content is a low-stakes
* summary; one retry on transient failure is enough before falling back
* to the error UI.
*
* When `payerId` is `null` (the peek isn't open), the query is disabled
* and the hook returns the idle React-Query state — no fetch.
*/
export function usePayerSummary(payerId: string | null) {
return useQuery<PayerSummary>({
queryKey: ["payer-summary", payerId],
queryFn: () => api.getPayerSummary(payerId as string),
enabled: payerId !== null,
staleTime: 60 * 1000,
retry: 1,
});
}
+34
View File
@@ -586,6 +586,39 @@ async function listProviders<T = unknown>(
return (await res.json()) as PaginatedResponse<T>; return (await res.json()) as PaginatedResponse<T>;
} }
/**
* Aggregate stats for one payer, used by the peek modal on Dashboard /
* Claims tables (SP21 universal drill-down). Drives
* `GET /api/payers/{payer_id}/summary`.
*
* `denial_rate` is a fraction in `[0, 1]` (the API does NOT pre-multiply).
* `top_providers` is the top 3 (or fewer) by claim count, ordered
* server-side.
*/
export interface PayerSummary {
payer_id: string;
name: string;
claim_count: number;
billed_total: number;
received_total: number;
denial_rate: number;
top_providers: Array<{ npi: string; count: number }>;
}
async function getPayerSummary(payerId: string): Promise<PayerSummary> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(
joinUrl(`/api/payers/${encodeURIComponent(payerId)}/summary`)
);
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(
`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`
);
}
return (await res.json()) as PayerSummary;
}
async function listActivity<T = unknown>( async function listActivity<T = unknown>(
params: ListActivityParams = {} params: ListActivityParams = {}
): Promise<PaginatedResponse<T>> { ): Promise<PaginatedResponse<T>> {
@@ -740,6 +773,7 @@ export const api = {
listRemittances, listRemittances,
getRemittance, getRemittance,
listProviders, listProviders,
getPayerSummary,
listActivity, listActivity,
listUnmatched, listUnmatched,
matchRemit, matchRemit,