feat(frontend): refactor Claims page to useClaims + primitives
This commit is contained in:
+111
-65
@@ -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,6 +189,23 @@ export function Claims() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="surface rounded-xl overflow-hidden">
|
<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>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -181,20 +220,18 @@ export function Claims() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filtered.length === 0 ? (
|
{visible.map((c) => {
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={8}
|
|
||||||
className="text-center py-12 text-muted-foreground"
|
|
||||||
>
|
|
||||||
No claims match the current filters.
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
filtered.map((c) => {
|
|
||||||
const provider = providerMap[c.providerNpi];
|
const provider = providerMap[c.providerNpi];
|
||||||
return (
|
return (
|
||||||
<TableRow key={c.id}>
|
<TableRow
|
||||||
|
key={c.id}
|
||||||
|
className={cn(
|
||||||
|
"animate-row-flash",
|
||||||
|
// Suppress flash when filtering so only the new
|
||||||
|
// page's first paint flashes; subsequent renders
|
||||||
|
// use the existing fade-in from the page wrapper.
|
||||||
|
)}
|
||||||
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="display num text-[13px]">{c.id}</div>
|
<div className="display num text-[13px]">{c.id}</div>
|
||||||
<div className="text-[11px] text-muted-foreground num">
|
<div className="text-[11px] text-muted-foreground num">
|
||||||
@@ -225,10 +262,19 @@ export function Claims() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
)}
|
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
total={data?.total ?? 0}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user