feat(frontend): refactor Claims page to useClaims + primitives

This commit is contained in:
Tyler
2026-06-19 19:44:00 -06:00
parent d7bd061ee0
commit 70e7745912
+160 -114
View File
@@ -18,55 +18,76 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { ClaimStatusBadge } from "@/components/StatusBadge"; import { ClaimStatusBadge } from "@/components/StatusBadge";
import { NewClaimDialog } from "@/components/NewClaimDialog"; import { NewClaimDialog } from "@/components/NewClaimDialog";
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 { useClaims } from "@/hooks/useClaims";
import { useAppStore } from "@/store"; import { useAppStore } from "@/store";
import { fmt } from "@/lib/format"; import { fmt } from "@/lib/format";
import type { ClaimStatus } from "@/types"; import type { ClaimStatus } from "@/types";
import { cn } from "@/lib/utils";
const ALL = "all" as const; const ALL = "all" as const;
const statuses: (ClaimStatus | typeof ALL)[] = [ const PAGE_SIZE = 25;
ALL,
"submitted", const STATUS_OPTIONS: FilterChipOption[] = [
"accepted", { value: "all", label: "All" },
"denied", { value: "submitted", label: "Submitted" },
"paid", { value: "accepted", label: "Accepted" },
"pending", { value: "denied", label: "Denied" },
{ value: "paid", label: "Paid" },
{ value: "pending", label: "Pending" },
]; ];
export function Claims() { export function Claims() {
const claims = useAppStore((s) => s.claims);
const providers = useAppStore((s) => s.providers);
const [query, setQuery] = useState("");
const [status, setStatus] = useState<ClaimStatus | typeof ALL>(ALL); const [status, setStatus] = useState<ClaimStatus | typeof ALL>(ALL);
const [npi, setNpi] = useState<string>(ALL); const [npi, setNpi] = useState<string>(ALL);
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const searchRef = useRef<HTMLInputElement>(null); const searchRef = useRef<HTMLInputElement>(null);
const providers = useAppStore((s) => s.providers);
const providerMap = useMemo( const providerMap = useMemo(
() => Object.fromEntries(providers.map((p) => [p.npi, p])), () => Object.fromEntries(providers.map((p) => [p.npi, p])),
[providers] [providers]
); );
const filtered = useMemo(() => { const params = {
status: status === ALL ? undefined : status,
provider_npi: npi === ALL ? undefined : npi,
sort: "billedAmount",
order: "desc" as const,
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
};
const { data, isLoading, isError, error, refetch } = useClaims(params);
const items = data?.items ?? [];
const totals = useMemo(
() => ({
billed: items.reduce((s, c) => s + c.billedAmount, 0),
received: items.reduce((s, c) => s + c.receivedAmount, 0),
}),
[items]
);
// Local search filter on the current page (cheap; no extra fetch).
const visible = useMemo(() => {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
return claims.filter((c) => { if (!q) return items;
if (status !== ALL && c.status !== status) return false; return items.filter(
if (npi !== ALL && c.providerNpi !== npi) return false; (c) =>
if (!q) return true;
return (
c.id.toLowerCase().includes(q) || c.id.toLowerCase().includes(q) ||
c.patientName.toLowerCase().includes(q) || c.patientName.toLowerCase().includes(q) ||
c.payerName.toLowerCase().includes(q) || c.payerName.toLowerCase().includes(q) ||
c.cptCode.toLowerCase().includes(q) c.cptCode.toLowerCase().includes(q)
); );
}); }, [items, query]);
}, [claims, query, status, npi]);
const totals = useMemo( const filtered = status !== ALL || npi !== ALL;
() => ({
billed: filtered.reduce((s, c) => s + c.billedAmount, 0),
received: filtered.reduce((s, c) => s + c.receivedAmount, 0),
}),
[filtered]
);
return ( return (
<div className="space-y-8 animate-fade-in"> <div className="space-y-8 animate-fade-in">
@@ -81,6 +102,14 @@ export function Claims() {
<NewClaimDialog /> <NewClaimDialog />
</header> </header>
{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="surface rounded-xl p-4">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[240px]"> <div className="relative flex-1 min-w-[240px]">
@@ -101,35 +130,16 @@ export function Claims() {
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</button> </button>
) : ( ) : null}
<button
type="button"
onClick={() => searchRef.current?.focus()}
className="absolute right-2 top-1/2 -translate-y-1/2 kbd hover:text-foreground"
aria-label="Focus search"
>
K
</button>
)}
</div> </div>
<Select <Select
value={status} value={npi}
onValueChange={(v) => setStatus(v as ClaimStatus | typeof ALL)} onValueChange={(v) => {
setNpi(v);
setPage(1);
}}
> >
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{statuses.map((s) => (
<SelectItem key={s} value={s}>
{s === ALL ? "All statuses" : s[0]!.toUpperCase() + s.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={npi} onValueChange={setNpi}>
<SelectTrigger className="w-[240px]"> <SelectTrigger className="w-[240px]">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -144,10 +154,22 @@ export function Claims() {
</Select> </Select>
</div> </div>
<div className="mt-3">
<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 items-center gap-6 mt-4 pt-4 border-t border-border/40 text-xs text-muted-foreground"> <div className="flex items-center gap-6 mt-4 pt-4 border-t border-border/40 text-xs text-muted-foreground">
<span> <span>
<span className="num text-foreground font-medium"> <span className="num text-foreground font-medium">
{fmt.num(filtered.length)} {fmt.num(data?.total ?? 0)}
</span>{" "} </span>{" "}
claims claims
</span> </span>
@@ -167,68 +189,92 @@ export function Claims() {
</div> </div>
<div className="surface rounded-xl overflow-hidden"> <div className="surface rounded-xl overflow-hidden">
<Table> {isLoading ? (
<TableHeader> <div className="p-4 space-y-2">
<TableRow> {Array.from({ length: 5 }).map((_, i) => (
<TableHead>Claim</TableHead> <Skeleton key={i} variant="row" />
<TableHead>Patient</TableHead> ))}
<TableHead>Provider</TableHead> </div>
<TableHead>Payer</TableHead> ) : items.length === 0 ? (
<TableHead className="text-right">Billed</TableHead> <EmptyState
<TableHead className="text-right">Received</TableHead> eyebrow="Claims · inbox idle"
<TableHead>Status</TableHead> message={
<TableHead>Submitted</TableHead> filtered
</TableRow> ? "No claims match the current filters."
</TableHeader> : "Drop an 837P file on the Upload page to populate this list."
<TableBody> }
{filtered.length === 0 ? ( />
<TableRow> ) : (
<TableCell <>
colSpan={8} <Table>
className="text-center py-12 text-muted-foreground" <TableHeader>
> <TableRow>
No claims match the current filters. <TableHead>Claim</TableHead>
</TableCell> <TableHead>Patient</TableHead>
</TableRow> <TableHead>Provider</TableHead>
) : ( <TableHead>Payer</TableHead>
filtered.map((c) => { <TableHead className="text-right">Billed</TableHead>
const provider = providerMap[c.providerNpi]; <TableHead className="text-right">Received</TableHead>
return ( <TableHead>Status</TableHead>
<TableRow key={c.id}> <TableHead>Submitted</TableHead>
<TableCell> </TableRow>
<div className="display num text-[13px]">{c.id}</div> </TableHeader>
<div className="text-[11px] text-muted-foreground num"> <TableBody>
CPT {c.cptCode} {visible.map((c) => {
</div> const provider = providerMap[c.providerNpi];
</TableCell> return (
<TableCell className="font-medium">{c.patientName}</TableCell> <TableRow
<TableCell> key={c.id}
<div className="text-sm">{provider?.name ?? "Unknown"}</div> className={cn(
<div className="text-[11px] text-muted-foreground num"> "animate-row-flash",
{c.providerNpi} // Suppress flash when filtering so only the new
</div> // page's first paint flashes; subsequent renders
</TableCell> // use the existing fade-in from the page wrapper.
<TableCell className="text-muted-foreground"> )}
{c.payerName} >
</TableCell> <TableCell>
<TableCell className="text-right display num"> <div className="display num text-[13px]">{c.id}</div>
{fmt.usd(c.billedAmount)} <div className="text-[11px] text-muted-foreground num">
</TableCell> CPT {c.cptCode}
<TableCell className="text-right display num text-muted-foreground"> </div>
{c.receivedAmount > 0 ? fmt.usd(c.receivedAmount) : "—"} </TableCell>
</TableCell> <TableCell className="font-medium">{c.patientName}</TableCell>
<TableCell> <TableCell>
<ClaimStatusBadge status={c.status} /> <div className="text-sm">{provider?.name ?? "Unknown"}</div>
</TableCell> <div className="text-[11px] text-muted-foreground num">
<TableCell className="text-muted-foreground text-[13px] num"> {c.providerNpi}
{fmt.dateShort(c.submissionDate)} </div>
</TableCell> </TableCell>
</TableRow> <TableCell className="text-muted-foreground">
); {c.payerName}
}) </TableCell>
)} <TableCell className="text-right display num">
</TableBody> {fmt.usd(c.billedAmount)}
</Table> </TableCell>
<TableCell className="text-right display num text-muted-foreground">
{c.receivedAmount > 0 ? fmt.usd(c.receivedAmount) : "—"}
</TableCell>
<TableCell>
<ClaimStatusBadge status={c.status} />
</TableCell>
<TableCell className="text-muted-foreground text-[13px] num">
{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>
</div> </div>
); );