import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { Search, X } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { StatusBadge } from "@/components/StatusBadge"; import { NewClaimDialog } from "@/components/NewClaimDialog"; import { ClaimDrawer } from "@/components/ClaimDrawer"; import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet"; import { Skeleton } from "@/components/ui/skeleton"; import { EmptyState } from "@/components/ui/empty-state"; import { ErrorState } from "@/components/ui/error-state"; import { FilterChips, type FilterChipOption } from "@/components/ui/filter-chips"; import { Pagination } from "@/components/ui/pagination"; import { PageHeader } from "@/components/PageHeader"; import { useClaims } from "@/hooks/useClaims"; import { useDrawerUrlState } from "@/hooks/useDrawerUrlState"; import { DrillableCell } from "@/components/drill/DrillableCell"; import { useTailStream } from "@/hooks/useTailStream"; import { useMergedTail } from "@/hooks/useMergedTail"; import { TailStatusPill } from "@/components/TailStatusPill"; import { useAppStore } from "@/store"; import { fmt } from "@/lib/format"; import type { Claim, ClaimStatus } from "@/types"; import { cn } from "@/lib/utils"; const ALL = "all" as const; const PAGE_SIZE = 25; const STATUS_OPTIONS: FilterChipOption[] = [ { value: "all", label: "All" }, { value: "submitted", label: "Submitted" }, { value: "accepted", label: "Accepted" }, { value: "denied", label: "Denied" }, { value: "paid", label: "Paid" }, { 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() { // ------------------------------------------------------------------------- // 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 navigate = useNavigate(); 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); const providerMap = useMemo( () => Object.fromEntries(providers.map((p) => [p.npi, p])), [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, order, limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, }; const { data, isLoading, isError, error, refetch, dataUpdatedAt } = useClaims(params); const tailFilterFn = useMemo( () => (c: Claim) => { if (status !== ALL && c.status !== status) return false; if (npi !== ALL && c.providerNpi !== npi) return false; return true; }, [status, npi], ); const { status: tailStatus, lastEventAt: tailLastEventAt, forceReconnect } = useTailStream("claims"); const items = useMergedTail("claims", data?.items ?? [], tailFilterFn); const totals = useMemo( () => ({ billed: items.reduce((s, c) => s + c.billedAmount, 0), received: items.reduce((s, c) => s + c.receivedAmount, 0), }), [items] ); const visible = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return items; return items.filter( (c) => c.id.toLowerCase().includes(q) || c.patientName.toLowerCase().includes(q) || c.payerName.toLowerCase().includes(q) || c.cptCode.toLowerCase().includes(q) ); }, [items, query]); const filtered = status !== ALL || npi !== ALL; const dimBackground = claimId !== null; return ( <> ({ id: c.id }))} onClose={close} onNavigate={setClaimId} onToggleHelp={() => setHelpOpen((v) => !v)} /> setHelpOpen(false)} />
All claims} subtitle="Every claim submitted, accepted, denied, or paid — filtered, sorted, and live." actions={ } status={ } /> {isError ? ( refetch()} /> ) : null}
setQuery(e.target.value)} className="pl-9 pr-10" /> {query ? ( ) : null}
{ setStatus((v as ClaimStatus | null) ?? ALL); setPage(1); }} eyebrow="Status" />
{fmt.num(data?.total ?? 0)} {" "} claims Billed{" "} {fmt.usd(totals.billed)} Received{" "} {fmt.usd(totals.received)} Press ? for shortcuts
{isLoading ? (
{Array.from({ length: 5 }).map((_, i) => ( ))}
) : items.length === 0 ? ( ) : ( <> Claim Patient Provider Payer Billed Received Status Submitted {visible.map((c) => { const provider = providerMap[c.providerNpi]; return ( open(c.id)} className={cn("animate-row-flash cursor-pointer")} >
{c.id}
CPT {c.cptCode}
{c.patientName} navigate( `/providers?provider=${encodeURIComponent(c.providerNpi)}`, ) } > {/* DrillableCell renders an inline-flex button, so the two stacked lines would otherwise land side-by-side. The flex-col wrapper preserves the original "name on top, NPI below" layout. */}
{provider?.name ?? "Unknown"}
{c.providerNpi}
{c.payerName} {fmt.usd(c.billedAmount)} {c.receivedAmount > 0 ? fmt.usd(c.receivedAmount) : "—"} {fmt.dateShort(c.submissionDate)}
); })}
)}
); }