feat(drill): ProviderDrawer — Claims + Activity tabs from extended /providers/{npi}
This commit is contained in:
@@ -44,6 +44,51 @@ const SAMPLE_PROVIDER: Provider = {
|
||||
outstandingAr: 12450,
|
||||
};
|
||||
|
||||
/**
|
||||
* Extended provider fixture (SP21 Task 3.1) — adds the
|
||||
* `recent_claims` and `recent_activity` slices the Claims/Activity
|
||||
* tabs read from. Used by the tabs tests below. Field shapes match
|
||||
* `ClaimSummary` and `ActivityEvent` from `@/types`.
|
||||
*/
|
||||
const SAMPLE_PROVIDER_WITH_DETAILS: Provider = {
|
||||
...SAMPLE_PROVIDER,
|
||||
recent_claims: [
|
||||
{
|
||||
id: "CLM-0001",
|
||||
state: "submitted",
|
||||
billedAmount: 250,
|
||||
patientName: "Jane Q Patient",
|
||||
providerNpi: "1881068062",
|
||||
payerName: "Aetna",
|
||||
cptCode: "99213",
|
||||
submissionDate: "2026-06-20",
|
||||
parsedAt: "2026-06-20",
|
||||
status: "submitted",
|
||||
batchId: "batch-001",
|
||||
},
|
||||
],
|
||||
recent_activity: [
|
||||
{
|
||||
id: 1,
|
||||
ts: "2026-06-20T15:30:00Z",
|
||||
kind: "claim_submitted",
|
||||
batchId: "batch-001",
|
||||
claimId: "CLM-0001",
|
||||
remittanceId: null,
|
||||
payload: {},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
ts: "2026-06-20T15:35:00Z",
|
||||
kind: "claim_paid",
|
||||
batchId: "batch-001",
|
||||
claimId: "CLM-0001",
|
||||
remittanceId: null,
|
||||
payload: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Configure the mocked hook's return value for a single test. The
|
||||
* `refetch` default is a fresh `vi.fn()` — tests that need to assert
|
||||
@@ -193,4 +238,66 @@ describe("ProviderDrawer", () => {
|
||||
fireEvent.click(closeBtn!);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// -- Tabs (SP21 Task 3.1) -------------------------------------------------
|
||||
//
|
||||
// Radix Tabs only mounts the active `Tabs.Content` into the DOM (no
|
||||
// `forceMount`), so clicking a tab trigger causes the previous panel to
|
||||
// unmount and the new one to mount. The tests below rely on that —
|
||||
// "panel X renders" is asserted by checking that unique text from that
|
||||
// panel's component is present in `document.body` after the click.
|
||||
|
||||
it("test_renders_three_tabs_overview_claims_activity", () => {
|
||||
mockDetail({ data: SAMPLE_PROVIDER_WITH_DETAILS });
|
||||
render(<ProviderDrawer npi="1881068062" onClose={() => {}} />);
|
||||
|
||||
// All three tab triggers are present, with the spec-mandated labels
|
||||
// in the spec-mandated order.
|
||||
const triggers = Array.from(document.querySelectorAll('[role="tab"]'));
|
||||
expect(triggers.length).toBe(3);
|
||||
expect(triggers.map((t) => t.textContent)).toEqual([
|
||||
"Overview",
|
||||
"Claims",
|
||||
"Activity",
|
||||
]);
|
||||
// Overview is the default; its trigger must be selected on mount.
|
||||
expect(triggers[0]?.getAttribute("aria-selected")).toBe("true");
|
||||
expect(triggers[0]?.getAttribute("data-state")).toBe("active");
|
||||
});
|
||||
|
||||
it("test_switches_to_claims_tab_and_shows_recent_claims", () => {
|
||||
mockDetail({ data: SAMPLE_PROVIDER_WITH_DETAILS });
|
||||
render(<ProviderDrawer npi="1881068062" onClose={() => {}} />);
|
||||
|
||||
const claimsTrigger = Array.from(document.querySelectorAll('[role="tab"]'))
|
||||
.find((t) => t.textContent === "Claims");
|
||||
expect(claimsTrigger).not.toBeNull();
|
||||
// Radix Tabs wires `onMouseDown` (not `onClick`) to its
|
||||
// `onValueChange` handler — fire the matching event so the tab
|
||||
// actually activates under happy-dom.
|
||||
fireEvent.mouseDown(claimsTrigger!);
|
||||
|
||||
// Claims tab is now selected — and ProviderRecentClaims content is in
|
||||
// the DOM (claim id + patient name + the "View all claims" link).
|
||||
expect(claimsTrigger?.getAttribute("aria-selected")).toBe("true");
|
||||
expect(document.body.textContent).toContain("CLM-0001");
|
||||
expect(document.body.textContent).toContain("Jane Q Patient");
|
||||
expect(document.body.textContent).toContain("View all claims");
|
||||
});
|
||||
|
||||
it("test_switches_to_activity_tab_and_shows_recent_activity", () => {
|
||||
mockDetail({ data: SAMPLE_PROVIDER_WITH_DETAILS });
|
||||
render(<ProviderDrawer npi="1881068062" onClose={() => {}} />);
|
||||
|
||||
const activityTrigger = Array.from(document.querySelectorAll('[role="tab"]'))
|
||||
.find((t) => t.textContent === "Activity");
|
||||
expect(activityTrigger).not.toBeNull();
|
||||
fireEvent.mouseDown(activityTrigger!);
|
||||
|
||||
// Activity tab is now selected — and ProviderRecentActivity content
|
||||
// is in the DOM (both `kind` strings from the fixture).
|
||||
expect(activityTrigger?.getAttribute("aria-selected")).toBe("true");
|
||||
expect(document.body.textContent).toContain("claim_submitted");
|
||||
expect(document.body.textContent).toContain("claim_paid");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { DrillDrawerHeader } from "@/components/drill/DrillDrawerHeader";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useProviderDetail } from "@/hooks/useProviderDetail";
|
||||
import { ProviderOverview } from "./ProviderOverview";
|
||||
import { ProviderRecentClaims } from "./ProviderRecentClaims";
|
||||
import { ProviderRecentActivity } from "./ProviderRecentActivity";
|
||||
import { ProviderDrawerError } from "./ProviderDrawerError";
|
||||
|
||||
interface Props {
|
||||
@@ -12,12 +15,17 @@ interface Props {
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider drill-down drawer (SP21 Task 2.2).
|
||||
* Provider drill-down drawer (SP21 Task 2.2 + 3.1).
|
||||
*
|
||||
* 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).
|
||||
* a three-tab body:
|
||||
*
|
||||
* - Overview — base provider fields (identity + activity shape)
|
||||
* - Claims — top-10 claims joined to this provider
|
||||
* (extended `/api/config/providers/{npi}.recent_claims`,
|
||||
* populated by Task 1.6)
|
||||
* - Activity — top-10 events joined to this provider's claims
|
||||
* (extended `/api/config/providers/{npi}.recent_activity`)
|
||||
*
|
||||
* Layout mirrors the ClaimDrawer / RemitDrawer pattern: Radix Dialog
|
||||
* repositioned to the right edge as a fixed-height side panel, with
|
||||
@@ -76,9 +84,22 @@ export function ProviderDrawer({ npi, onClose }: Props) {
|
||||
title={data.name}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<div className="p-6">
|
||||
<ProviderOverview provider={data} />
|
||||
</div>
|
||||
<Tabs.Root defaultValue="overview" className="px-6 py-4">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="overview">Overview</Tabs.Trigger>
|
||||
<Tabs.Trigger value="claims">Claims</Tabs.Trigger>
|
||||
<Tabs.Trigger value="activity">Activity</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="overview">
|
||||
<ProviderOverview provider={data} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="claims">
|
||||
<ProviderRecentClaims provider={data} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="activity">
|
||||
<ProviderRecentActivity provider={data} />
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Provider } from "@/types";
|
||||
|
||||
/**
|
||||
* Activity tab content for the ProviderDrawer (SP21 Task 3.1).
|
||||
*
|
||||
* STUB — renders the top-10 `recent_activity` events as a flat list
|
||||
* (`ts` + `kind`). Real activity-event routing (linking each event to
|
||||
* its source claim/remit/provider) arrives in Phase 4 once
|
||||
* `provider_added` and `remit_received` events get their drawer
|
||||
* surfaces; for now the events aren't drillable from this view —
|
||||
* clicking them does nothing. The ProviderDrawer's Overview already
|
||||
* surfaces the provider's `provider_added` event via the Dashboard
|
||||
* activity feed (Task 2.5).
|
||||
*/
|
||||
export function ProviderRecentActivity({ provider }: { provider: Provider }) {
|
||||
const items = provider.recent_activity ?? [];
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground text-[13px]">No recent activity.</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ul className="space-y-1.5 text-[12.5px]">
|
||||
{items.map((a) => (
|
||||
<li key={a.id} className="flex items-center gap-2">
|
||||
<span className="mono text-[10.5px] text-muted-foreground">
|
||||
{new Date(a.ts).toLocaleString()}
|
||||
</span>
|
||||
<span>{a.kind}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Provider } from "@/types";
|
||||
import { fmt } from "@/lib/format";
|
||||
|
||||
/**
|
||||
* Claims tab content for the ProviderDrawer (SP21 Task 3.1).
|
||||
*
|
||||
* Reads `provider.recent_claims` — the top-10 claims joined to this
|
||||
* provider from the extended `GET /api/config/providers/{npi}` endpoint
|
||||
* (Task 1.6). Renders one row per claim (`id` + `patientName` +
|
||||
* `billedAmount`) with a "View all claims →" link to the global claims
|
||||
* page at the bottom. Falls back to an empty-state line when the
|
||||
* provider has no recent claims (e.g. legacy callers that hit the
|
||||
* detail endpoint without the recent-claims slice).
|
||||
*/
|
||||
export function ProviderRecentClaims({ provider }: { provider: Provider }) {
|
||||
const claims = provider.recent_claims ?? [];
|
||||
if (claims.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground text-[13px]">No recent claims.</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{claims.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="flex items-center gap-3 py-2 border-b border-border/30 last:border-0"
|
||||
>
|
||||
<div className="display mono text-[12.5px] w-32 shrink-0">{c.id}</div>
|
||||
<div className="flex-1 min-w-0 text-[12.5px] text-muted-foreground truncate">
|
||||
{c.patientName ?? "—"}
|
||||
</div>
|
||||
<div className="display mono text-[13px]">
|
||||
{fmt.usd(c.billedAmount)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<a href="/claims" className="text-[12.5px] text-accent hover:underline">
|
||||
View all claims →
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Tabs primitive — SP21 Universal Drill-Down (Task 3.1).
|
||||
*
|
||||
* Thin wrapper over `@radix-ui/react-tabs`. Mirrors the same re-export
|
||||
* pattern used by `dialog.tsx`: the individual primitives are forwardRef'd
|
||||
* styled components, and the `Tabs` namespace object lets callers use
|
||||
* the spec's `<Tabs.Root>`, `<Tabs.List>`, `<Tabs.Trigger>`,
|
||||
* `<Tabs.Content>` dot-notation directly:
|
||||
*
|
||||
* import { Tabs } from "@/components/ui/tabs";
|
||||
* <Tabs.Root defaultValue="overview">
|
||||
* <Tabs.List>
|
||||
* <Tabs.Trigger value="overview">Overview</Tabs.Trigger>
|
||||
* ...
|
||||
* </Tabs.List>
|
||||
* <Tabs.Content value="overview">...</Tabs.Content>
|
||||
* </Tabs.Root>
|
||||
*/
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex gap-2 border-b border-border/30 mb-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-3 py-2 text-[12.5px] text-muted-foreground",
|
||||
"data-[state=active]:text-foreground",
|
||||
"data-[state=active]:border-b-2 data-[state=active]:border-accent",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
/**
|
||||
* Namespace export matching the spec's `import * as Tabs from ...`
|
||||
* dot-notation. `Root` is the unstyled Radix primitive; the rest are the
|
||||
* styled wrappers above.
|
||||
*/
|
||||
export const Tabs = {
|
||||
Root: TabsPrimitive.Root,
|
||||
List: TabsList,
|
||||
Trigger: TabsTrigger,
|
||||
Content: TabsContent,
|
||||
};
|
||||
|
||||
export { TabsList, TabsTrigger, TabsContent };
|
||||
Reference in New Issue
Block a user