Files
cyclone/src/pages/Claims.tsx
T

428 lines
16 KiB
TypeScript

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<ClaimStatus> = new Set<ClaimStatus>([
"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<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);
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 (
<>
<ClaimDrawer
claimId={claimId}
claims={items.map((c) => ({ id: c.id }))}
onClose={close}
onNavigate={setClaimId}
onToggleHelp={() => setHelpOpen((v) => !v)}
/>
<KeyboardCheatsheet
open={helpOpen}
onClose={() => setHelpOpen(false)}
/>
<div
data-testid="claims-page-body"
className={cn(
"space-y-6 lg:space-y-8 animate-fade-in transition-opacity",
dimBackground && "pointer-events-none opacity-60"
)}
>
<PageHeader
eyebrow="Claims"
title={<>All <span className="display italic text-muted-foreground">claims</span></>}
subtitle="Every claim submitted, accepted, denied, or paid — filtered, sorted, and live."
actions={
<TailStatusPill
status={tailStatus}
lastEventAt={tailLastEventAt}
onReconnect={forceReconnect}
/>
}
status={
<NewClaimDialog />
}
/>
{isError ? (
<ErrorState
message="Couldn't load claims from the backend."
detail={error instanceof Error ? error.message : String(error)}
onRetry={() => refetch()}
/>
) : null}
<div className="surface rounded-xl p-4">
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[240px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={searchRef}
placeholder="Search by claim ID, patient, payer, CPT…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 pr-10"
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground p-1.5 rounded-md transition-colors hover:bg-muted/60"
aria-label="Clear search"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
<Select
value={npi}
onValueChange={(v) => {
setNpi(v);
setPage(1);
}}
>
<SelectTrigger className="w-[240px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All providers</SelectItem>
{providers.map((p) => (
<SelectItem key={p.npi} value={p.npi}>
{p.npi} {p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="mt-3.5">
<FilterChips
options={STATUS_OPTIONS}
value={status === ALL ? null : status}
onChange={(v) => {
setStatus((v as ClaimStatus | null) ?? ALL);
setPage(1);
}}
eyebrow="Status"
/>
</div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 mt-4 pt-4 border-t border-border/30 mono text-[11px] text-muted-foreground">
<span>
<span className="text-foreground font-medium">
{fmt.num(data?.total ?? 0)}
</span>{" "}
claims
</span>
<span>
Billed{" "}
<span className="text-foreground font-medium">
{fmt.usd(totals.billed)}
</span>
</span>
<span>
Received{" "}
<span className="text-foreground font-medium">
{fmt.usd(totals.received)}
</span>
</span>
<span className="ml-auto text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground/70">
Press <span className="kbd mx-1">?</span> for shortcuts
</span>
</div>
</div>
<div className="surface rounded-xl overflow-hidden">
{isLoading ? (
<div className="p-4 space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} variant="row" />
))}
</div>
) : items.length === 0 ? (
<EmptyState
eyebrow="Claims · inbox idle"
message={
filtered
? "No claims match the current filters."
: "Drop an 837P file on the Upload page to populate this list."
}
/>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Claim</TableHead>
<TableHead>Patient</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Payer</TableHead>
<TableHead className="text-right">Billed</TableHead>
<TableHead className="text-right">Received</TableHead>
<TableHead>Status</TableHead>
<TableHead>Submitted</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.map((c) => {
const provider = providerMap[c.providerNpi];
return (
<TableRow
key={`${c.id}-${dataUpdatedAt}`}
onClick={() => open(c.id)}
className={cn("animate-row-flash cursor-pointer")}
>
<TableCell>
<div className="display mono text-[12.5px] text-foreground">{c.id}</div>
<div className="mono text-[10.5px] text-muted-foreground">
CPT {c.cptCode}
</div>
</TableCell>
<TableCell className="font-medium text-[13px]">{c.patientName}</TableCell>
<TableCell>
<DrillableCell
ariaLabel={`View provider ${provider?.name ?? c.providerNpi}`}
onClick={() =>
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.
*/}
<div className="flex flex-col items-start">
<div className="text-[13px]">{provider?.name ?? "Unknown"}</div>
<div className="mono text-[10.5px] text-muted-foreground">
{c.providerNpi}
</div>
</div>
</DrillableCell>
</TableCell>
<TableCell className="text-muted-foreground text-[13px]">
{c.payerName}
</TableCell>
<TableCell className="text-right display mono">
{fmt.usd(c.billedAmount)}
</TableCell>
<TableCell className="text-right display mono text-muted-foreground">
{c.receivedAmount > 0 ? fmt.usd(c.receivedAmount) : "—"}
</TableCell>
<TableCell>
<StatusBadge status={c.status} />
</TableCell>
<TableCell className="text-muted-foreground mono text-[12px]">
{fmt.dateShort(c.submissionDate)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<div className="px-4 pb-4">
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={data?.total ?? 0}
onPageChange={setPage}
/>
</div>
</>
)}
</div>
</div>
</>
);
}