diff --git a/src/components/ProviderDrawer/ProviderDrawer.test.tsx b/src/components/ProviderDrawer/ProviderDrawer.test.tsx new file mode 100644 index 0000000..800f5c6 --- /dev/null +++ b/src/components/ProviderDrawer/ProviderDrawer.test.tsx @@ -0,0 +1,46 @@ +// @vitest-environment happy-dom +// ProviderDrawer wires `useProviderDetail` (TanStack Query) and renders a +// Radix Dialog portal — both need an act-aware, DOM-backed environment or +// React logs warnings and the portal can't mount. +(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 { ProviderDrawer } from "@/components/ProviderDrawer"; + +// 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). The drawer renders the provider fields into the +// Overview tab from the `data` returned by this hook, so the mock just +// needs to supply the fields the component reads. +vi.mock("@/hooks/useProviderDetail", () => ({ + useProviderDetail: () => ({ + data: { + npi: "1881068062", + name: "Montrose Memorial", + taxId: "721587149", + address: "123 Main St", + city: "Montrose", + state: "CO", + zip: "81401", + phone: "(970) 555-1234", + claimCount: 184, + outstandingAr: 12450, + }, + isLoading: false, + isError: false, + }), +})); + +// happy-dom keeps `document.body` between tests; without cleanup, +// `screen.getByText(...)` would find nodes from earlier renders. +afterEach(() => cleanup()); + +describe("ProviderDrawer", () => { + it("renders Overview tab content for a known provider", () => { + render( {}} />); + expect(screen.getByText("Montrose Memorial")).toBeTruthy(); + expect(screen.getByText(/1881068062/)).toBeTruthy(); + expect(screen.getByText(/721587149/)).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/src/components/ProviderDrawer/ProviderDrawer.tsx b/src/components/ProviderDrawer/ProviderDrawer.tsx new file mode 100644 index 0000000..36a99ab --- /dev/null +++ b/src/components/ProviderDrawer/ProviderDrawer.tsx @@ -0,0 +1,55 @@ +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { DrillDrawerHeader } from "@/components/drill/DrillDrawerHeader"; +import { ProviderOverview } from "./ProviderOverview"; +import { useProviderDetail } from "@/hooks/useProviderDetail"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface Props { + npi: string | null; + onClose: () => void; +} + +/** + * Provider drill-down drawer (SP21 Task 2.2). + * + * Side-panel shell that consumes ``useProviderDetail(npi)`` and renders + * the Overview tab content (Phase 2 ships only this tab; Phase 3 adds + * Claims/Activity tabs once ``recent_claims`` and ``recent_activity`` + * rendering lands). + * + * Layout mirrors the ClaimDrawer / RemitDrawer pattern: Radix Dialog + * repositioned to the right edge as a fixed-height side panel, with + * the shared ``DrillDrawerHeader`` on top and a scrollable body below. + */ +export function ProviderDrawer({ npi, onClose }: Props) { + const { data, isLoading } = useProviderDetail(npi); + return ( + { if (!o) onClose(); }}> + + {npi === null ? null : ( + <> + +
+ {isLoading || !data ? ( +
+ + + +
+ ) : ( + + )} +
+ + )} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/ProviderDrawer/ProviderOverview.tsx b/src/components/ProviderDrawer/ProviderOverview.tsx new file mode 100644 index 0000000..050000f --- /dev/null +++ b/src/components/ProviderDrawer/ProviderOverview.tsx @@ -0,0 +1,43 @@ +import type { Provider } from "@/types"; +import { fmt } from "@/lib/format"; + +/** + * Overview tab content for the provider drill-down drawer (SP21 + * Task 2.2). Renders the base provider fields in a two-column grid: + * + * - Identity: NPI, Tax ID, address, phone + * - Activity: claim count, outstanding AR + * + * Subsequent tasks (Phase 3) will hang ``recent_claims`` and + * ``recent_activity`` off this surface; for now we only render the + * base fields the drawer needs to present "who is this provider and + * what's their activity shape" at a glance. + */ +export function ProviderOverview({ provider }: { provider: Provider }) { + return ( +
+
+ + + + +
+
+ + +
+
+ ); +} + +function Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+
{label}
+
{value}
+
+ ); +} \ No newline at end of file diff --git a/src/components/ProviderDrawer/index.ts b/src/components/ProviderDrawer/index.ts new file mode 100644 index 0000000..da2ebaf --- /dev/null +++ b/src/components/ProviderDrawer/index.ts @@ -0,0 +1,3 @@ +// Barrel export for the ProviderDrawer module (SP21 Task 2.2). + +export { ProviderDrawer } from "./ProviderDrawer"; \ No newline at end of file diff --git a/src/components/drill/DrillDrawerHeader.tsx b/src/components/drill/DrillDrawerHeader.tsx new file mode 100644 index 0000000..398f616 --- /dev/null +++ b/src/components/drill/DrillDrawerHeader.tsx @@ -0,0 +1,37 @@ +import { X } from "lucide-react"; + +interface Props { + eyebrow: string; + title: string; + onClose: () => void; +} + +/** + * Shared side-panel header used by every SP21 drill-down drawer + * (provider, …future ones). Renders an uppercase eyebrow above a + * larger title, with a close button on the right. + * + * Visual style mirrors the eyebrow + title pattern used elsewhere in + * the app (see ``.eyebrow`` in ``src/index.css`` and the + * ``DrillDrawerHeader`` usage in ``ClaimDrawerHeader``). + */ +export function DrillDrawerHeader({ eyebrow, title, onClose }: Props) { + return ( +
+
+
+ {eyebrow} +
+

