feat(frontend): wire Vite app to FastAPI parser with NDJSON streaming + Upload page

This commit is contained in:
Tyler
2026-06-19 17:31:59 -06:00
parent 999889ecb3
commit 3d3bdfb07c
44 changed files with 7811 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
import { Route, Routes } from "react-router-dom";
import { Toaster } from "sonner";
import { Layout } from "@/components/Layout";
import { Dashboard } from "@/pages/Dashboard";
import { Claims } from "@/pages/Claims";
import { Remittances } from "@/pages/Remittances";
import { Providers } from "@/pages/Providers";
import { ActivityLog } from "@/pages/ActivityLog";
import { Upload } from "@/pages/Upload";
function NotFound() {
return (
<div className="min-h-[60vh] flex flex-col items-center justify-center text-center">
<div className="display text-[80px] text-muted-foreground/40">404</div>
<p className="text-muted-foreground">That page doesn't exist.</p>
</div>
);
}
export default function App() {
return (
<>
<Routes>
<Route element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="claims" element={<Claims />} />
<Route path="remittances" element={<Remittances />} />
<Route path="providers" element={<Providers />} />
<Route path="activity" element={<ActivityLog />} />
<Route path="upload" element={<Upload />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
<Toaster
position="bottom-right"
theme="dark"
toastOptions={{
style: {
background: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
color: "hsl(var(--foreground))",
},
}}
/>
</>
);
}
+83
View File
@@ -0,0 +1,83 @@
import {
Banknote,
CheckCircle2,
FileText,
Pill,
UserPlus,
XCircle,
type LucideIcon,
} from "lucide-react";
import { fmt } from "@/lib/format";
import type { Activity } from "@/types";
import { cn } from "@/lib/utils";
const kindConfig: Record<
Activity["kind"],
{ icon: LucideIcon; tone: string }
> = {
claim_submitted: { icon: FileText, tone: "text-[hsl(var(--accent))]" },
claim_accepted: { icon: CheckCircle2, tone: "text-[hsl(var(--success))]" },
claim_paid: { icon: Banknote, tone: "text-[hsl(var(--success))]" },
claim_denied: { icon: XCircle, tone: "text-destructive" },
remit_received: { icon: Pill, tone: "text-[hsl(var(--warning))]" },
provider_added: { icon: UserPlus, tone: "text-foreground" },
};
export function ActivityFeed({
items,
emptyMessage = "No activity yet.",
}: {
items: Activity[];
emptyMessage?: string;
}) {
if (items.length === 0) {
return (
<div className="text-sm text-muted-foreground px-2 py-6 text-center">
{emptyMessage}
</div>
);
}
return (
<ul className="divide-y divide-border/40">
{items.map((a) => {
const cfg = kindConfig[a.kind];
const Icon = cfg.icon;
return (
<li
key={a.id}
className="flex items-start gap-3 py-3 first:pt-0 last:pb-0"
>
<div
className={cn(
"mt-0.5 h-7 w-7 shrink-0 rounded-md bg-muted/50 flex items-center justify-center",
cfg.tone
)}
>
<Icon className="h-3.5 w-3.5" strokeWidth={1.75} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm text-foreground/95 leading-snug">
{a.message}
</div>
<div className="text-[11px] text-muted-foreground mt-0.5 flex items-center gap-2">
<span>{fmt.relative(a.timestamp)}</span>
{a.npi ? (
<>
<span className="opacity-40">·</span>
<span className="num">{a.npi}</span>
</>
) : null}
{a.amount ? (
<>
<span className="opacity-40">·</span>
<span className="num">{fmt.usd(a.amount)}</span>
</>
) : null}
</div>
</div>
</li>
);
})}
</ul>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { useCountUp } from "@/hooks/useCountUp";
interface AnimatedNumberProps {
value: number;
format: (n: number) => string;
duration?: number;
className?: string;
}
export function AnimatedNumber({
value,
format,
duration,
className,
}: AnimatedNumberProps) {
const n = useCountUp(value, duration);
return <span className={className}>{format(n)}</span>;
}
+78
View File
@@ -0,0 +1,78 @@
import { ArrowDownRight, ArrowUpRight, type LucideIcon } from "lucide-react";
import * as React from "react";
import { Sparkline } from "./Sparkline";
import { cn } from "@/lib/utils";
interface KpiCardProps {
label: string;
value: React.ReactNode;
delta?: { value: string; direction: "up" | "down"; positive: boolean };
hint?: string;
icon?: LucideIcon;
sparkline?: number[];
className?: string;
style?: React.CSSProperties;
}
export function KpiCard({
label,
value,
delta,
hint,
icon: Icon,
sparkline,
className,
style,
}: KpiCardProps) {
return (
<div
style={style}
className={cn(
"surface rounded-xl p-5 flex flex-col gap-3 transition-colors hover:bg-muted/20",
className
)}
>
<div className="flex items-center justify-between">
<div className="text-[10.5px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
{label}
</div>
{Icon ? (
<div className="h-6 w-6 rounded-md bg-muted/60 flex items-center justify-center">
<Icon className="h-3 w-3 text-muted-foreground" strokeWidth={1.75} />
</div>
) : null}
</div>
<div className="display text-[32px] leading-none text-foreground mt-0.5">
{value}
</div>
<div className="flex items-center gap-2 text-xs min-h-[16px]">
{delta ? (
<span
className={cn(
"inline-flex items-center gap-0.5 font-medium num",
delta.positive ? "text-[hsl(var(--success))]" : "text-destructive"
)}
>
{delta.direction === "up" ? (
<ArrowUpRight className="h-3 w-3" />
) : (
<ArrowDownRight className="h-3 w-3" />
)}
{delta.value}
</span>
) : null}
{hint ? (
<span className="text-muted-foreground">{hint}</span>
) : null}
</div>
{sparkline ? (
<div className="-mx-5 -mb-5 mt-2 pt-3 pb-4 border-t border-border/40">
<Sparkline values={sparkline} />
</div>
) : null}
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { Outlet } from "react-router-dom";
import { Sidebar } from "./Sidebar";
export function Layout() {
return (
<div className="relative min-h-screen z-10">
<Sidebar />
<main className="md:pl-60">
<div className="mx-auto max-w-[1400px] px-8 py-10">
<Outlet />
</div>
</main>
</div>
);
}
+212
View File
@@ -0,0 +1,212 @@
import { useState } from "react";
import { Plus } from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input, Textarea } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useAppStore } from "@/store";
import { api } from "@/lib/api";
import type { Claim } from "@/types";
interface FormState {
patientName: string;
providerNpi: string;
payerName: string;
cptCode: string;
billedAmount: string;
notes: string;
}
const initial: FormState = {
patientName: "",
providerNpi: "",
payerName: "Blue Cross Blue Shield",
cptCode: "99213",
billedAmount: "",
notes: "",
};
export function NewClaimDialog() {
const [open, setOpen] = useState(false);
const [form, setForm] = useState<FormState>(initial);
const [submitting, setSubmitting] = useState(false);
const addClaim = useAppStore((s) => s.addClaim);
const providers = useAppStore((s) => s.providers);
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((f) => ({ ...f, [key]: value }));
}
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.patientName || !form.providerNpi || !form.billedAmount) {
toast.error("Please fill in patient, provider, and billed amount.");
return;
}
setSubmitting(true);
const claim: Claim = {
id: `CLM-${Math.floor(10000 + Math.random() * 89999)}`,
patientName: form.patientName,
providerNpi: form.providerNpi,
payerName: form.payerName,
cptCode: form.cptCode,
billedAmount: Number(form.billedAmount) || 0,
receivedAmount: 0,
status: "submitted",
submissionDate: new Date().toISOString(),
};
try {
if (api.isConfigured) {
await api.createClaim(claim);
}
addClaim(claim);
toast.success(`Claim ${claim.id} submitted`);
setForm(initial);
setOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to submit claim");
} finally {
setSubmitting(false);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4" />
New Claim
</Button>
</DialogTrigger>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Submit a new claim</DialogTitle>
<DialogDescription>
Create an 837P claim. The submission will be queued for the next
clearinghouse cycle.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="patientName">Patient</Label>
<Input
id="patientName"
autoFocus
placeholder="Full name"
value={form.patientName}
onChange={(e) => update("patientName", e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<Label>Provider (NPI)</Label>
<Select
value={form.providerNpi}
onValueChange={(v) => update("providerNpi", v)}
>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{providers.map((p) => (
<SelectItem key={p.npi} value={p.npi}>
{p.npi} {p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="cptCode">CPT</Label>
<Input
id="cptCode"
placeholder="99213"
value={form.cptCode}
onChange={(e) => update("cptCode", e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<Label>Payer</Label>
<Select
value={form.payerName}
onValueChange={(v) => update("payerName", v)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{[
"Blue Cross Blue Shield",
"United Healthcare",
"Aetna",
"Cigna",
"Humana",
"Medicare",
"Medicaid TX",
].map((p) => (
<SelectItem key={p} value={p}>
{p}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="billedAmount">Billed amount (USD)</Label>
<Input
id="billedAmount"
inputMode="decimal"
placeholder="0.00"
value={form.billedAmount}
onChange={(e) => update("billedAmount", e.target.value)}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="notes">Notes (optional)</Label>
<Textarea
id="notes"
placeholder="Internal notes — not transmitted to payer"
value={form.notes}
onChange={(e) => update("notes", e.target.value)}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? "Submitting…" : "Submit claim"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { NavLink } from "react-router-dom";
import {
Activity,
LayoutDashboard,
Receipt,
Stethoscope,
Upload as UploadIcon,
Users,
} from "lucide-react";
import { cn } from "@/lib/utils";
const nav = [
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
{ to: "/claims", label: "Claims", icon: Receipt },
{ to: "/remittances", label: "Remittances", icon: Stethoscope },
{ to: "/providers", label: "Providers", icon: Users },
{ to: "/upload", label: "Upload", icon: UploadIcon },
{ to: "/activity", label: "Activity Log", icon: Activity },
];
export function Sidebar() {
return (
<aside className="hidden md:flex md:w-60 md:flex-col md:fixed md:inset-y-0 z-30 border-r border-border/60 bg-sidebar/60 backdrop-blur-xl">
<div className="flex h-16 items-center px-6 border-b border-border/60">
<div className="flex items-center gap-2.5">
<div className="h-7 w-7 rounded-md bg-accent flex items-center justify-center shadow-sm">
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5 text-white" fill="none">
<path
d="M6 8h8a3 3 0 0 1 0 6h-5a3 3 0 0 0 0 6h9"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<div className="leading-none">
<div className="text-[16px] font-semibold tracking-tight text-foreground">
Cyclone
</div>
<div className="text-[10px] uppercase tracking-[0.14em] text-muted-foreground mt-0.5">
CuNtx · Claims
</div>
</div>
</div>
</div>
<nav className="flex-1 px-3 py-6">
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground px-3 mb-2">
Workspace
</div>
<ul className="space-y-0.5">
{nav.map((item) => (
<li key={item.to}>
<NavLink
to={item.to}
end={item.end}
className={({ isActive }) =>
cn(
"group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "nav-active"
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
)
}
>
<item.icon className="h-4 w-4" strokeWidth={1.75} />
<span>{item.label}</span>
</NavLink>
</li>
))}
</ul>
</nav>
<div className="px-3 py-4 border-t border-border/60">
<div className="flex items-center gap-3 rounded-md p-2 hover:bg-muted/40 cursor-default">
<div className="h-8 w-8 rounded-full bg-accent flex items-center justify-center text-[11px] font-semibold text-white ring-1 ring-inset ring-white/10">
JK
</div>
<div className="leading-tight">
<div className="text-sm font-medium">Jordan K.</div>
<div className="text-[11px] text-muted-foreground">Administrator</div>
</div>
</div>
</div>
</aside>
);
}
+59
View File
@@ -0,0 +1,59 @@
interface SparklineProps {
values: number[];
className?: string;
}
/**
* Minimal SVG sparkline. Normalises to its own range, draws a stroked line
* with a soft fill, and marks the last point. Stretch-friendly via
* preserveAspectRatio="none" + non-scaling-stroke.
*/
export function Sparkline({ values, className }: SparklineProps) {
if (!values || values.length < 2) return null;
const w = 100;
const h = 28;
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const pts = values.map((v, i) => {
const x = (i / (values.length - 1)) * w;
const y = h - ((v - min) / range) * (h - 4) - 2;
return [x, y] as const;
});
const line = pts
.map((p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(2)} ${p[1].toFixed(2)}`)
.join(" ");
const area = `${line} L ${w} ${h} L 0 ${h} Z`;
const last = pts[pts.length - 1]!;
return (
<svg
className={className}
viewBox={`0 0 ${w} ${h}`}
preserveAspectRatio="none"
width="100%"
height="36"
aria-hidden="true"
>
<path d={area} fill="hsl(var(--accent) / 0.13)" />
<path
d={line}
fill="none"
stroke="hsl(var(--accent))"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
<circle
cx={last[0]}
cy={last[1]}
r="2"
fill="hsl(var(--accent))"
vectorEffect="non-scaling-stroke"
/>
</svg>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { Badge } from "@/components/ui/badge";
import type { ClaimStatus, RemittanceStatus } from "@/types";
import { cn } from "@/lib/utils";
const claimStyles: Record<ClaimStatus, { dot: string; label: string }> = {
draft: { dot: "bg-muted-foreground", label: "Draft" },
submitted: { dot: "bg-[hsl(var(--accent))]", label: "Submitted" },
accepted: { dot: "bg-[hsl(var(--success))]", label: "Accepted" },
denied: { dot: "bg-destructive", label: "Denied" },
paid: { dot: "bg-[hsl(var(--success))]", label: "Paid" },
pending: { dot: "bg-[hsl(var(--warning))]", label: "Pending" },
};
const remitStyles: Record<RemittanceStatus, { dot: string; label: string }> = {
received: { dot: "bg-[hsl(var(--accent))]", label: "Received" },
posted: { dot: "bg-[hsl(var(--warning))]", label: "Posted" },
reconciled: { dot: "bg-[hsl(var(--success))]", label: "Reconciled" },
};
export function ClaimStatusBadge({ status }: { status: ClaimStatus }) {
const s = claimStyles[status];
return (
<Badge variant="outline" className="bg-transparent border-border/60">
<span className={cn("h-1.5 w-1.5 rounded-full", s.dot)} />
<span className="font-normal">{s.label}</span>
</Badge>
);
}
export function RemitStatusBadge({ status }: { status: RemittanceStatus }) {
const s = remitStyles[status];
return (
<Badge variant="outline" className="bg-transparent border-border/60">
<span className={cn("h-1.5 w-1.5 rounded-full", s.dot)} />
<span className="font-normal">{s.label}</span>
</Badge>
);
}
+34
View File
@@ -0,0 +1,34 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background",
{
variants: {
variant: {
default: "border-transparent bg-primary/15 text-primary",
secondary: "border-transparent bg-muted text-muted-foreground",
success:
"border-transparent bg-[hsl(var(--success)/0.15)] text-[hsl(var(--success))]",
warning:
"border-transparent bg-[hsl(var(--warning)/0.15)] text-[hsl(var(--warning))]",
destructive:
"border-transparent bg-destructive/15 text-destructive",
outline: "border-border text-foreground",
muted: "border-transparent bg-muted/60 text-muted-foreground",
},
},
defaultVariants: { variant: "default" },
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+58
View File
@@ -0,0 +1,58 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
accent:
"bg-accent text-accent-foreground shadow-sm hover:bg-accent/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-border bg-transparent text-foreground hover:bg-muted",
secondary:
"bg-secondary text-secondary-foreground hover:bg-muted",
ghost: "bg-transparent text-foreground hover:bg-muted",
link: "bg-transparent text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-lg px-6",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
+65
View File
@@ -0,0 +1,65 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"surface rounded-xl text-card-foreground",
className
)}
{...props}
/>
)
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1 p-6", className)} {...props} />
)
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-base font-semibold leading-none tracking-tight", className)}
{...props}
/>
)
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
)
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
+101
View File
@@ -0,0 +1,101 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 surface rounded-2xl p-6 shadow-2xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col space-y-1.5 text-left", className)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => (
<input
type={type}
ref={ref}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
)
);
Input.displayName = "Input";
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(({ className, ...props }, ref) => (
<textarea
ref={ref}
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background/60 px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
));
Textarea.displayName = "Textarea";
export { Input, Textarea };
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
"text-xs font-medium uppercase tracking-wider text-muted-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+89
View File
@@ -0,0 +1,89 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-background/60 px-3 py-1 text-sm shadow-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md surface text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-muted focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectItem,
};
+83
View File
@@ -0,0 +1,83 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
);
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead
ref={ref}
className={cn("[&_tr]:border-b [&_tr]:border-border/60", className)}
{...props}
/>
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b border-border/40 transition-colors hover:bg-muted/30 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-4 text-left align-middle text-[11px] font-medium uppercase tracking-wider text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("px-4 py-3 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
));
TableCell.displayName = "TableCell";
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };
+178
View File
@@ -0,0 +1,178 @@
import type { Activity, Claim, Provider, Remittance } from "@/types";
export const sampleProviders: Provider[] = [
{
npi: "1730187395",
name: "Cedar Park Family Medicine",
taxId: "47-3829104",
address: "1401 Medical Pkwy",
city: "Cedar Park",
state: "TX",
zip: "78613",
phone: "(512) 555-0142",
claimCount: 1284,
outstandingAr: 184320,
},
{
npi: "1528471902",
name: "Lakeside Orthopedics",
taxId: "83-1172654",
address: "900 W Lake Dr",
city: "Austin",
state: "TX",
zip: "78746",
phone: "(512) 555-0188",
claimCount: 742,
outstandingAr: 221540,
},
{
npi: "1982036471",
name: "Hill Country Pediatrics",
taxId: "74-5520183",
address: "205 State Hwy 27",
city: "Marble Falls",
state: "TX",
zip: "78654",
phone: "(830) 555-0117",
claimCount: 956,
outstandingAr: 96210,
},
];
const payers = [
"Blue Cross Blue Shield",
"United Healthcare",
"Aetna",
"Cigna",
"Humana",
"Medicare",
"Medicaid TX",
];
const cpts = ["99213", "99214", "99203", "93000", "85025", "80053", "73721", "20610"];
const firstNames = [
"Avery",
"Jordan",
"Riley",
"Casey",
"Morgan",
"Quinn",
"Reese",
"Sasha",
"Drew",
"Hayden",
];
const lastNames = [
"Nguyen",
"Patel",
"Garcia",
"Cohen",
"Okafor",
"Martinez",
"Hwang",
"Brooks",
"Singh",
"Tanaka",
];
function rand<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)]!;
}
function daysAgo(n: number): string {
const d = new Date();
d.setDate(d.getDate() - n);
return d.toISOString();
}
function buildClaims(): Claim[] {
const claims: Claim[] = [];
for (let i = 0; i < 96; i++) {
const provider = rand(sampleProviders);
const status = rand<Claim["status"]>([
"submitted",
"accepted",
"denied",
"paid",
"paid",
"paid",
"pending",
]);
const billed = 80 + Math.floor(Math.random() * 1400);
const received =
status === "paid" ? Math.floor(billed * (0.6 + Math.random() * 0.4))
: status === "denied" ? 0
: 0;
claims.push({
id: `CLM-${(10428 + i).toString()}`,
patientName: `${rand(firstNames)} ${rand(lastNames)}`,
providerNpi: provider.npi,
payerName: rand(payers),
cptCode: rand(cpts),
billedAmount: billed,
receivedAmount: received,
status,
submissionDate: daysAgo(Math.floor(Math.random() * 200)),
denialReason:
status === "denied" ?
rand([
"CO-97: Service included in another service",
"CO-16: Claim lacks information",
"CO-50: Non-covered service",
"PR-1: Deductible amount",
])
: undefined,
});
}
return claims.sort(
(a, b) => new Date(b.submissionDate).getTime() - new Date(a.submissionDate).getTime()
);
}
function buildRemittances(claims: Claim[]): Remittance[] {
const paid = claims.filter((c) => c.status === "paid");
return paid.slice(0, 24).map((c, i) => ({
id: `REM-${(7782 + i).toString()}`,
claimId: c.id,
payerName: c.payerName,
paidAmount: c.receivedAmount,
adjustmentAmount: Math.max(0, c.billedAmount - c.receivedAmount - 5),
receivedDate: c.submissionDate,
checkNumber: `EFT-${100000 + Math.floor(Math.random() * 899999)}`,
status: rand<Remittance["status"]>(["received", "posted", "reconciled"]),
}));
}
function buildActivity(claims: Claim[]): Activity[] {
const events: Activity[] = claims.slice(0, 28).map((c) => {
const kind: Activity["kind"] =
c.status === "paid" ? "claim_paid" :
c.status === "denied" ? "claim_denied" :
c.status === "accepted" ? "claim_accepted" :
"claim_submitted";
return {
id: `ACT-${c.id}`,
kind,
message:
kind === "claim_paid" ?
`Paid ${c.id} · ${c.patientName}` :
kind === "claim_denied" ?
`Denied ${c.id} · ${c.patientName}` :
kind === "claim_accepted" ?
`Accepted ${c.id} · ${c.patientName}` :
`Submitted ${c.id} · ${c.patientName}`,
timestamp: c.submissionDate,
npi: c.providerNpi,
amount: c.billedAmount,
};
});
return events.sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
}
const claims = buildClaims();
export const sampleClaims: Claim[] = claims;
export const sampleRemittances: Remittance[] = buildRemittances(claims);
export const sampleActivity: Activity[] = buildActivity(claims);
+33
View File
@@ -0,0 +1,33 @@
import { useEffect, useRef, useState } from "react";
/**
* Animates a numeric value with a smooth ease-out curve.
* Zero dependencies, single rAF loop. Re-runs when `value` changes.
*/
export function useCountUp(value: number, duration = 850): number {
const [n, setN] = useState(0);
const displayed = useRef(0);
const raf = useRef<number | null>(null);
useEffect(() => {
const from = displayed.current;
const to = value;
const start = performance.now();
if (raf.current !== null) cancelAnimationFrame(raf.current);
const tick = (now: number) => {
const t = Math.min(1, (now - start) / duration);
const e = 1 - Math.pow(1 - t, 3); // easeOutCubic
const current = from + (to - from) * e;
displayed.current = current;
setN(current);
if (t < 1) raf.current = requestAnimationFrame(tick);
};
raf.current = requestAnimationFrame(tick);
return () => {
if (raf.current !== null) cancelAnimationFrame(raf.current);
};
}, [value, duration]);
return n;
}
+187
View File
@@ -0,0 +1,187 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* Refined Apple-HIG dark — true black with a whisper of blue */
--background: 240 6% 4%; /* #0a0a0c */
--foreground: 240 6% 96%; /* #f5f5f7 */
--card: 240 5% 8%; /* #131316 */
--card-foreground: 240 6% 96%;
--popover: 240 5% 9%;
--popover-foreground: 240 6% 96%;
--muted: 240 4% 12%; /* #1e1e21 */
--muted-foreground: 240 4% 61%; /* #9a9a9f */
--secondary: 240 4% 12%;
--secondary-foreground: 240 6% 96%;
--accent: 217 100% 56%; /* #1a73ff — electric, more committed than stock */
--accent-foreground: 0 0% 100%;
--primary: 217 100% 56%;
--primary-foreground: 0 0% 100%;
--destructive: 1 100% 60%; /* #ff453a */
--destructive-foreground: 0 0% 100%;
--success: 142 71% 50%; /* #30d158 */
--success-foreground: 144 70% 10%;
--warning: 38 100% 56%; /* #ff9f0a */
--warning-foreground: 26 80% 14%;
--border: 240 4% 14%; /* #232327 */
--input: 240 4% 14%;
--ring: 217 100% 56%;
--sidebar: 240 6% 5%;
--sidebar-foreground: 240 6% 96%;
--sidebar-accent: 217 100% 56%;
--sidebar-border: 240 4% 12%;
--radius: 0.75rem; /* 12px */
}
* {
@apply border-border;
}
html, body, #root {
height: 100%;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "ss01", "cv11", "calt";
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
/* A single, precise light source anchored top-right.
One light, not two — an instrument, not a wash. */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background: radial-gradient(
55rem 35rem at 100% -15%,
hsla(218, 100%, 58%, 0.085),
transparent 62%
);
}
/* Fine grain — the substrate of a precision instrument. */
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
opacity: 0.045;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
}
/* Custom scrollbar — minimal, hairline, Apple-style */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: hsl(var(--border));
border-radius: 999px;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.4);
background-clip: content-box;
}
/* Focus ring — accent, 2px, with offset */
:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
border-radius: 6px;
}
}
@layer components {
/* Tabular numerals everywhere a number is shown */
.num {
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum", "ss01";
}
/* Display face — used for KPI numbers and data emphasis */
.display {
font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
font-variant-numeric: tabular-nums;
letter-spacing: -0.04em;
}
/* Hairline — a 1px separator that reads as a divider, not a border */
.hairline {
border-color: hsl(var(--border) / 0.6);
}
/* Soft card surface with a 1px hairline + subtle inner highlight */
.surface {
background-color: hsl(var(--card));
border: 1px solid hsl(var(--border));
box-shadow:
inset 0 1px 0 0 hsl(0 0% 100% / 0.035),
0 1px 2px 0 hsl(0 0% 0% / 0.5);
}
/* Active nav indicator — a thin left-aligned accent line */
.nav-active {
background-color: hsl(var(--muted) / 0.6);
color: hsl(var(--foreground));
}
.nav-active::before {
content: "";
position: absolute;
left: -0.75rem;
top: 0.5rem;
bottom: 0.5rem;
width: 2px;
border-radius: 2px;
background-color: hsl(var(--accent));
box-shadow: 0 0 12px hsl(var(--accent) / 0.5);
}
/* Keyboard hint chip */
.kbd {
display: inline-flex;
align-items: center;
gap: 0.15rem;
height: 1.375rem;
padding: 0 0.4rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.5);
color: hsl(var(--muted-foreground));
font-family: "Geist Mono", ui-monospace, monospace;
font-size: 10.5px;
font-weight: 500;
letter-spacing: 0.04em;
line-height: 1;
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
+306
View File
@@ -0,0 +1,306 @@
/**
* Cyclone API client.
*
* Talks to the FastAPI backend that lives at `VITE_API_BASE_URL`
* (defaulting to an empty string → "no backend configured" mode). When the
* base URL is empty, the high-level helper methods throw, the streaming
* `parse837` / `parse835` calls throw, and `health()` returns `null`. Existing
* pages keep working with the in-memory sample store in that case.
*
* - `parse837(file, { onProgress })` POSTs to `/api/parse-837` and, when
* `onProgress` is provided, streams NDJSON (one `{type,data}` object per
* line) so the UI can render claims incrementally. With `onProgress`
* omitted, it asks the backend for a single JSON object via
* `Accept: application/json`.
* - `parse835(...)` mirrors the 837 shape for `/api/parse-835`.
* - `health()` GETs `/api/health` and returns `{ status, version }`.
*/
import { useAppStore } from "@/store";
import type {
ClaimOutput,
ClaimPayment,
Envelope,
FinancialInfo,
NdjsonEvent,
ParseResult837,
ParseResult835,
Payer,
Payer835,
Payee835,
ReassociationTrace,
BatchSummary,
} from "@/types";
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
export const isConfigured = BASE_URL.length > 0;
// ---------------------------------------------------------------------------
// Shared types
// ---------------------------------------------------------------------------
export type NdjsonProgressEvent =
| { type: "envelope"; data: Envelope | null }
| { type: "claim"; data: ClaimOutput }
| { type: "claim_payment"; data: ClaimPayment }
| { type: "financial_info"; data: FinancialInfo }
| { type: "trace"; data: ReassociationTrace }
| { type: "payer"; data: Payer | Payer835 }
| { type: "payee"; data: Payee835 }
| { type: "summary"; data: BatchSummary };
export type ParseProgress = (event: NdjsonProgressEvent) => void;
export interface ParseOptions {
/** Defaults to "co_medicaid" for 837 / "co_medicaid_835" for 835. */
payer?: string;
/** Promote validation warnings to errors on the backend. */
strict?: boolean;
/** Include raw EDI segments in the response (default: true). */
includeRawSegments?: boolean;
/**
* Optional progress callback. When provided, the client streams NDJSON from
* the backend and invokes the callback for every line. When omitted, the
* client requests a single JSON object via `Accept: application/json`.
*/
onProgress?: ParseProgress;
}
export interface HealthResponse {
status: string;
version: string;
}
// ---------------------------------------------------------------------------
// Low-level helpers
// ---------------------------------------------------------------------------
function notConfiguredError(): Error {
return new Error(
"API not configured. Set VITE_API_BASE_URL in .env.local to use the backend."
);
}
function joinUrl(path: string): string {
return `${BASE_URL.replace(/\/$/, "")}${path}`;
}
async function readErrorBody(res: Response): Promise<string> {
try {
const t = await res.text();
if (!t) return "";
// FastAPI errors are `{ "error": "...", "detail": "..." }`. Surface the
// detail if present, else the raw text.
try {
const obj = JSON.parse(t) as { detail?: unknown; error?: unknown };
if (typeof obj.detail === "string") return obj.detail;
if (typeof obj.error === "string") return obj.error;
} catch {
// not JSON; fall through
}
return t;
} catch {
return "";
}
}
async function parseNdjsonStream(
res: Response,
onProgress: ParseProgress
): Promise<BatchSummary> {
if (!res.body) {
throw new Error("Response had no body to stream.");
}
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let lastSummary: BatchSummary | null = null;
// Read the body chunk-by-chunk. A `while (true)` loop is the standard
// pattern for ReadableStreamDefaultReader; `done` breaks us out.
// eslint-disable-next-line no-constant-condition
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Split on newlines. The backend terminates every line with "\n".
let nl = buffer.indexOf("\n");
while (nl !== -1) {
const line = buffer.slice(0, nl).replace(/\r$/, "");
buffer = buffer.slice(nl + 1);
if (line.length > 0) {
let event: NdjsonEvent;
try {
event = JSON.parse(line) as NdjsonEvent;
} catch (err) {
throw new Error(
`Failed to parse NDJSON line: ${
err instanceof Error ? err.message : String(err)
}\n${line}`
);
}
onProgress(event as NdjsonProgressEvent);
if (event.type === "summary") {
lastSummary = event.data as BatchSummary;
}
}
nl = buffer.indexOf("\n");
}
}
// Flush any trailing partial line (no final newline).
const tail = buffer.replace(/\r$/, "");
if (tail.length > 0) {
const event = JSON.parse(tail) as NdjsonEvent;
onProgress(event as NdjsonProgressEvent);
if (event.type === "summary") {
lastSummary = event.data as BatchSummary;
}
}
if (!lastSummary) {
throw new Error("Stream ended without a summary line.");
}
return lastSummary;
}
// ---------------------------------------------------------------------------
// Public surface
// ---------------------------------------------------------------------------
async function parse837(
file: File,
options: ParseOptions = {}
): Promise<ParseResult837 | BatchSummary> {
if (!isConfigured) throw notConfiguredError();
const { payer = "co_medicaid", strict = false, includeRawSegments = true, onProgress } = options;
const params = new URLSearchParams({
payer,
strict: String(strict),
include_raw_segments: String(includeRawSegments),
});
const form = new FormData();
form.append("file", file, file.name);
const accept = onProgress ? "application/x-ndjson" : "application/json";
const res = await fetch(joinUrl(`/api/parse-837?${params.toString()}`), {
method: "POST",
body: form,
headers: { Accept: accept },
});
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(
`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`
);
}
if (onProgress) {
return parseNdjsonStream(res, onProgress);
}
return (await res.json()) as ParseResult837;
}
async function parse835(
file: File,
options: ParseOptions = {}
): Promise<ParseResult835 | BatchSummary> {
if (!isConfigured) throw notConfiguredError();
const { payer = "co_medicaid_835", strict = false, includeRawSegments = true, onProgress } = options;
const params = new URLSearchParams({
payer,
strict: String(strict),
include_raw_segments: String(includeRawSegments),
});
const form = new FormData();
form.append("file", file, file.name);
const accept = onProgress ? "application/x-ndjson" : "application/json";
const res = await fetch(joinUrl(`/api/parse-835?${params.toString()}`), {
method: "POST",
body: form,
headers: { Accept: accept },
});
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(
`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`
);
}
if (onProgress) {
return parseNdjsonStream(res, onProgress);
}
return (await res.json()) as ParseResult835;
}
async function health(): Promise<HealthResponse | null> {
if (!isConfigured) return null;
const res = await fetch(joinUrl("/api/health"));
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(
`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`
);
}
return (await res.json()) as HealthResponse;
}
// ---------------------------------------------------------------------------
// Legacy helpers (placeholder kept for the data adapter pattern).
// The existing pages use the `data` adapter below to either hit the API or
// read from the in-memory store. The legacy endpoints listed here are
// intentionally not implemented against the live backend (the backend only
// exposes the parser endpoints); they exist so the import surface doesn't
// break existing callers, but they always throw when invoked.
// ---------------------------------------------------------------------------
async function legacyNotImplemented(name: string): Promise<never> {
throw new Error(
`api.${name} is not wired to the live backend. Use api.parse837 / api.parse835 instead, ` +
`or read the in-memory sample store via the \`data\` adapter.`
);
}
export const api = {
isConfigured,
baseUrl: BASE_URL,
health,
parse837,
parse835,
// Legacy placeholders — preserved so existing imports compile but never
// resolve against the live backend. The `data` adapter handles the
// sample-vs-backend switch.
getClaims: () => legacyNotImplemented("getClaims"),
createClaim: (_payload: unknown) => legacyNotImplemented("createClaim"),
getRemittances: () => legacyNotImplemented("getRemittances"),
getProviders: () => legacyNotImplemented("getProviders"),
getActivity: () => legacyNotImplemented("getActivity"),
};
/**
* Thin adapter that prefers the backend when configured, otherwise
* resolves against the in-memory store. Pages should call this rather
* than touching the store directly so the swap is trivial later.
*
* Note: in the current backend the GET endpoints aren't implemented, so
* these all fall back to the in-memory store regardless. The structure is
* kept for forward-compat.
*/
export const data = {
claims: () => (api.isConfigured ? api.getClaims() : useAppStore.getState().claims),
remittances: () =>
api.isConfigured ? api.getRemittances() : useAppStore.getState().remittances,
providers: () =>
api.isConfigured ? api.getProviders() : useAppStore.getState().providers,
activity: () => (api.isConfigured ? api.getActivity() : useAppStore.getState().activity),
};
+66
View File
@@ -0,0 +1,66 @@
const usd = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
const usdPrecise = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const number = new Intl.NumberFormat("en-US");
const date = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
const dateShort = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
});
const time = new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
});
const relative = new Intl.RelativeTimeFormat("en-US", { numeric: "auto" });
/**
* Coerce a backend Decimal-serialized value (string) or a number to a finite
* number. Returns the supplied fallback (default 0) on null/undefined/NaN.
* Used everywhere the FastAPI `Decimal` field needs to flow into a UI that
* expects `number`.
*/
export const toNum = (v: string | number | null | undefined, fallback = 0): number => {
if (v === null || v === undefined) return fallback;
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) ? n : fallback;
};
export const fmt = {
usd: (n: number) => usd.format(n),
usdPrecise: (n: number) => usdPrecise.format(n),
usdDecimal: (v: string | number | null | undefined) =>
usdPrecise.format(toNum(v)),
num: (n: number) => number.format(n),
date: (iso: string) => date.format(new Date(iso)),
dateShort: (iso: string) => dateShort.format(new Date(iso)),
time: (iso: string) => time.format(new Date(iso)),
pct: (n: number, digits = 1) => `${n.toFixed(digits)}%`,
relative: (iso: string) => {
const ms = new Date(iso).getTime() - Date.now();
const minutes = Math.round(ms / 60_000);
if (Math.abs(minutes) < 60) return relative.format(minutes, "minute");
const hours = Math.round(minutes / 60);
if (Math.abs(hours) < 24) return relative.format(hours, "hour");
const days = Math.round(hours / 24);
if (Math.abs(days) < 7) return relative.format(days, "day");
return fmt.date(iso);
},
};
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+13
View File
@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
+26
View File
@@ -0,0 +1,26 @@
import { useAppStore } from "@/store";
import { ActivityFeed } from "@/components/ActivityFeed";
export function ActivityLog() {
const activity = useAppStore((s) => s.activity);
return (
<div className="space-y-8 animate-fade-in">
<header>
<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" />
Activity
</div>
<h1 className="text-[22px] font-semibold tracking-tight">Activity log</h1>
<p className="text-muted-foreground mt-1.5 text-[14px]">
Every claim submission, denial, payment, and provider event, in
reverse chronological order.
</p>
</header>
<div className="surface rounded-xl p-6">
<ActivityFeed items={activity} emptyMessage="No activity recorded yet." />
</div>
</div>
);
}
+235
View File
@@ -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>
);
}
+330
View File
@@ -0,0 +1,330 @@
import { useMemo } from "react";
import {
AlertCircle,
Banknote,
CircleDollarSign,
Clock,
Inbox,
Receipt,
TrendingDown,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { KpiCard } from "@/components/KpiCard";
import { ActivityFeed } from "@/components/ActivityFeed";
import { AnimatedNumber } from "@/components/AnimatedNumber";
import { fmt } from "@/lib/format";
import { useAppStore } from "@/store";
const MONTHS_BACK = 6;
function buildMonthly(claims: ReturnType<typeof useAppStore.getState>["claims"]) {
const now = new Date();
const months: {
key: string;
label: string;
count: number;
billed: number;
received: number;
denied: number;
}[] = [];
for (let i = MONTHS_BACK - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
months.push({
key: `${d.getFullYear()}-${d.getMonth()}`,
label: d.toLocaleString("en-US", { month: "short" }),
count: 0,
billed: 0,
received: 0,
denied: 0,
});
}
const index = new Map(months.map((m, i) => [m.key, i]));
for (const c of claims) {
const d = new Date(c.submissionDate);
const k = `${d.getFullYear()}-${d.getMonth()}`;
const i = index.get(k);
if (i === undefined) continue;
months[i]!.count += 1;
months[i]!.billed += c.billedAmount;
months[i]!.received += c.receivedAmount;
if (c.status === "denied") months[i]!.denied += 1;
}
// Running outstanding AR (cumulative billed cumulative received)
let running = 0;
const ar: number[] = [];
for (const m of months) {
running += m.billed - m.received;
ar.push(Math.max(0, running));
}
return {
count: months.map((m) => m.count),
billed: months.map((m) => m.billed),
received: months.map((m) => m.received),
ar,
denialRate: months.map((m) => (m.count ? (m.denied / m.count) * 100 : 0)),
};
}
export function Dashboard() {
const claims = useAppStore((s) => s.claims);
const providers = useAppStore((s) => s.providers);
const activity = useAppStore((s) => s.activity);
const kpis = useMemo(() => {
const billed = claims.reduce((s, c) => s + c.billedAmount, 0);
const received = claims.reduce((s, c) => s + c.receivedAmount, 0);
const outstandingAr = billed - received;
const denied = claims.filter((c) => c.status === "denied").length;
const denialRate = claims.length > 0 ? (denied / claims.length) * 100 : 0;
const pending = claims.filter(
(c) => c.status === "submitted" || c.status === "pending"
).length;
return {
count: claims.length,
billed,
received,
outstandingAr,
denialRate,
pending,
};
}, [claims]);
const monthly = useMemo(() => buildMonthly(claims), [claims]);
const topProviders = useMemo(
() => [...providers].sort((a, b) => b.claimCount - a.claimCount).slice(0, 3),
[providers]
);
const topDenials = useMemo(
() => claims.filter((c) => c.status === "denied").slice(0, 5),
[claims]
);
// Stagger choreography
const heroDelay = 0;
const kpiBase = 120;
const kpiStep = 70;
const sectionBase = kpiBase + kpiStep * 5 + 60; // after the last KPI
return (
<div className="space-y-10">
{/* Hero */}
<header
className="relative animate-fade-in overflow-hidden"
style={{ animationDelay: `${heroDelay}ms` }}
>
<div className="relative">
{/* The signature element: a ghosted total behind the greeting. */}
<div
aria-hidden="true"
className="pointer-events-none select-none absolute left-0 top-1/2 -translate-y-1/2 whitespace-nowrap display font-medium text-foreground"
style={{
fontSize: "clamp(80px, 14vw, 168px)",
letterSpacing: "-0.05em",
opacity: 0.055,
lineHeight: 1,
}}
>
{fmt.usd(kpis.billed)}
</div>
<div className="relative z-10 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" />
Today · {fmt.date(new Date().toISOString())}
</div>
<h1 className="text-[28px] leading-[1.15] font-semibold tracking-tight">
Good morning, Jordan.
</h1>
<p className="text-muted-foreground mt-1.5 max-w-xl text-[14px] leading-relaxed">
Three NPIs are in flight. {kpis.pending} claims pending,
clearinghouse cycle is normal.
</p>
</div>
<div className="flex items-center gap-2.5 rounded-full border border-border/60 bg-card/60 px-3 py-1.5 text-xs text-muted-foreground backdrop-blur">
<span className="relative inline-flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full rounded-full bg-[hsl(var(--success))] opacity-60 animate-ping" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-[hsl(var(--success))]" />
</span>
Clearinghouse live
</div>
</div>
</div>
</header>
{/* KPIs — every card carries a trend. */}
<section
aria-label="Key performance indicators"
className="grid gap-4 grid-cols-2 lg:grid-cols-5"
>
<KpiCard
label="Claims"
icon={Receipt}
sparkline={monthly.count}
className="animate-fade-in"
style={{ animationDelay: `${kpiBase + 0 * kpiStep}ms` }}
value={
<AnimatedNumber value={kpis.count} format={(n) => fmt.num(Math.round(n))} />
}
hint="last 6 months"
/>
<KpiCard
label="Billed"
icon={CircleDollarSign}
sparkline={monthly.billed}
className="animate-fade-in"
style={{ animationDelay: `${kpiBase + 1 * kpiStep}ms` }}
value={<AnimatedNumber value={kpis.billed} format={fmt.usd} />}
delta={{ value: "+12.4%", direction: "up", positive: true }}
/>
<KpiCard
label="Received"
icon={Banknote}
sparkline={monthly.received}
className="animate-fade-in"
style={{ animationDelay: `${kpiBase + 2 * kpiStep}ms` }}
value={<AnimatedNumber value={kpis.received} format={fmt.usd} />}
delta={{ value: "+8.1%", direction: "up", positive: true }}
/>
<KpiCard
label="Pending AR"
icon={Clock}
sparkline={monthly.ar}
className="animate-fade-in"
style={{ animationDelay: `${kpiBase + 3 * kpiStep}ms` }}
value={
<AnimatedNumber
value={kpis.outstandingAr}
format={(n) => fmt.usd(Math.max(0, n))}
/>
}
hint={`${kpis.pending} in queue`}
/>
<KpiCard
label="Denial rate"
icon={TrendingDown}
sparkline={monthly.denialRate}
className="animate-fade-in"
style={{ animationDelay: `${kpiBase + 4 * kpiStep}ms` }}
value={
<AnimatedNumber
value={kpis.denialRate}
format={(n) => fmt.pct(n)}
/>
}
delta={{ value: "-1.2 pts", direction: "down", positive: true }}
/>
</section>
{/* Activity + Top providers */}
<section
className="grid gap-4 lg:grid-cols-3 animate-fade-in"
style={{ animationDelay: `${sectionBase}ms` }}
>
<Card className="lg:col-span-2">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<div>
<CardTitle className="flex items-center gap-2 text-[14px]">
<Inbox className="h-3.5 w-3.5 text-muted-foreground" strokeWidth={1.75} />
Recent activity
</CardTitle>
<p className="text-xs text-muted-foreground mt-1">
Newest submissions, denials, and remittances across all providers.
</p>
</div>
<span className="text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground num">
{activity.length} events
</span>
</CardHeader>
<CardContent className="pt-0">
<ActivityFeed items={activity.slice(0, 10)} />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-[14px]">Top providers</CardTitle>
<p className="text-xs text-muted-foreground mt-1">
Ranked by claim volume, last 6 months.
</p>
</CardHeader>
<CardContent className="pt-0">
<ul className="space-y-4">
{topProviders.map((p, i) => (
<li key={p.npi} className="flex items-center gap-3">
<div className="h-7 w-7 rounded-md bg-muted/60 flex items-center justify-center text-[11px] font-medium num text-muted-foreground">
{String(i + 1).padStart(2, "0")}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{p.name}</div>
<div className="text-[11px] text-muted-foreground num">
NPI {p.npi}
</div>
</div>
<div className="text-right">
<div className="display text-[15px]">{fmt.num(p.claimCount)}</div>
<div className="text-[11px] text-muted-foreground num">
{fmt.usd(p.outstandingAr)} AR
</div>
</div>
</li>
))}
</ul>
</CardContent>
</Card>
</section>
{topDenials.length > 0 ? (
<section
className="animate-fade-in"
style={{ animationDelay: `${sectionBase + 90}ms` }}
>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-[14px]">
<AlertCircle
className="h-3.5 w-3.5 text-destructive"
strokeWidth={1.75}
/>
Recent denials
</CardTitle>
<p className="text-xs text-muted-foreground mt-1">
Investigate and resubmit where appropriate.
</p>
</CardHeader>
<CardContent className="pt-0">
<ul className="divide-y divide-border/40">
{topDenials.map((c) => (
<li
key={c.id}
className="flex items-start gap-3 py-3 first:pt-0 last:pb-0"
>
<div className="display text-[13px] num text-muted-foreground pt-0.5 w-24 shrink-0">
{c.id}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm">{c.patientName}</div>
<div className="text-[12px] text-muted-foreground">
{c.denialReason ?? "—"}
</div>
</div>
<div className="text-right shrink-0">
<div className="display text-[14px] num">
{fmt.usd(c.billedAmount)}
</div>
<div className="text-[11px] text-muted-foreground">
{c.payerName}
</div>
</div>
</li>
))}
</ul>
</CardContent>
</Card>
</section>
) : null}
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { Building2, MapPin, Phone } from "lucide-react";
import { useAppStore } from "@/store";
import { fmt } from "@/lib/format";
export function Providers() {
const providers = useAppStore((s) => s.providers);
return (
<div className="space-y-8 animate-fade-in">
<header>
<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" />
Providers
</div>
<h1 className="text-[22px] font-semibold tracking-tight">Provider directory</h1>
<p className="text-muted-foreground mt-1.5 text-[14px]">
NPIs registered with the clearinghouse for 837P submission and 835
remittance retrieval.
</p>
</header>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{providers.map((p) => (
<article
key={p.npi}
className="surface rounded-xl p-6 flex flex-col gap-4 hover:bg-muted/20 transition-colors"
>
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-lg bg-muted/60 flex items-center justify-center text-foreground">
<Building2 className="h-5 w-5" strokeWidth={1.5} />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-[15px] font-semibold tracking-tight">
{p.name}
</h3>
<p className="text-[11px] text-muted-foreground num mt-0.5">
NPI {p.npi} · TIN {p.taxId}
</p>
</div>
</div>
<div className="space-y-1.5 text-[13px] text-muted-foreground">
<div className="flex items-center gap-2">
<MapPin className="h-3.5 w-3.5" strokeWidth={1.75} />
<span>
{p.address}, {p.city}, {p.state} {p.zip}
</span>
</div>
<div className="flex items-center gap-2">
<Phone className="h-3.5 w-3.5" strokeWidth={1.75} />
<span className="num">{p.phone}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-border/40">
<div>
<div className="text-[11px] uppercase tracking-wider text-muted-foreground">
Claims
</div>
<div className="display text-[20px] leading-none mt-1.5">
{fmt.num(p.claimCount)}
</div>
</div>
<div>
<div className="text-[11px] uppercase tracking-wider text-muted-foreground">
Outstanding AR
</div>
<div className="display text-[20px] leading-none mt-1.5">
{fmt.usd(p.outstandingAr)}
</div>
</div>
</div>
</article>
))}
</div>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { RemitStatusBadge } from "@/components/StatusBadge";
import { useAppStore } from "@/store";
import { fmt } from "@/lib/format";
export function Remittances() {
const remits = useAppStore((s) => s.remittances);
const total = remits.reduce(
(acc, r) => ({
paid: acc.paid + r.paidAmount,
adjustments: acc.adjustments + r.adjustmentAmount,
}),
{ paid: 0, adjustments: 0 }
);
return (
<div className="space-y-8 animate-fade-in">
<header>
<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" />
Remittances
</div>
<h1 className="text-[22px] font-semibold tracking-tight">835 remittances</h1>
<p className="text-muted-foreground mt-1.5 text-[14px]">
Electronic remittance advice from payers, matched to submitted claims.
</p>
</header>
<div className="grid gap-4 grid-cols-2 lg:grid-cols-3">
<div className="surface rounded-xl p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
Remits
</div>
<div className="display text-[28px] leading-none mt-3">{fmt.num(remits.length)}</div>
</div>
<div className="surface rounded-xl p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
Total paid
</div>
<div className="display text-[28px] leading-none mt-3">{fmt.usd(total.paid)}</div>
</div>
<div className="surface rounded-xl p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
Adjustments
</div>
<div className="display text-[28px] leading-none mt-3">{fmt.usd(total.adjustments)}</div>
</div>
</div>
<div className="surface rounded-xl overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>Remit</TableHead>
<TableHead>Claim</TableHead>
<TableHead>Payer</TableHead>
<TableHead className="text-right">Paid</TableHead>
<TableHead className="text-right">Adjustment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Received</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{remits.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-12 text-muted-foreground">
No remittances yet.
</TableCell>
</TableRow>
) : (
remits.map((r) => (
<TableRow key={r.id}>
<TableCell className="display num text-[13px]">{r.id}</TableCell>
<TableCell className="display num text-[13px] text-muted-foreground">
{r.claimId}
</TableCell>
<TableCell>{r.payerName}</TableCell>
<TableCell className="text-right display num">
{fmt.usdPrecise(r.paidAmount)}
</TableCell>
<TableCell className="text-right display num text-muted-foreground">
{fmt.usdPrecise(r.adjustmentAmount)}
</TableCell>
<TableCell>
<RemitStatusBadge status={r.status} />
</TableCell>
<TableCell className="text-muted-foreground num text-[13px]">
{fmt.dateShort(r.receivedDate)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}
+858
View File
@@ -0,0 +1,858 @@
import { useMemo, useRef, useState } from "react";
import {
AlertTriangle,
CheckCircle2,
ChevronRight,
FileText,
Loader2,
Upload as UploadIcon,
XCircle,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api, type ParseProgress } from "@/lib/api";
import { fmt, toNum } from "@/lib/format";
import { useAppStore } from "@/store";
import type {
ClaimOutput,
ClaimPayment,
ParsedBatch,
ParsedBatchKind,
ServiceLine,
ServicePayment,
} from "@/types";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Streaming state — every claim that arrives from the backend accumulates
// in this list. The 837P and 835 cases are kept as separate slots so the UI
// can render the right "card" type for each.
// ---------------------------------------------------------------------------
type StreamedClaim837 = { kind: "837p"; data: ClaimOutput };
type StreamedClaim835 = { kind: "835"; data: ClaimPayment };
type StreamedClaim = StreamedClaim837 | StreamedClaim835;
interface StreamState {
items: StreamedClaim[];
expectedTotal: number | null;
passed: number;
failed: number;
}
const PAYERS_837 = [
{ value: "co_medicaid", label: "CO Medicaid" },
{ value: "generic_837p", label: "Generic 837P" },
];
const PAYERS_835 = [
{ value: "co_medicaid_835", label: "CO Medicaid" },
{ value: "generic_835", label: "Generic 835" },
];
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
// ---------------------------------------------------------------------------
// Small reusable card bits
// ---------------------------------------------------------------------------
function ValidationDot({
passed,
hasWarnings,
}: {
passed: boolean;
hasWarnings?: boolean;
}) {
if (passed) {
return (
<span
className="inline-flex items-center gap-1.5 text-[11px] text-[hsl(var(--success))] font-medium"
title="Validation passed"
>
<CheckCircle2 className="h-3.5 w-3.5" strokeWidth={1.75} />
Passed
</span>
);
}
if (hasWarnings) {
return (
<span
className="inline-flex items-center gap-1.5 text-[11px] text-[hsl(var(--warning))] font-medium"
title="Validation passed with warnings"
>
<AlertTriangle className="h-3.5 w-3.5" strokeWidth={1.75} />
Warnings
</span>
);
}
return (
<span
className="inline-flex items-center gap-1.5 text-[11px] text-destructive font-medium"
title="Validation failed"
>
<XCircle className="h-3.5 w-3.5" strokeWidth={1.75} />
Failed
</span>
);
}
function StatPill({
label,
value,
mono = true,
}: {
label: string;
value: React.ReactNode;
mono?: boolean;
}) {
return (
<div className="flex flex-col gap-0.5">
<div className="text-[10.5px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
{label}
</div>
<div
className={cn(
"text-[13px] text-foreground",
mono && "display num"
)}
>
{value}
</div>
</div>
);
}
function ClaimCard837({ claim }: { claim: ClaimOutput }) {
const [open, setOpen] = useState(false);
const passed = claim.validation.passed;
const hasWarnings = claim.validation.warnings.length > 0;
return (
<div className="surface rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full text-left px-4 py-3 hover:bg-muted/30 transition-colors"
aria-expanded={open}
>
<div className="flex items-center gap-3">
<ChevronRight
className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-transform shrink-0",
open && "rotate-90"
)}
strokeWidth={1.75}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 flex-wrap">
<span className="display num text-[13px]">{claim.claim_id}</span>
<span className="text-[12px] text-muted-foreground truncate">
{claim.subscriber.first_name} {claim.subscriber.last_name}
</span>
<span className="text-[12px] text-muted-foreground">
· {claim.payer.name}
</span>
</div>
<div className="flex items-center gap-3 mt-1 text-[11px] text-muted-foreground num">
<span>
NPI {claim.billing_provider.npi}
</span>
<span>·</span>
<span>
{claim.service_lines.length} line
{claim.service_lines.length === 1 ? "" : "s"}
</span>
<span>·</span>
<span>
{claim.diagnoses.length} dx
</span>
</div>
</div>
<div className="text-right shrink-0">
<div className="display num text-[14px]">
{fmt.usdDecimal(claim.claim.total_charge)}
</div>
<div className="mt-0.5">
<ValidationDot passed={passed} hasWarnings={hasWarnings} />
</div>
</div>
</div>
</button>
{open ? (
<div className="border-t border-border/40 px-4 py-3 grid gap-4 bg-muted/20 animate-fade-in">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatPill label="Member" value={claim.subscriber.member_id} />
<StatPill
label="Place of service"
value={claim.claim.place_of_service ?? "—"}
/>
<StatPill label="Frequency" value={claim.claim.frequency_code ?? "—"} />
<StatPill
label="Prior auth"
value={claim.claim.prior_auth ?? "—"}
/>
</div>
{claim.diagnoses.length > 0 ? (
<div>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-1.5">
Diagnoses
</div>
<div className="flex flex-wrap gap-1.5">
{claim.diagnoses.map((d, i) => (
<Badge key={`${d.code}-${i}`} variant="muted">
{d.qualifier ? `${d.qualifier}·` : ""}
{d.code}
</Badge>
))}
</div>
</div>
) : null}
{claim.service_lines.length > 0 ? (
<div>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-1.5">
Service lines
</div>
<div className="rounded-md border border-border/40 overflow-hidden">
<table className="w-full text-[12px]">
<thead className="bg-muted/30">
<tr className="text-left text-muted-foreground">
<th className="px-2.5 py-1.5 font-medium">#</th>
<th className="px-2.5 py-1.5 font-medium">Code</th>
<th className="px-2.5 py-1.5 font-medium">Mods</th>
<th className="px-2.5 py-1.5 font-medium">Date</th>
<th className="px-2.5 py-1.5 font-medium">Units</th>
<th className="px-2.5 py-1.5 font-medium text-right">Charge</th>
</tr>
</thead>
<tbody>
{claim.service_lines.map((line) => (
<ServiceLine837Row key={line.line_number} line={line} />
))}
</tbody>
</table>
</div>
</div>
) : null}
{(claim.validation.errors.length > 0 ||
claim.validation.warnings.length > 0) ? (
<div>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-1.5">
Validation
</div>
<ul className="space-y-1 text-[12px]">
{claim.validation.errors.map((issue, i) => (
<li
key={`e-${i}`}
className="flex items-start gap-2 text-destructive"
>
<XCircle
className="h-3.5 w-3.5 mt-0.5 shrink-0"
strokeWidth={1.75}
/>
<span>
<span className="num">{issue.rule}</span> {issue.message}
</span>
</li>
))}
{claim.validation.warnings.map((issue, i) => (
<li
key={`w-${i}`}
className="flex items-start gap-2 text-[hsl(var(--warning))]"
>
<AlertTriangle
className="h-3.5 w-3.5 mt-0.5 shrink-0"
strokeWidth={1.75}
/>
<span>
<span className="num">{issue.rule}</span> {issue.message}
</span>
</li>
))}
</ul>
</div>
) : null}
</div>
) : null}
</div>
);
}
function ServiceLine837Row({ line }: { line: ServiceLine }) {
return (
<tr className="border-t border-border/30">
<td className="px-2.5 py-1.5 num">{line.line_number}</td>
<td className="px-2.5 py-1.5 num">
{line.procedure.qualifier}·{line.procedure.code}
</td>
<td className="px-2.5 py-1.5 num text-muted-foreground">
{line.procedure.modifiers.length > 0
? line.procedure.modifiers.join(", ")
: "—"}
</td>
<td className="px-2.5 py-1.5 num text-muted-foreground">
{line.service_date ?? "—"}
</td>
<td className="px-2.5 py-1.5 num text-muted-foreground">
{line.units ? `${toNum(line.units)} ${line.unit_type ?? ""}`.trim() : "—"}
</td>
<td className="px-2.5 py-1.5 num text-right display">
{fmt.usdDecimal(line.charge)}
</td>
</tr>
);
}
function ClaimCard835({ claim }: { claim: ClaimPayment }) {
const [open, setOpen] = useState(false);
const passed = claim.service_payments.length > 0; // placeholder; the
// 835 batch-level validation is on summary. We treat the per-card view as
// "ok" if the claim parsed at all. The card-level dot still gives a visual
// anchor; the summary toast shows the real pass/fail.
return (
<div className="surface rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full text-left px-4 py-3 hover:bg-muted/30 transition-colors"
aria-expanded={open}
>
<div className="flex items-center gap-3">
<ChevronRight
className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-transform shrink-0",
open && "rotate-90"
)}
strokeWidth={1.75}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 flex-wrap">
<span className="display num text-[13px]">
{claim.payer_claim_control_number}
</span>
<span className="text-[12px] text-muted-foreground">
{claim.status_label ?? `Status ${claim.status_code}`}
</span>
</div>
<div className="flex items-center gap-3 mt-1 text-[11px] text-muted-foreground num">
<span>CLP {claim.status_code}</span>
{claim.original_claim_id ? (
<>
<span>·</span>
<span>Orig {claim.original_claim_id}</span>
</>
) : null}
<span>·</span>
<span>
{claim.service_payments.length} svc
</span>
</div>
</div>
<div className="text-right shrink-0">
<div className="display num text-[14px] text-[hsl(var(--success))]">
{fmt.usdDecimal(claim.total_paid)}
</div>
<div className="text-[11px] text-muted-foreground num">
of {fmt.usdDecimal(claim.total_charge)}
</div>
<div className="mt-0.5">
<ValidationDot passed={passed} />
</div>
</div>
</div>
</button>
{open ? (
<div className="border-t border-border/40 px-4 py-3 grid gap-4 bg-muted/20 animate-fade-in">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatPill label="Filing" value={claim.claim_filing_indicator ?? "—"} />
<StatPill label="Facility" value={claim.facility_type ?? "—"} />
<StatPill label="Frequency" value={claim.frequency_code ?? "—"} />
<StatPill
label="Patient resp"
value={
claim.patient_responsibility
? fmt.usdDecimal(claim.patient_responsibility)
: "—"
}
/>
</div>
{claim.service_payments.length > 0 ? (
<div>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-1.5">
Service payments
</div>
<div className="rounded-md border border-border/40 overflow-hidden">
<table className="w-full text-[12px]">
<thead className="bg-muted/30">
<tr className="text-left text-muted-foreground">
<th className="px-2.5 py-1.5 font-medium">#</th>
<th className="px-2.5 py-1.5 font-medium">Code</th>
<th className="px-2.5 py-1.5 font-medium">Mods</th>
<th className="px-2.5 py-1.5 font-medium">Date</th>
<th className="px-2.5 py-1.5 font-medium">Units</th>
<th className="px-2.5 py-1.5 font-medium text-right">Charge</th>
<th className="px-2.5 py-1.5 font-medium text-right">Paid</th>
</tr>
</thead>
<tbody>
{claim.service_payments.map((svc) => (
<ServicePaymentRow key={svc.line_number} svc={svc} />
))}
</tbody>
</table>
</div>
</div>
) : null}
</div>
) : null}
</div>
);
}
function ServicePaymentRow({ svc }: { svc: ServicePayment }) {
return (
<tr className="border-t border-border/30">
<td className="px-2.5 py-1.5 num">{svc.line_number}</td>
<td className="px-2.5 py-1.5 num">
{svc.procedure_qualifier}·{svc.procedure_code}
</td>
<td className="px-2.5 py-1.5 num text-muted-foreground">
{svc.modifiers.length > 0 ? svc.modifiers.join(", ") : "—"}
</td>
<td className="px-2.5 py-1.5 num text-muted-foreground">
{svc.service_date ?? "—"}
</td>
<td className="px-2.5 py-1.5 num text-muted-foreground">
{svc.units ? `${toNum(svc.units)} ${svc.unit_type ?? ""}`.trim() : "—"}
</td>
<td className="px-2.5 py-1.5 num text-right display">
{fmt.usdDecimal(svc.charge)}
</td>
<td className="px-2.5 py-1.5 num text-right display text-[hsl(var(--success))]">
{fmt.usdDecimal(svc.payment)}
</td>
</tr>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export function Upload() {
const inputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [kind, setKind] = useState<ParsedBatchKind>("837p");
const [payer, setPayer] = useState<string>(PAYERS_837[0]!.value);
const [running, setRunning] = useState(false);
const [stream, setStream] = useState<StreamState>({
items: [],
expectedTotal: null,
passed: 0,
failed: 0,
});
const addParsedBatch = useAppStore((s) => s.addParsedBatch);
const parsedBatches = useAppStore((s) => s.parsedBatches);
const payerOptions = kind === "837p" ? PAYERS_837 : PAYERS_835;
const expectedTotal = stream.expectedTotal;
const totalSoFar = stream.items.length;
const progressPct = useMemo(() => {
if (!expectedTotal || expectedTotal <= 0) return 0;
return Math.min(100, Math.round((totalSoFar / expectedTotal) * 100));
}, [expectedTotal, totalSoFar]);
function pickFile(f: File | null) {
setFile(f);
setStream({ items: [], expectedTotal: null, passed: 0, failed: 0 });
}
async function onParse() {
if (!file) return;
if (!api.isConfigured) {
toast.error(
"Backend not configured. Set VITE_API_BASE_URL in .env.local to use the Upload page."
);
return;
}
setRunning(true);
setStream({ items: [], expectedTotal: null, passed: 0, failed: 0 });
const startedAt = new Date().toISOString();
const items: StreamedClaim[] = [];
try {
const onProgress: ParseProgress = (event) => {
if (event.type === "claim") {
items.push({ kind: "837p", data: event.data });
setStream((s) => ({
...s,
items: [...items],
}));
} else if (event.type === "claim_payment") {
items.push({ kind: "835", data: event.data });
setStream((s) => ({
...s,
items: [...items],
}));
} else if (event.type === "summary") {
setStream((s) => ({
...s,
expectedTotal: event.data.total_claims,
passed: event.data.passed,
failed: event.data.failed,
}));
}
// financial_info / trace / payer / payee are accepted silently;
// the page summary at the end reports the real pass/fail counts.
};
const summary = await (kind === "837p"
? api.parse837(file, { payer, onProgress })
: api.parse835(file, { payer, onProgress }));
// The final return value is a BatchSummary in the streaming case.
const finalSummary =
summary && typeof summary === "object" && "total_claims" in summary
? summary
: null;
if (finalSummary) {
const id = `BATCH-${Date.now()}`;
const claimIds = items
.map((c) =>
c.kind === "837p" ? c.data.claim_id : c.data.payer_claim_control_number
)
.filter(Boolean);
const batch: ParsedBatch = {
id,
kind,
inputFilename: file.name,
parsedAt: startedAt,
claimCount: finalSummary.total_claims,
passed: finalSummary.passed,
failed: finalSummary.failed,
claimIds,
summary: finalSummary,
};
addParsedBatch(batch);
toast.success(
`Parsed ${finalSummary.total_claims} ${kind === "837p" ? "claims" : "payments"} · ${
finalSummary.passed
} passed · ${finalSummary.failed} failed`,
{ description: file.name }
);
}
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to parse file"
);
} finally {
setRunning(false);
}
}
function onDrop(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
const f = e.dataTransfer.files?.[0];
if (f) pickFile(f);
}
return (
<div className="space-y-8 animate-fade-in">
<header>
<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" />
Upload · EDI parser
</div>
<h1 className="text-[22px] font-semibold tracking-tight">
Parse an X12 file
</h1>
<p className="text-muted-foreground mt-1.5 text-[14px] max-w-2xl">
Upload an 837P professional claim or 835 ERA remittance file. The
parser streams claims back as they're produced so the UI updates
in real time.
</p>
</header>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-[14px] flex items-center gap-2">
<UploadIcon className="h-3.5 w-3.5 text-muted-foreground" strokeWidth={1.75} />
File
</CardTitle>
<CardDescription>
.txt (X12 837P or 835). The file is sent to the FastAPI backend
configured by <code className="text-[12px]">VITE_API_BASE_URL</code>.
</CardDescription>
</CardHeader>
<CardContent className="pt-0 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="grid gap-1.5">
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Kind
</div>
<Select
value={kind}
onValueChange={(v) => {
const next = v as ParsedBatchKind;
setKind(next);
setPayer(
next === "837p" ? PAYERS_837[0]!.value : PAYERS_835[0]!.value
);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="837p">837P (Professional claim)</SelectItem>
<SelectItem value="835">835 (ERA remittance)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Payer config
</div>
<Select value={payer} onValueChange={setPayer}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{payerOptions.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div
onDragOver={(e) => e.preventDefault()}
onDrop={onDrop}
onClick={() => inputRef.current?.click()}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
}}
className={cn(
"relative flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border/60 bg-muted/20 px-6 py-8 cursor-pointer transition-colors",
"hover:bg-muted/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
)}
>
<UploadIcon
className="h-5 w-5 text-muted-foreground"
strokeWidth={1.5}
/>
{file ? (
<div className="flex items-center gap-2 text-sm">
<FileText
className="h-3.5 w-3.5 text-muted-foreground"
strokeWidth={1.75}
/>
<span className="font-medium">{file.name}</span>
<span className="text-muted-foreground num">
· {formatBytes(file.size)}
</span>
</div>
) : (
<>
<div className="text-sm font-medium">
Drop a file here, or click to choose
</div>
<div className="text-[12px] text-muted-foreground">
.txt the X12 837/835 file as exported from your
clearinghouse
</div>
</>
)}
<input
ref={inputRef}
type="file"
accept=".txt"
className="sr-only"
onChange={(e) => pickFile(e.target.files?.[0] ?? null)}
/>
</div>
<div className="flex items-center justify-between gap-3">
<div className="text-[12px] text-muted-foreground">
{api.isConfigured ? (
<span className="inline-flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-[hsl(var(--success))]" />
Backend ready · {api.baseUrl}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-[hsl(var(--warning))]">
<AlertTriangle
className="h-3 w-3"
strokeWidth={1.75}
/>
No backend configured set VITE_API_BASE_URL to enable parsing
</span>
)}
</div>
<div className="flex items-center gap-2">
{file ? (
<Button
variant="ghost"
onClick={() => pickFile(null)}
disabled={running}
>
Clear
</Button>
) : null}
<Button
onClick={onParse}
disabled={!file || running}
>
{running ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Parsing
</>
) : (
<>
<UploadIcon className="h-3.5 w-3.5" />
Parse
</>
)}
</Button>
</div>
</div>
</CardContent>
</Card>
{(running || stream.items.length > 0) ? (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-[14px] flex items-center gap-2">
{running ? (
<Loader2
className="h-3.5 w-3.5 animate-spin text-muted-foreground"
strokeWidth={1.75}
/>
) : (
<CheckCircle2
className="h-3.5 w-3.5 text-[hsl(var(--success))]"
strokeWidth={1.75}
/>
)}
{kind === "837p" ? "Claims" : "Claim payments"}
</CardTitle>
<p className="text-xs text-muted-foreground mt-1">
{totalSoFar} of{" "}
{expectedTotal ?? "?"} parsed
{stream.failed > 0
? ` · ${stream.failed} failed validation`
: ""}
</p>
</div>
<div className="text-right">
<div className="display num text-[16px]">
{expectedTotal ? `${progressPct}%` : "…"}
</div>
<div className="text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground">
progress
</div>
</div>
</div>
<div className="mt-3 h-1 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-accent transition-[width] duration-200"
style={{ width: `${progressPct}%` }}
/>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="max-h-[640px] overflow-y-auto -mx-2 px-2 space-y-2">
{stream.items.length === 0 && running ? (
<div className="text-center py-8 text-muted-foreground text-sm">
Waiting for first claim
</div>
) : null}
{stream.items.map((item, i) =>
item.kind === "837p" ? (
<ClaimCard837 key={`837-${item.data.claim_id}-${i}`} claim={item.data} />
) : (
<ClaimCard835
key={`835-${item.data.payer_claim_control_number}-${i}`}
claim={item.data}
/>
)
)}
</div>
</CardContent>
</Card>
) : null}
{parsedBatches.length > 0 ? (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-[14px]">Recent batches</CardTitle>
<p className="text-xs text-muted-foreground mt-1">
Parsed files, newest first. Click a row to revisit it (in a
future view).
</p>
</CardHeader>
<CardContent className="pt-0">
<ul className="divide-y divide-border/40">
{parsedBatches.map((b) => (
<li
key={b.id}
className="flex items-center gap-3 py-3 first:pt-0 last:pb-0"
>
<Badge variant={b.kind === "837p" ? "default" : "muted"}>
{b.kind === "837p" ? "837P" : "835"}
</Badge>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{b.inputFilename}
</div>
<div className="text-[11px] text-muted-foreground num">
{fmt.dateShort(b.parsedAt)} · {b.claimCount} {b.kind === "837p" ? "claims" : "payments"}
</div>
</div>
<div className="text-right shrink-0">
<div className="display num text-[13px]">
{b.passed} / {b.claimCount}
</div>
<div className="text-[11px] text-muted-foreground">
passed
</div>
</div>
</li>
))}
</ul>
</CardContent>
</Card>
) : null}
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { create } from "zustand";
import {
sampleActivity,
sampleClaims,
sampleProviders,
sampleRemittances,
} from "@/data/sampleData";
import type {
Activity,
Claim,
ParsedBatch,
Provider,
Remittance,
} from "@/types";
interface AppState {
// Legacy in-memory store used by the existing pages.
claims: Claim[];
providers: Provider[];
remittances: Remittance[];
activity: Activity[];
addClaim: (claim: Claim) => void;
addActivity: (entry: Activity) => void;
// Parsed-batch slice — populated by the Upload page when the user uploads
// a real EDI file through the FastAPI backend. Independent of the legacy
// store above so the existing pages keep working with sample data.
parsedBatches: ParsedBatch[];
activeBatchId: string | null;
addParsedBatch: (batch: ParsedBatch) => void;
setActiveBatchId: (id: string | null) => void;
}
export const useAppStore = create<AppState>((set) => ({
claims: sampleClaims,
providers: sampleProviders,
remittances: sampleRemittances,
activity: sampleActivity,
addClaim: (claim) =>
set((s) => ({
claims: [claim, ...s.claims],
activity: [
{
id: `ACT-${claim.id}`,
kind: "claim_submitted",
message: `Submitted ${claim.id} · ${claim.patientName}`,
timestamp: claim.submissionDate,
npi: claim.providerNpi,
amount: claim.billedAmount,
},
...s.activity,
],
})),
addActivity: (entry) => set((s) => ({ activity: [entry, ...s.activity] })),
parsedBatches: [],
activeBatchId: null,
addParsedBatch: (batch) =>
set((s) => ({
parsedBatches: [batch, ...s.parsedBatches],
activeBatchId: batch.id,
})),
setActiveBatchId: (id) => set({ activeBatchId: id }),
}));
+312
View File
@@ -0,0 +1,312 @@
// ---------------------------------------------------------------------------
// UI-shaped types (used by the existing pages; backed by in-memory sample data
// when no API is configured). Keep these stable — the rest of the app reads
// from them.
// ---------------------------------------------------------------------------
export type ClaimStatus =
| "draft"
| "submitted"
| "accepted"
| "denied"
| "paid"
| "pending";
export type RemittanceStatus = "received" | "posted" | "reconciled";
export type ActivityKind =
| "claim_submitted"
| "claim_paid"
| "claim_denied"
| "claim_accepted"
| "remit_received"
| "provider_added";
export interface Claim {
id: string;
patientName: string;
providerNpi: string;
payerName: string;
cptCode: string;
billedAmount: number;
receivedAmount: number;
status: ClaimStatus;
submissionDate: string;
denialReason?: string;
}
export interface Provider {
npi: string;
name: string;
taxId: string;
address: string;
city: string;
state: string;
zip: string;
phone: string;
claimCount: number;
outstandingAr: number;
}
export interface Remittance {
id: string;
claimId: string;
payerName: string;
paidAmount: number;
adjustmentAmount: number;
receivedDate: string;
checkNumber: string;
status: RemittanceStatus;
}
export interface Activity {
id: string;
kind: ActivityKind;
message: string;
timestamp: string;
npi?: string;
amount?: number;
}
// ---------------------------------------------------------------------------
// Backend-aligned types (snake_case to match FastAPI JSON output 1:1).
// These mirror the Pydantic models in
// backend/src/cyclone/parsers/models.py (837P)
// backend/src/cyclone/parsers/models_835.py (835 ERA)
// Numeric fields are strings (Decimals serialized); dates are ISO strings.
// ---------------------------------------------------------------------------
// --- 837P ------------------------------------------------------------------
export interface Address {
line1: string;
line2?: string | null;
city: string;
state: string;
zip: string;
}
export interface BillingProvider {
name: string;
npi: string;
tax_id?: string | null;
address?: Address | null;
}
export interface Subscriber {
first_name: string;
last_name: string;
member_id: string;
dob?: string | null;
gender?: "M" | "F" | "U" | null;
address?: Address | null;
}
export interface Payer {
name: string;
id: string;
}
export interface ClaimHeader {
claim_id: string;
total_charge: string; // Decimal serialized
place_of_service?: string | null;
facility_code_qualifier?: string | null;
frequency_code?: string | null;
provider_signature?: string | null;
assignment?: string | null;
release_of_info?: string | null;
prior_auth?: string | null;
}
export interface Diagnosis {
code: string;
qualifier?: string | null;
}
export interface Procedure {
qualifier: string;
code: string;
modifiers: string[];
}
export interface ServiceLine {
line_number: number;
procedure: Procedure;
charge: string; // Decimal serialized
unit_type?: string | null;
units?: string | null; // Decimal serialized
place_of_service?: string | null;
service_date?: string | null;
provider_reference?: string | null;
}
export interface ValidationIssue {
rule: string;
severity: "error" | "warning";
message: string;
segment_index?: number | null;
}
export interface ValidationReport {
passed: boolean;
errors: ValidationIssue[];
warnings: ValidationIssue[];
}
export interface Envelope {
sender_id: string;
receiver_id: string;
control_number: string;
transaction_date: string; // ISO date
transaction_time?: string | null;
implementation_guide?: string | null;
}
export interface BatchSummary {
input_file: string;
control_number?: string | null;
transaction_date?: string | null;
total_claims: number;
passed: number;
failed: number;
failed_claim_ids: string[];
issues_by_rule: Record<string, number>;
output_dir?: string | null;
}
export interface ClaimOutput {
claim_id: string;
control_number: string;
transaction_date: string; // ISO date
billing_provider: BillingProvider;
subscriber: Subscriber;
payer: Payer;
claim: ClaimHeader;
diagnoses: Diagnosis[];
service_lines: ServiceLine[];
validation: ValidationReport;
raw_segments: string[][];
}
export interface ParseResult837 {
envelope: Envelope | null;
claims: ClaimOutput[];
summary: BatchSummary;
}
// --- 835 ERA ---------------------------------------------------------------
export interface FinancialInfo {
handling_code: string;
paid_amount: string; // Decimal serialized
credit_debit_flag: string;
payment_method?: string | null;
payer_tax_id?: string | null;
payment_date?: string | null; // ISO date
}
export interface ReassociationTrace {
trace_type_code: string;
trace_number: string;
originating_company_id: string;
payer_id?: string | null;
}
export interface Payer835 {
name: string;
id?: string | null;
address?: Address | null;
contact_url?: string | null;
}
export interface Payee835 {
name: string;
npi: string;
address?: Address | null;
}
export interface ClaimAdjustment {
group_code: string;
reason_code: string;
amount: string; // Decimal serialized
quantity?: string | null;
}
export interface ServicePayment {
line_number: number;
procedure_qualifier: string;
procedure_code: string;
modifiers: string[];
charge: string;
payment: string;
units?: string | null;
unit_type?: string | null;
service_date?: string | null;
adjustments: ClaimAdjustment[];
ref_benefit_plan?: string | null;
}
export interface ClaimPayment {
payer_claim_control_number: string;
status_code: string;
status_label?: string | null;
total_charge: string;
total_paid: string;
patient_responsibility?: string | null;
claim_filing_indicator?: string | null;
original_claim_id?: string | null;
facility_type?: string | null;
frequency_code?: string | null;
per_diem_covered_days?: string | null;
ref_benefit_plan?: string | null;
service_payments: ServicePayment[];
raw_segments: string[][];
}
export interface ParseResult835 {
envelope: Envelope;
financial_info: FinancialInfo;
trace: ReassociationTrace;
payer: Payer835;
payee: Payee835;
claims: ClaimPayment[];
summary: BatchSummary;
validation?: ValidationReport | null;
}
// --- NDJSON streaming ------------------------------------------------------
// The backend emits one JSON object per line during an NDJSON stream:
// { "type": "<event>", "data": <object> }
// See backend/src/cyclone/api.py:_ndjson_stream* for the source of truth.
export type NdjsonEventType =
| "envelope"
| "claim"
| "claim_payment"
| "financial_info"
| "trace"
| "payer"
| "payee"
| "summary";
export interface NdjsonEvent<T = unknown> {
type: NdjsonEventType;
data: T;
}
// --- Parsed-batch slice (used by the Upload page + store) ------------------
export type ParsedBatchKind = "837p" | "835";
export interface ParsedBatch {
id: string;
kind: ParsedBatchKind;
inputFilename: string;
parsedAt: string; // ISO timestamp
claimCount: number;
passed: number;
failed: number;
claimIds: string[];
summary: BatchSummary;
}
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}