feat(frontend): wire Vite app to FastAPI parser with NDJSON streaming + Upload page
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
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 { ClaimStatusBadge } from "@/components/StatusBadge";
|
||||
import { NewClaimDialog } from "@/components/NewClaimDialog";
|
||||
import { useAppStore } from "@/store";
|
||||
import { fmt } from "@/lib/format";
|
||||
import type { ClaimStatus } from "@/types";
|
||||
|
||||
const ALL = "all" as const;
|
||||
const statuses: (ClaimStatus | typeof ALL)[] = [
|
||||
ALL,
|
||||
"submitted",
|
||||
"accepted",
|
||||
"denied",
|
||||
"paid",
|
||||
"pending",
|
||||
];
|
||||
|
||||
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 [npi, setNpi] = useState<string>(ALL);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const providerMap = useMemo(
|
||||
() => Object.fromEntries(providers.map((p) => [p.npi, p])),
|
||||
[providers]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return claims.filter((c) => {
|
||||
if (status !== ALL && c.status !== status) return false;
|
||||
if (npi !== ALL && c.providerNpi !== npi) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
c.id.toLowerCase().includes(q) ||
|
||||
c.patientName.toLowerCase().includes(q) ||
|
||||
c.payerName.toLowerCase().includes(q) ||
|
||||
c.cptCode.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [claims, query, status, npi]);
|
||||
|
||||
const totals = useMemo(
|
||||
() => ({
|
||||
billed: filtered.reduce((s, c) => s + c.billedAmount, 0),
|
||||
received: filtered.reduce((s, c) => s + c.receivedAmount, 0),
|
||||
}),
|
||||
[filtered]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
<header className="flex items-end justify-between gap-6 flex-wrap">
|
||||
<div>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
||||
<span className="inline-block h-px w-6 bg-border" />
|
||||
Claims
|
||||
</div>
|
||||
<h1 className="text-[22px] font-semibold tracking-tight">All claims</h1>
|
||||
</div>
|
||||
<NewClaimDialog />
|
||||
</header>
|
||||
|
||||
<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-16"
|
||||
/>
|
||||
{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"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(v) => setStatus(v as ClaimStatus | typeof ALL)}
|
||||
>
|
||||
<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]">
|
||||
<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="flex items-center gap-6 mt-4 pt-4 border-t border-border/40 text-xs text-muted-foreground">
|
||||
<span>
|
||||
<span className="num text-foreground font-medium">
|
||||
{fmt.num(filtered.length)}
|
||||
</span>{" "}
|
||||
claims
|
||||
</span>
|
||||
<span>
|
||||
Billed{" "}
|
||||
<span className="num text-foreground font-medium">
|
||||
{fmt.usd(totals.billed)}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
Received{" "}
|
||||
<span className="num text-foreground font-medium">
|
||||
{fmt.usd(totals.received)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface rounded-xl overflow-hidden">
|
||||
<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>
|
||||
{filtered.length === 0 ? (
|
||||
<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];
|
||||
return (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell>
|
||||
<div className="display num text-[13px]">{c.id}</div>
|
||||
<div className="text-[11px] text-muted-foreground num">
|
||||
CPT {c.cptCode}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{c.patientName}</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">{provider?.name ?? "Unknown"}</div>
|
||||
<div className="text-[11px] text-muted-foreground num">
|
||||
{c.providerNpi}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{c.payerName}
|
||||
</TableCell>
|
||||
<TableCell className="text-right display num">
|
||||
{fmt.usd(c.billedAmount)}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user