feat(drill): ProviderDrawer with Overview tab + DrillDrawerHeader shell
This commit is contained in:
@@ -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(<ProviderDrawer npi="1881068062" onClose={() => {}} />);
|
||||
expect(screen.getByText("Montrose Memorial")).toBeTruthy();
|
||||
expect(screen.getByText(/1881068062/)).toBeTruthy();
|
||||
expect(screen.getByText(/721587149/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<Dialog open={npi !== null} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent
|
||||
className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-border bg-card p-0"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
{npi === null ? null : (
|
||||
<>
|
||||
<DrillDrawerHeader
|
||||
eyebrow="Provider"
|
||||
title={data?.name ?? "Loading…"}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<div className="p-6 overflow-y-auto h-[calc(100%-64px)]">
|
||||
{isLoading || !data ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton variant="row" />
|
||||
<Skeleton variant="row" />
|
||||
<Skeleton variant="row" />
|
||||
</div>
|
||||
) : (
|
||||
<ProviderOverview provider={data} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="NPI" value={provider.npi} mono />
|
||||
<Field label="Tax ID" value={provider.taxId} mono />
|
||||
<Field
|
||||
label="Address"
|
||||
value={`${provider.address}, ${provider.city}, ${provider.state} ${provider.zip}`}
|
||||
/>
|
||||
<Field label="Phone" value={provider.phone} mono />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 pt-3 border-t border-border/30">
|
||||
<Field label="Claims" value={fmt.num(provider.claimCount)} mono />
|
||||
<Field label="Outstanding AR" value={fmt.usd(provider.outstandingAr)} mono />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="eyebrow">{label}</div>
|
||||
<div className={`text-[13px] mt-1 ${mono ? "display mono" : ""}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Barrel export for the ProviderDrawer module (SP21 Task 2.2).
|
||||
|
||||
export { ProviderDrawer } from "./ProviderDrawer";
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-between border-b border-border/30 px-6 py-4">
|
||||
<div>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{eyebrow}
|
||||
</div>
|
||||
<h2 className="text-[18px] font-semibold tracking-tight mt-0.5">{title}</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close drawer"
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Provider>({
|
||||
queryKey: ["provider-detail", npi],
|
||||
queryFn: () => api.getProvider(npi as string),
|
||||
enabled: npi !== null,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
+31
-1
@@ -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<T = unknown>(
|
||||
return (await res.json()) as PaginatedResponse<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Provider> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user