{title}

+
+ +
+ ); +} \ No newline at end of file diff --git a/src/hooks/useProviderDetail.ts b/src/hooks/useProviderDetail.ts new file mode 100644 index 0000000..d2c91fb --- /dev/null +++ b/src/hooks/useProviderDetail.ts @@ -0,0 +1,26 @@ +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import type { Provider } from "@/types"; + +/** + * Per-provider detail drawer query (SP21 Task 2.2). + * + * Drives ``GET /api/config/providers/{npi}`` via ``api.getProvider``. + * The endpoint (added in Task 1.6) returns the full ``Provider`` shape + * with ``recent_claims`` and ``recent_activity`` arrays populated when + * the backend can serve them — Phase 3 will render those; Phase 2 only + * consumes the base fields. + * + * `npi === null` (drawer closed) disables the query so no fetch is + * issued. ``staleTime`` is 60 s — provider directory rows change + * infrequently, but we still want the drawer to refresh when a user + * reopens it across a long session. + */ +export function useProviderDetail(npi: string | null) { + return useQuery({ + queryKey: ["provider-detail", npi], + queryFn: () => api.getProvider(npi as string), + enabled: npi !== null, + staleTime: 60 * 1000, + }); +} \ No newline at end of file diff --git a/src/lib/api.ts b/src/lib/api.ts index 153d796..5e47806 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -16,7 +16,8 @@ * - `parse835(...)` mirrors the 837 shape for `/api/parse-835`. * - `health()` GETs `/api/health` and returns `{ status, version }`. * - `listBatches / getBatch / listClaims / listRemittances / listProviders - * / listActivity` are plain JSON GETs against the persistence surface. + * / getProvider / listActivity` are plain JSON GETs against the + * persistence surface. * - `listUnmatched / matchRemit / unmatchClaim` hit the reconciliation * surface. POSTs throw `ApiError` so callers can branch on `.status`. */ @@ -36,6 +37,7 @@ import type { Payer, Payer835, Payee835, + Provider, ReassociationTrace, UnmatchedClaim, UnmatchedResponse, @@ -586,6 +588,33 @@ async function listProviders( return (await res.json()) as PaginatedResponse; } +/** + * Fetch one configured provider by NPI, used by the provider drill-down + * drawer (SP21 Task 2.2 / 1.6). + * + * Drives ``GET /api/config/providers/{npi}``. Note the ``/api/config/`` + * prefix — this is the config-side route namespace, distinct from the + * persistence-side ``/api/providers`` used by ``listProviders``. The + * response is the full ``Provider`` shape (including the optional + * ``recent_claims`` and ``recent_activity`` arrays populated by Task + * 1.6 when the backend can serve them). + * + * Throws ``ApiError`` on non-2xx — including 404, which the drawer may + * want to branch on for a "provider no longer configured" state. + */ +async function getProvider(npi: string): Promise { + if (!isConfigured) throw notConfiguredError(); + const res = await fetch( + joinUrl(`/api/config/providers/${encodeURIComponent(npi)}`), + { headers: { Accept: "application/json" } } + ); + if (!res.ok) { + const detail = await readErrorBody(res); + throw new ApiError(res.status, detail || res.statusText); + } + return (await res.json()) as Provider; +} + /** * Aggregate stats for one payer, used by the peek modal on Dashboard / * Claims tables (SP21 universal drill-down). Drives @@ -773,6 +802,7 @@ export const api = { listRemittances, getRemittance, listProviders, + getProvider, getPayerSummary, listActivity, listUnmatched,