diff --git a/src/components/drill/PayerPeekContent.test.tsx b/src/components/drill/PayerPeekContent.test.tsx new file mode 100644 index 0000000..5c39c28 --- /dev/null +++ b/src/components/drill/PayerPeekContent.test.tsx @@ -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 , 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 {node}; +} + +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 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).mockReturnValue({ + data: undefined, + isLoading: true, + }); + + render(withRouter()); + expect(screen.queryByText(/claims/i)).toBeNull(); + }); + + it("renders summary stats when data loads", () => { + (usePayerSummary as unknown as ReturnType).mockReturnValue({ + data: SAMPLE_PAYER, + isLoading: false, + }); + + render(withRouter()); + + 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"); + }); +}); \ No newline at end of file diff --git a/src/components/drill/PayerPeekContent.tsx b/src/components/drill/PayerPeekContent.tsx new file mode 100644 index 0000000..5b967db --- /dev/null +++ b/src/components/drill/PayerPeekContent.tsx @@ -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 ( +
+ + + +
+ ); + } + return ; +} + +function Loaded({ payer }: { payer: PayerSummary }) { + return ( +
+
+
+ {payer.name} +
+
+ {payer.payer_id} +
+
+
+ + + + +
+ {payer.top_providers.length > 0 ? ( +
+
Top providers
+
    + {payer.top_providers.slice(0, 3).map((p) => ( +
  • + {p.npi} + + {fmt.num(p.count)} claims + +
  • + ))} +
+
+ ) : null} + + View all claims → + +
+ ); +} + +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 ( +
+
{label}
+
{value}
+
+ ); +} \ No newline at end of file diff --git a/src/hooks/usePayerSummary.ts b/src/hooks/usePayerSummary.ts new file mode 100644 index 0000000..2470f64 --- /dev/null +++ b/src/hooks/usePayerSummary.ts @@ -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({ + queryKey: ["payer-summary", payerId], + queryFn: () => api.getPayerSummary(payerId as string), + enabled: payerId !== null, + staleTime: 60 * 1000, + retry: 1, + }); +} \ No newline at end of file diff --git a/src/lib/api.ts b/src/lib/api.ts index 8ed06bb..153d796 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -586,6 +586,39 @@ async function listProviders( return (await res.json()) as PaginatedResponse; } +/** + * 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 { + 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( params: ListActivityParams = {} ): Promise> { @@ -740,6 +773,7 @@ export const api = { listRemittances, getRemittance, listProviders, + getPayerSummary, listActivity, listUnmatched, matchRemit,