feat(drill): ProviderDrawer with Overview tab + DrillDrawerHeader shell

This commit is contained in:
Tyler
2026-06-21 14:40:08 -06:00
parent 5b3b8619e6
commit 0678e25de7
7 changed files with 241 additions and 1 deletions
@@ -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>
);
}
+3
View File
@@ -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>
);
}