feat(drill): PayerPeekContent + usePayerSummary + api.getPayerSummary
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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` (0–1) 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -586,6 +586,39 @@ async function listProviders<T = unknown>(
|
||||
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>(
|
||||
params: ListActivityParams = {}
|
||||
): Promise<PaginatedResponse<T>> {
|
||||
@@ -740,6 +773,7 @@ export const api = {
|
||||
listRemittances,
|
||||
getRemittance,
|
||||
listProviders,
|
||||
getPayerSummary,
|
||||
listActivity,
|
||||
listUnmatched,
|
||||
matchRemit,
|
||||
|
||||
Reference in New Issue
Block a user