feat(frontend): wire Vite app to FastAPI parser with NDJSON streaming + Upload page
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# Cyclone — environment configuration
|
||||
# Copy this file to `.env.local` and fill in values for your environment.
|
||||
|
||||
# Base URL for the Python (FastAPI) backend that powers the Upload page and
|
||||
# the real /api/parse-837 + /api/parse-835 endpoints. Leave empty to keep
|
||||
# the in-memory sample data store and disable real EDI parsing.
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/cyclone.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Cyclone — EDI Claims Management</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://api.fontshare.com/v2/css?f[]=cabinet-grotesk@400,500,600,700&f[]=geist-mono@400,500,600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body class="bg-background text-foreground antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3610
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "cyclone",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Cyclone — self-hosted EDI claims management frontend (CuNtx)",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.453.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"sonner": "^1.5.0",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.7.7",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="8" fill="#0a84ff"/>
|
||||
<path d="M9 11h10a3 3 0 0 1 0 6h-7a3 3 0 0 0 0 6h11" stroke="white" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 273 B |
+47
@@ -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))",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: [
|
||||
"Cabinet Grotesk",
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
"SF Pro Text",
|
||||
"Segoe UI",
|
||||
"system-ui",
|
||||
"Roboto",
|
||||
"sans-serif",
|
||||
],
|
||||
mono: [
|
||||
"Geist Mono",
|
||||
"SF Mono",
|
||||
"ui-monospace",
|
||||
"Menlo",
|
||||
"Monaco",
|
||||
"Consolas",
|
||||
"monospace",
|
||||
],
|
||||
},
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
success: {
|
||||
DEFAULT: "hsl(var(--success))",
|
||||
foreground: "hsl(var(--success-foreground))",
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: "hsl(var(--warning))",
|
||||
foreground: "hsl(var(--warning-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: "hsl(var(--sidebar))",
|
||||
foreground: "hsl(var(--sidebar-foreground))",
|
||||
accent: "hsl(var(--sidebar-accent))",
|
||||
border: "hsl(var(--sidebar-border))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 4px)",
|
||||
sm: "calc(var(--radius) - 6px)",
|
||||
xl: "calc(var(--radius) + 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
"fade-in": {
|
||||
from: { opacity: "0", transform: "translateY(6px)" },
|
||||
to: { opacity: "1", transform: "translateY(0)" },
|
||||
},
|
||||
"fade-in-soft": {
|
||||
from: { opacity: "0" },
|
||||
to: { opacity: "1" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
"fade-in": "fade-in 0.4s cubic-bezier(0.2, 0.8, 0.2, 1) both",
|
||||
"fade-in-soft": "fade-in-soft 0.6s ease-out both",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
host: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user