diff --git a/src/pages/Claims.test.tsx b/src/pages/Claims.test.tsx index 32ac777..da6ced0 100644 --- a/src/pages/Claims.test.tsx +++ b/src/pages/Claims.test.tsx @@ -8,6 +8,7 @@ import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router-dom"; import { Claims } from "./Claims"; import { api } from "@/lib/api"; import { useTailStore } from "@/store/tail-store"; @@ -148,8 +149,30 @@ function setLocation(url: string): void { * a real DOM container wrapped in `QueryClientProvider` so the page's * TanStack Query calls (via `useClaims`) resolve against our mocked * `api.listClaims`. + * + * Also wraps in a `MemoryRouter` so the page's `useSearchParams` + * (added when Claims started reading `?status=` / `?sort=` off the URL + * for Dashboard KPI drill-through) has a router context to read from. + * The default `initialEntries` mirrors whatever `setLocation(...)` set + * on `window.location`, so the existing tests' pre-set URLs keep + * working without per-test changes. */ function renderClaims(): { unmount: () => void } { + const initialEntries = [ + window.location.pathname + window.location.search, + ]; + return renderClaimsAt(initialEntries); +} + +/** + * Variant of `renderClaims` that lets a test pin a specific initial + * URL. Used by the URL-param filter tests below so each one can start + * at exactly the URL it's asserting on. + */ +function renderClaimsAt(initialEntries: string[]): { + unmount: () => void; + container: HTMLDivElement; +} { const container = document.createElement("div"); document.body.appendChild(container); const qc = new QueryClient({ @@ -168,11 +191,16 @@ function renderClaims(): { unmount: () => void } { React.createElement( QueryClientProvider, { client: qc }, - React.createElement(Claims) - ) + React.createElement( + MemoryRouter, + { initialEntries }, + React.createElement(Claims), + ), + ), ); }); return { + container, unmount: () => { act(() => root.unmount()); container.remove(); @@ -531,4 +559,144 @@ describe("Claims page drawer wiring", () => { unmount(); }); + + // ------------------------------------------------------------------- + // URL-driven filter state (sub-project 6, Phase 1 Task 1.8 follow-up). + // + // Dashboard KPI tiles drill through to Claims with `?status=` and + // `?sort=` query params. These tests pin each URL and assert that + // the page actually filters (not just lands on a default view). + // + // The strongest assertion is on `api.listClaims.mock.calls` — that's + // the exact params the page forwards to the API, so a passing test + // proves end-to-end that URL → useSearchParams → useClaims → fetch. + // ------------------------------------------------------------------- + + it("test_url_param_status_denied_filters_and_selects_chip", async () => { + const { unmount, container } = renderClaimsAt(["/claims?status=denied"]); + + // The first render of FilterChips is reached before useSearchParams + // has the URL-derived `status` value committed (MemoryRouter + // initializes synchronously but the chip's aria-checked only flips + // to "true" on the next React commit). Wait for that commit so + // the assertion below isn't a race against the first render. + // We settle on `useClaims` having been called with status=denied + // — that's the strongest invariant: it proves the URL → state → + // API-call chain has fully propagated end-to-end. + await settle( + () => { + const calls = (api.listClaims as ReturnType).mock.calls; + const lastCallParams = calls[calls.length - 1]?.[0] as + | { status?: string; sort?: string; order?: string } + | undefined; + return lastCallParams?.status === "denied"; + }, + ); + + // Scope queries to this test's container so we can't accidentally + // pick up a chip from a leaked render of a previous test. + const deniedChip = Array.from( + container.querySelectorAll('[role="radio"]'), + ).find( + (el) => el.textContent?.trim().toLowerCase() === "denied", + ) as HTMLButtonElement | undefined; + expect(deniedChip).toBeDefined(); + expect(deniedChip?.getAttribute("aria-checked")).toBe("true"); + + // No other status chip should be checked. + const checkedChips = Array.from( + container.querySelectorAll('[role="radio"][aria-checked="true"]'), + ); + expect(checkedChips).toHaveLength(1); + expect(checkedChips[0]?.textContent?.trim().toLowerCase()).toBe( + "denied", + ); + + unmount(); + }); + + it("test_url_param_sort_minus_receivedAmount_sorts_by_received_desc", async () => { + const { unmount } = renderClaimsAt(["/claims?sort=-receivedAmount"]); + + // Wait for useClaims to be called once. + await settle( + () => (api.listClaims as ReturnType).mock.calls.length > 0, + ); + + const calls = (api.listClaims as ReturnType).mock.calls; + const lastCallParams = calls[calls.length - 1]?.[0] as + | { status?: string; sort?: string; order?: string } + | undefined; + + // The `-` prefix must be stripped off and order set to desc. + expect(lastCallParams?.sort).toBe("receivedAmount"); + expect(lastCallParams?.order).toBe("desc"); + // No status filter — user only asked for a sort change. + expect(lastCallParams?.status).toBeUndefined(); + + unmount(); + }); + + it("test_url_param_sort_without_prefix_orders_ascending", async () => { + const { unmount } = renderClaimsAt(["/claims?sort=submittedDate"]); + + await settle( + () => (api.listClaims as ReturnType).mock.calls.length > 0, + ); + + const calls = (api.listClaims as ReturnType).mock.calls; + const lastCallParams = calls[calls.length - 1]?.[0] as + | { status?: string; sort?: string; order?: string } + | undefined; + + // No `-` prefix → order is asc. + expect(lastCallParams?.sort).toBe("submittedDate"); + expect(lastCallParams?.order).toBe("asc"); + + unmount(); + }); + + it("test_no_url_params_defaults_to_billed_desc_all_statuses", async () => { + // Default URL — matches the existing "click a row to open the + // drawer" tests, but here we assert on the params forwarded to + // the API to prove the no-param default matches the previous + // hardcoded behavior (sort=billedAmount, order=desc, no status). + const { unmount } = renderClaimsAt(["/claims"]); + + await settle( + () => (api.listClaims as ReturnType).mock.calls.length > 0, + ); + + const calls = (api.listClaims as ReturnType).mock.calls; + const lastCallParams = calls[calls.length - 1]?.[0] as + | { status?: string; sort?: string; order?: string } + | undefined; + + expect(lastCallParams?.status).toBeUndefined(); + expect(lastCallParams?.sort).toBe("billedAmount"); + expect(lastCallParams?.order).toBe("desc"); + + unmount(); + }); + + it("test_url_param_sort_dash_only_falls_back_to_default", async () => { + // `?sort=-` is the prefix-with-no-field degenerate case. A naive + // `slice(1)` would yield "" and the API would get `sort=""`. + // We assert the page falls back to the default sort instead. + const { unmount } = renderClaimsAt(["/claims?sort=-"]); + + await settle( + () => (api.listClaims as ReturnType).mock.calls.length > 0, + ); + + const calls = (api.listClaims as ReturnType).mock.calls; + const lastCallParams = calls[calls.length - 1]?.[0] as + | { status?: string; sort?: string; order?: string } + | undefined; + + expect(lastCallParams?.sort).toBe("billedAmount"); + expect(lastCallParams?.order).toBe("desc"); + + unmount(); + }); }); diff --git a/src/pages/Claims.tsx b/src/pages/Claims.tsx index a505eb4..914ce97 100644 --- a/src/pages/Claims.tsx +++ b/src/pages/Claims.tsx @@ -1,4 +1,5 @@ -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useSearchParams } from "react-router-dom"; import { Search, X } from "lucide-react"; import { Input } from "@/components/ui/input"; import { @@ -48,14 +49,80 @@ const STATUS_OPTIONS: FilterChipOption[] = [ { value: "pending", label: "Pending" }, ]; +// Whitelist of valid `?status=` values — anything else falls back to ALL. +// Used both to validate the URL param on read AND to guard the Dropdown +// onValueChange so the URL never carries a bogus value. +const VALID_STATUSES: ReadonlySet = new Set([ + "draft", + "submitted", + "accepted", + "denied", + "paid", + "pending", +]); + export function Claims() { - const [status, setStatus] = useState(ALL); + // ------------------------------------------------------------------------- + // URL-driven filter state. + // + // The page reads `?status=` and `?sort=` off the URL (via React Router's + // useSearchParams) so deep links from the Dashboard KPI tiles — e.g. + // navigate("/claims?status=denied") — actually filter, not just land on + // a default view. Status and sort/order derive from the URL; pagination + // (`?page=`) and search (`?q=`) stay local because they're ephemeral + // view state that doesn't deserve a URL slot. + // + // Sort syntax: `?sort=billedAmount` → asc, `?sort=-billedAmount` → desc. + // The leading `-` is the conventional "negate" prefix used by most + // list APIs (incl. our own /api/claims), so we mirror it instead of + // using a separate `?order=` param. + // ------------------------------------------------------------------------- + const [searchParams, setSearchParams] = useSearchParams(); + + const rawStatus = searchParams.get("status"); + const status: ClaimStatus | typeof ALL = + rawStatus && VALID_STATUSES.has(rawStatus as ClaimStatus) + ? (rawStatus as ClaimStatus) + : ALL; + + const rawSort = searchParams.get("sort"); + let sort: string; + let order: "asc" | "desc"; + if (rawSort && rawSort.length > 1 && rawSort.startsWith("-")) { + // Strip the `-` desc-marker, but only when there's an actual field + // after it. `?sort=-` (prefix only, no field) falls through to the + // default below — otherwise `slice(1)` would yield `""` and we'd + // hit the API with `sort=""`. + sort = rawSort.slice(1); + order = "desc"; + } else if (rawSort && rawSort !== "-") { + sort = rawSort; + order = "asc"; + } else { + // Default — match the previous hardcoded behavior so existing + // bookmarks and the "no params" path keep sorting by billed desc. + // Also catches `?sort=-` (no field after the prefix) and any other + // degenerate value. + sort = "billedAmount"; + order = "desc"; + } + const [npi, setNpi] = useState(ALL); const [query, setQuery] = useState(""); const [page, setPage] = useState(1); const [helpOpen, setHelpOpen] = useState(false); const searchRef = useRef(null); + // Reset pagination whenever the URL-driven filters change. Without + // this, a deep link like `/claims?status=denied` lands on whatever + // page the user was last on (e.g. page 5 of an unfiltered list) and + // shows an empty view. Safe to run on every render of `status` or + // `sort` because `setPage(1)` is idempotent when already at 1, and + // changing `page` doesn't trigger this effect. + useEffect(() => { + setPage(1); + }, [status, sort]); + const { claimId, open, close, setClaimId } = useDrawerUrlState(); const providers = useAppStore((s) => s.providers); @@ -64,11 +131,26 @@ export function Claims() { [providers] ); + // Drop the `status` query param entirely when going back to "all", so + // the URL stays clean for sharing. Caller still owns `setPage(1)` to + // reset pagination. + const setStatus = (next: ClaimStatus | typeof ALL): void => { + setSearchParams( + (prev) => { + const params = new URLSearchParams(prev); + if (next === ALL) params.delete("status"); + else params.set("status", next); + return params; + }, + { replace: false }, + ); + }; + const params = { status: status === ALL ? undefined : status, provider_npi: npi === ALL ? undefined : npi, - sort: "billedAmount", - order: "desc" as const, + sort, + order, limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, }; diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 7fe890f..38db9f3 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { useNavigate } from "react-router-dom"; import { AlertCircle, Banknote, @@ -13,6 +14,7 @@ import { PageHeader } from "@/components/PageHeader"; import { KpiCard } from "@/components/KpiCard"; import { ActivityFeed } from "@/components/ActivityFeed"; import { AnimatedNumber } from "@/components/AnimatedNumber"; +import { DrillableCell } from "@/components/drill/DrillableCell"; import { fmt } from "@/lib/format"; import { useAppStore } from "@/store"; @@ -70,6 +72,8 @@ export function Dashboard() { const providers = useAppStore((s) => s.providers); const activity = useAppStore((s) => s.activity); + const navigate = useNavigate(); + const kpis = useMemo(() => { const billed = claims.reduce((s, c) => s + c.billedAmount, 0); const received = claims.reduce((s, c) => s + c.receivedAmount, 0); @@ -162,67 +166,92 @@ export function Dashboard() { aria-label="Key performance indicators" className="grid gap-3.5 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5" > - fmt.num(Math.round(n))} /> - } - hint="last 6 months" - /> - } - delta={{ value: "+12.4%", direction: "up", positive: true }} - /> - } - delta={{ value: "+8.1%", direction: "up", positive: true }} - /> - fmt.usd(Math.max(0, n))} - /> - } - hint={`${kpis.pending} in queue`} - /> - fmt.pct(n)} - /> - } - delta={{ value: "-1.2 pts", direction: "down", positive: true }} - /> + navigate("/claims")} + ariaLabel="View all claims" + > + fmt.num(Math.round(n))} /> + } + hint="last 6 months" + /> + + navigate("/claims?sort=-billedAmount")} + ariaLabel="View claims sorted by billed amount" + > + } + delta={{ value: "+12.4%", direction: "up", positive: true }} + /> + + navigate("/claims?sort=-receivedAmount")} + ariaLabel="View claims sorted by received amount" + > + } + delta={{ value: "+8.1%", direction: "up", positive: true }} + /> + + navigate("/claims")} + ariaLabel="View pending accounts receivable claims" + > + fmt.usd(Math.max(0, n))} + /> + } + hint={`${kpis.pending} in queue`} + /> + + navigate("/claims?status=denied")} + ariaLabel="View denied claims" + > + fmt.pct(n)} + /> + } + delta={{ value: "-1.2 pts", direction: "down", positive: true }} + /> + {/* Activity + Top providers */}