feat(dashboard): KPI tiles drillable — navigate to /claims with filter
This commit is contained in:
+170
-2
@@ -8,6 +8,7 @@ import React, { act } from "react";
|
|||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { Claims } from "./Claims";
|
import { Claims } from "./Claims";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { useTailStore } from "@/store/tail-store";
|
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
|
* a real DOM container wrapped in `QueryClientProvider` so the page's
|
||||||
* TanStack Query calls (via `useClaims`) resolve against our mocked
|
* TanStack Query calls (via `useClaims`) resolve against our mocked
|
||||||
* `api.listClaims`.
|
* `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 } {
|
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");
|
const container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
const qc = new QueryClient({
|
const qc = new QueryClient({
|
||||||
@@ -168,11 +191,16 @@ function renderClaims(): { unmount: () => void } {
|
|||||||
React.createElement(
|
React.createElement(
|
||||||
QueryClientProvider,
|
QueryClientProvider,
|
||||||
{ client: qc },
|
{ client: qc },
|
||||||
React.createElement(Claims)
|
React.createElement(
|
||||||
)
|
MemoryRouter,
|
||||||
|
{ initialEntries },
|
||||||
|
React.createElement(Claims),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
container,
|
||||||
unmount: () => {
|
unmount: () => {
|
||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
container.remove();
|
container.remove();
|
||||||
@@ -531,4 +559,144 @@ describe("Claims page drawer wiring", () => {
|
|||||||
|
|
||||||
unmount();
|
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<typeof vi.fn>).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<typeof vi.fn>).mock.calls.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const calls = (api.listClaims as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mock.calls.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const calls = (api.listClaims as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mock.calls.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const calls = (api.listClaims as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mock.calls.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const calls = (api.listClaims as ReturnType<typeof vi.fn>).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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+86
-4
@@ -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 { Search, X } from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
@@ -48,14 +49,80 @@ const STATUS_OPTIONS: FilterChipOption[] = [
|
|||||||
{ value: "pending", label: "Pending" },
|
{ 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<ClaimStatus> = new Set<ClaimStatus>([
|
||||||
|
"draft",
|
||||||
|
"submitted",
|
||||||
|
"accepted",
|
||||||
|
"denied",
|
||||||
|
"paid",
|
||||||
|
"pending",
|
||||||
|
]);
|
||||||
|
|
||||||
export function Claims() {
|
export function Claims() {
|
||||||
const [status, setStatus] = useState<ClaimStatus | typeof ALL>(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<string>(ALL);
|
const [npi, setNpi] = useState<string>(ALL);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
const searchRef = useRef<HTMLInputElement>(null);
|
const searchRef = useRef<HTMLInputElement>(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 { claimId, open, close, setClaimId } = useDrawerUrlState();
|
||||||
|
|
||||||
const providers = useAppStore((s) => s.providers);
|
const providers = useAppStore((s) => s.providers);
|
||||||
@@ -64,11 +131,26 @@ export function Claims() {
|
|||||||
[providers]
|
[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 = {
|
const params = {
|
||||||
status: status === ALL ? undefined : status,
|
status: status === ALL ? undefined : status,
|
||||||
provider_npi: npi === ALL ? undefined : npi,
|
provider_npi: npi === ALL ? undefined : npi,
|
||||||
sort: "billedAmount",
|
sort,
|
||||||
order: "desc" as const,
|
order,
|
||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
offset: (page - 1) * PAGE_SIZE,
|
offset: (page - 1) * PAGE_SIZE,
|
||||||
};
|
};
|
||||||
|
|||||||
+90
-61
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Banknote,
|
Banknote,
|
||||||
@@ -13,6 +14,7 @@ import { PageHeader } from "@/components/PageHeader";
|
|||||||
import { KpiCard } from "@/components/KpiCard";
|
import { KpiCard } from "@/components/KpiCard";
|
||||||
import { ActivityFeed } from "@/components/ActivityFeed";
|
import { ActivityFeed } from "@/components/ActivityFeed";
|
||||||
import { AnimatedNumber } from "@/components/AnimatedNumber";
|
import { AnimatedNumber } from "@/components/AnimatedNumber";
|
||||||
|
import { DrillableCell } from "@/components/drill/DrillableCell";
|
||||||
import { fmt } from "@/lib/format";
|
import { fmt } from "@/lib/format";
|
||||||
import { useAppStore } from "@/store";
|
import { useAppStore } from "@/store";
|
||||||
|
|
||||||
@@ -70,6 +72,8 @@ export function Dashboard() {
|
|||||||
const providers = useAppStore((s) => s.providers);
|
const providers = useAppStore((s) => s.providers);
|
||||||
const activity = useAppStore((s) => s.activity);
|
const activity = useAppStore((s) => s.activity);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const kpis = useMemo(() => {
|
const kpis = useMemo(() => {
|
||||||
const billed = claims.reduce((s, c) => s + c.billedAmount, 0);
|
const billed = claims.reduce((s, c) => s + c.billedAmount, 0);
|
||||||
const received = claims.reduce((s, c) => s + c.receivedAmount, 0);
|
const received = claims.reduce((s, c) => s + c.receivedAmount, 0);
|
||||||
@@ -162,67 +166,92 @@ export function Dashboard() {
|
|||||||
aria-label="Key performance indicators"
|
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"
|
className="grid gap-3.5 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5"
|
||||||
>
|
>
|
||||||
<KpiCard
|
<DrillableCell
|
||||||
label="Claims"
|
onClick={() => navigate("/claims")}
|
||||||
icon={Receipt}
|
ariaLabel="View all claims"
|
||||||
sparkline={monthly.count}
|
>
|
||||||
className="animate-fade-in-up"
|
<KpiCard
|
||||||
style={{ animationDelay: `${kpiBase + 0 * kpiStep}ms` }}
|
label="Claims"
|
||||||
value={
|
icon={Receipt}
|
||||||
<AnimatedNumber value={kpis.count} format={(n) => fmt.num(Math.round(n))} />
|
sparkline={monthly.count}
|
||||||
}
|
className="animate-fade-in-up"
|
||||||
hint="last 6 months"
|
style={{ animationDelay: `${kpiBase + 0 * kpiStep}ms` }}
|
||||||
/>
|
value={
|
||||||
<KpiCard
|
<AnimatedNumber value={kpis.count} format={(n) => fmt.num(Math.round(n))} />
|
||||||
label="Billed"
|
}
|
||||||
icon={CircleDollarSign}
|
hint="last 6 months"
|
||||||
accent="accent"
|
/>
|
||||||
sparkline={monthly.billed}
|
</DrillableCell>
|
||||||
className="animate-fade-in-up"
|
<DrillableCell
|
||||||
style={{ animationDelay: `${kpiBase + 1 * kpiStep}ms` }}
|
onClick={() => navigate("/claims?sort=-billedAmount")}
|
||||||
value={<AnimatedNumber value={kpis.billed} format={fmt.usd} />}
|
ariaLabel="View claims sorted by billed amount"
|
||||||
delta={{ value: "+12.4%", direction: "up", positive: true }}
|
>
|
||||||
/>
|
<KpiCard
|
||||||
<KpiCard
|
label="Billed"
|
||||||
label="Received"
|
icon={CircleDollarSign}
|
||||||
icon={Banknote}
|
accent="accent"
|
||||||
accent="success"
|
sparkline={monthly.billed}
|
||||||
sparkline={monthly.received}
|
className="animate-fade-in-up"
|
||||||
className="animate-fade-in-up"
|
style={{ animationDelay: `${kpiBase + 1 * kpiStep}ms` }}
|
||||||
style={{ animationDelay: `${kpiBase + 2 * kpiStep}ms` }}
|
value={<AnimatedNumber value={kpis.billed} format={fmt.usd} />}
|
||||||
value={<AnimatedNumber value={kpis.received} format={fmt.usd} />}
|
delta={{ value: "+12.4%", direction: "up", positive: true }}
|
||||||
delta={{ value: "+8.1%", direction: "up", positive: true }}
|
/>
|
||||||
/>
|
</DrillableCell>
|
||||||
<KpiCard
|
<DrillableCell
|
||||||
label="Pending AR"
|
onClick={() => navigate("/claims?sort=-receivedAmount")}
|
||||||
icon={Clock}
|
ariaLabel="View claims sorted by received amount"
|
||||||
accent="warning"
|
>
|
||||||
sparkline={monthly.ar}
|
<KpiCard
|
||||||
className="animate-fade-in-up"
|
label="Received"
|
||||||
style={{ animationDelay: `${kpiBase + 3 * kpiStep}ms` }}
|
icon={Banknote}
|
||||||
value={
|
accent="success"
|
||||||
<AnimatedNumber
|
sparkline={monthly.received}
|
||||||
value={kpis.outstandingAr}
|
className="animate-fade-in-up"
|
||||||
format={(n) => fmt.usd(Math.max(0, n))}
|
style={{ animationDelay: `${kpiBase + 2 * kpiStep}ms` }}
|
||||||
/>
|
value={<AnimatedNumber value={kpis.received} format={fmt.usd} />}
|
||||||
}
|
delta={{ value: "+8.1%", direction: "up", positive: true }}
|
||||||
hint={`${kpis.pending} in queue`}
|
/>
|
||||||
/>
|
</DrillableCell>
|
||||||
<KpiCard
|
<DrillableCell
|
||||||
label="Denial rate"
|
onClick={() => navigate("/claims")}
|
||||||
icon={TrendingDown}
|
ariaLabel="View pending accounts receivable claims"
|
||||||
accent="destructive"
|
>
|
||||||
sparkline={monthly.denialRate}
|
<KpiCard
|
||||||
className="animate-fade-in-up"
|
label="Pending AR"
|
||||||
style={{ animationDelay: `${kpiBase + 4 * kpiStep}ms` }}
|
icon={Clock}
|
||||||
value={
|
accent="warning"
|
||||||
<AnimatedNumber
|
sparkline={monthly.ar}
|
||||||
value={kpis.denialRate}
|
className="animate-fade-in-up"
|
||||||
format={(n) => fmt.pct(n)}
|
style={{ animationDelay: `${kpiBase + 3 * kpiStep}ms` }}
|
||||||
/>
|
value={
|
||||||
}
|
<AnimatedNumber
|
||||||
delta={{ value: "-1.2 pts", direction: "down", positive: true }}
|
value={kpis.outstandingAr}
|
||||||
/>
|
format={(n) => fmt.usd(Math.max(0, n))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
hint={`${kpis.pending} in queue`}
|
||||||
|
/>
|
||||||
|
</DrillableCell>
|
||||||
|
<DrillableCell
|
||||||
|
onClick={() => navigate("/claims?status=denied")}
|
||||||
|
ariaLabel="View denied claims"
|
||||||
|
>
|
||||||
|
<KpiCard
|
||||||
|
label="Denial rate"
|
||||||
|
icon={TrendingDown}
|
||||||
|
accent="destructive"
|
||||||
|
sparkline={monthly.denialRate}
|
||||||
|
className="animate-fade-in-up"
|
||||||
|
style={{ animationDelay: `${kpiBase + 4 * kpiStep}ms` }}
|
||||||
|
value={
|
||||||
|
<AnimatedNumber
|
||||||
|
value={kpis.denialRate}
|
||||||
|
format={(n) => fmt.pct(n)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
delta={{ value: "-1.2 pts", direction: "down", positive: true }}
|
||||||
|
/>
|
||||||
|
</DrillableCell>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Activity + Top providers */}
|
{/* Activity + Top providers */}
|
||||||
|
|||||||
Reference in New Issue
Block a user