From 08e83da91dbca07dd69c3dc7214b3bd4e02eb436 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 21 Jun 2026 16:54:50 -0600 Subject: [PATCH] =?UTF-8?q?feat(drill):=20ProviderDrawer=20=E2=80=94=20Cla?= =?UTF-8?q?ims=20+=20Activity=20tabs=20from=20extended=20/providers/{npi}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 62 ++++++++++ package.json | 1 + .../ProviderDrawer/ProviderDrawer.test.tsx | 107 ++++++++++++++++++ .../ProviderDrawer/ProviderDrawer.tsx | 35 ++++-- .../ProviderDrawer/ProviderRecentActivity.tsx | 34 ++++++ .../ProviderDrawer/ProviderRecentClaims.tsx | 43 +++++++ src/components/ui/tabs.tsx | 84 ++++++++++++++ 7 files changed, 359 insertions(+), 7 deletions(-) create mode 100644 src/components/ProviderDrawer/ProviderRecentActivity.tsx create mode 100644 src/components/ProviderDrawer/ProviderRecentClaims.tsx create mode 100644 src/components/ui/tabs.tsx diff --git a/package-lock.json b/package-lock.json index efb4461..b7903f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.15", "@tanstack/react-query": "^5.101.0", "ansi-styles": "^6.2.3", "class-variance-authority": "^0.7.0", @@ -1331,6 +1332,37 @@ } } }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.13.tgz", + "integrity": "sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.1.tgz", @@ -1393,6 +1425,36 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.15.tgz", + "integrity": "sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", diff --git a/package.json b/package.json index 935ec2e..b9de198 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.15", "@tanstack/react-query": "^5.101.0", "ansi-styles": "^6.2.3", "class-variance-authority": "^0.7.0", diff --git a/src/components/ProviderDrawer/ProviderDrawer.test.tsx b/src/components/ProviderDrawer/ProviderDrawer.test.tsx index 6bccef4..e7a0bb3 100644 --- a/src/components/ProviderDrawer/ProviderDrawer.test.tsx +++ b/src/components/ProviderDrawer/ProviderDrawer.test.tsx @@ -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( {}} />); + + // 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( {}} />); + + 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( {}} />); + + 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"); + }); }); diff --git a/src/components/ProviderDrawer/ProviderDrawer.tsx b/src/components/ProviderDrawer/ProviderDrawer.tsx index 6c5a920..1a753b0 100644 --- a/src/components/ProviderDrawer/ProviderDrawer.tsx +++ b/src/components/ProviderDrawer/ProviderDrawer.tsx @@ -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} /> -
- -
+ + + Overview + Claims + Activity + + + + + + + + + + + )} diff --git a/src/components/ProviderDrawer/ProviderRecentActivity.tsx b/src/components/ProviderDrawer/ProviderRecentActivity.tsx new file mode 100644 index 0000000..ddb848d --- /dev/null +++ b/src/components/ProviderDrawer/ProviderRecentActivity.tsx @@ -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 ( +
No recent activity.
+ ); + } + return ( +
    + {items.map((a) => ( +
  • + + {new Date(a.ts).toLocaleString()} + + {a.kind} +
  • + ))} +
+ ); +} \ No newline at end of file diff --git a/src/components/ProviderDrawer/ProviderRecentClaims.tsx b/src/components/ProviderDrawer/ProviderRecentClaims.tsx new file mode 100644 index 0000000..5d83e59 --- /dev/null +++ b/src/components/ProviderDrawer/ProviderRecentClaims.tsx @@ -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 ( +
No recent claims.
+ ); + } + return ( +
+ {claims.map((c) => ( +
+
{c.id}
+
+ {c.patientName ?? "—"} +
+
+ {fmt.usd(c.billedAmount)} +
+
+ ))} + + View all claims → + +
+ ); +} \ No newline at end of file diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 0000000..82e5f36 --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -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 ``, ``, ``, + * `` dot-notation directly: + * + * import { Tabs } from "@/components/ui/tabs"; + * + * + * Overview + * ... + * + * ... + * + */ + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +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 }; \ No newline at end of file