feat(dashboard): KPI tiles drillable — navigate to /claims with filter

This commit is contained in:
Tyler
2026-06-21 13:20:16 -06:00
parent 50dc0b2fb3
commit 88da3a6246
3 changed files with 346 additions and 67 deletions
+86 -4
View File
@@ -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<ClaimStatus> = new Set<ClaimStatus>([
"draft",
"submitted",
"accepted",
"denied",
"paid",
"pending",
]);
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 [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const [helpOpen, setHelpOpen] = useState(false);
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 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,
};