Files
cyclone/src/pages/Upload.tsx
T
Nora 35e0f5a422 feat(auth): disable write affordances for viewer role
Re-apply f91d7b3 manually against current Upload.tsx / Inbox.tsx /
Reconciliation.tsx since main's UI text diverged from the original
cherry-pick (Release to 'parse' span, mono uppercase tracking, etc.).
The write affordances are now wrapped in <RoleGate allow={admin,user}>:

  - Upload.tsx: dropzone + Parse/Clear buttons
  - Inbox.tsx: rejected / payer_rejected / candidates BulkBars
  - Reconciliation.tsx: 'Match selected' button

Test fixtures stub useAuth to return an admin user so RoleGate lets
the BulkBars / Match button render synchronously on first paint —
the tests don't need a full AuthProvider + /api/auth/me probe.
2026-06-22 15:54:38 -06:00

1118 lines
43 KiB
TypeScript

import { useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
AlertTriangle,
ArrowRight,
ChevronRight,
CloudUpload,
FileText,
Inbox,
Loader2,
Upload as UploadIcon,
X,
XCircle,
} from "lucide-react";
import { toast } from "sonner";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { PageHeader } from "@/components/PageHeader";
import { ClaimCard837 } from "@/components/ClaimCard837";
import { ExportBar } from "@/components/ExportBar";
import { StatPill, ValidationDot } from "@/components/ClaimCard/shared";
import { api, type ParseProgress } from "@/lib/api";
import { fmt, toNum } from "@/lib/format";
import { useAppStore } from "@/store";
import { useParse } from "@/hooks/useParse";
import { useBatchExport } from "@/hooks/useBatchExport";
import type {
ClaimOutput,
ClaimPayment,
ParsedBatch,
ParsedBatchKind,
ServicePayment,
} from "@/types";
import { cn } from "@/lib/utils";
import { RoleGate } from "@/auth/RoleGate";
// ---------------------------------------------------------------------------
// 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.
//
// NOTE: ClaimCard837 and ClaimCard835 use the --surface (warm paper) family
// on purpose — they read as "ingestion documents" within an otherwise dark
// instrument. The dark page chrome around them is what gives the section
// its place in the rest of the app.
// ---------------------------------------------------------------------------
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`;
}
export function ClaimCard835({ claim }: { claim: ClaimPayment }) {
const [open, setOpen] = useState(false);
const passed = claim.service_payments.length > 0;
// SP21 Phase 5 Task 5.7: same drill gate as ClaimCard837. Only show
// "See claim in detail" once the claim_id is in the persisted
// batches (otherwise ClaimDrawer would 404).
const parsedBatches = useAppStore((s) => s.parsedBatches);
const persistedClaimIds = useMemo(
() => new Set(parsedBatches.flatMap((b) => b.claimIds)),
[parsedBatches],
);
const canDrill = persistedClaimIds.has(claim.payer_claim_control_number);
const navigate = useNavigate();
return (
<div
className="rounded-lg overflow-hidden border"
style={{
backgroundColor: "hsl(36 22% 96%)",
borderColor: "hsl(30 14% 14% / 0.10)",
boxShadow: "inset 0 1px 0 0 hsl(0 0% 100% / 0.5)",
}}
>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full text-left px-4 py-3 hover:bg-[hsl(36_22%_92%)] transition-colors"
aria-expanded={open}
>
<div className="flex items-center gap-3">
<ChevronRight
className={cn(
"h-3.5 w-3.5 transition-transform shrink-0",
open && "rotate-90"
)}
strokeWidth={1.75}
style={{ color: "hsl(var(--surface-ink-3))" }}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 flex-wrap">
<span
className="display mono text-[12.5px]"
style={{ color: "hsl(var(--surface-ink))" }}
>
{claim.payer_claim_control_number}
</span>
<span
className="text-[12px]"
style={{ color: "hsl(var(--surface-ink-2))" }}
>
{claim.status_label ?? `Status ${claim.status_code}`}
</span>
</div>
<div
className="mono text-[10.5px] flex items-center gap-2 mt-1"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
<span>CLP {claim.status_code}</span>
{claim.original_claim_id ? (
<>
<span className="opacity-40">·</span>
<span>Orig {claim.original_claim_id}</span>
</>
) : null}
<span className="opacity-40">·</span>
<span>{claim.service_payments.length} svc</span>
</div>
</div>
<div className="text-right shrink-0">
<div
className="display mono text-[14px] text-[hsl(var(--success))]"
>
{fmt.usdDecimal(claim.total_paid)}
</div>
<div
className="mono text-[10.5px]"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
of {fmt.usdDecimal(claim.total_charge)}
</div>
<div className="mt-0.5">
<ValidationDot passed={passed} />
</div>
</div>
</div>
</button>
{open ? (
<div
className="border-t px-4 py-3 grid gap-4 animate-fade-in"
style={{
borderColor: "hsl(30 14% 14% / 0.08)",
backgroundColor: "hsl(36 22% 92%)",
}}
>
<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="mono text-[10px] uppercase tracking-[0.18em] font-semibold mb-1.5"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
Service payments
</div>
<div
className="rounded-md border overflow-x-auto"
style={{
borderColor: "hsl(30 14% 14% / 0.10)",
backgroundColor: "hsl(36 22% 98%)",
}}
>
<table className="w-full text-[12px]">
<thead style={{ backgroundColor: "hsl(36 22% 90%)" }}>
<tr className="text-left" style={{ color: "hsl(var(--surface-ink-2))" }}>
<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}
{/* SP21 Phase 5 Task 5.7: drill to the persisted remit. The
835 cards have no separate "claim" (they're the payment
side), so the link goes to /remittances?remit=PCN which
opens the RemitDrawer for this payment. Same persisted-
gate: only show when the PCN is in a saved batch. */}
{canDrill ? (
<div className="pt-1">
<button
type="button"
onClick={() =>
navigate(
`/remittances?remit=${encodeURIComponent(claim.payer_claim_control_number)}`,
)
}
data-testid="upload-remit-drill"
aria-label={`See remittance ${claim.payer_claim_control_number} in detail`}
className="inline-flex items-center gap-1.5 text-[12px] mono font-semibold cursor-pointer rounded-sm px-1 -mx-1 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
style={{ color: "hsl(var(--surface-ink))" }}
>
See claim in detail
<ArrowRight className="h-3 w-3" strokeWidth={2} />
</button>
</div>
) : null}
</div>
) : null}
</div>
);
}
function ServicePaymentRow({ svc }: { svc: ServicePayment }) {
return (
<tr
className="border-t"
style={{ borderColor: "hsl(30 14% 14% / 0.08)" }}
>
<td
className="px-2.5 py-1.5 mono"
style={{ color: "hsl(var(--surface-ink))" }}
>
{svc.line_number}
</td>
<td
className="px-2.5 py-1.5 mono"
style={{ color: "hsl(var(--surface-ink))" }}
>
{svc.procedure_qualifier}·{svc.procedure_code}
</td>
<td
className="px-2.5 py-1.5 mono"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{svc.modifiers.length > 0 ? svc.modifiers.join(", ") : "—"}
</td>
<td
className="px-2.5 py-1.5 mono"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{svc.service_date ?? "—"}
</td>
<td
className="px-2.5 py-1.5 mono"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{svc.units ? `${toNum(svc.units)} ${svc.unit_type ?? ""}`.trim() : "—"}
</td>
<td
className="px-2.5 py-1.5 mono text-right display"
style={{ color: "hsl(var(--surface-ink))" }}
>
{fmt.usdDecimal(svc.charge)}
</td>
<td
className="px-2.5 py-1.5 mono text-right display text-[hsl(var(--success))]"
>
{fmt.usdDecimal(svc.payment)}
</td>
</tr>
);
}
// ---------------------------------------------------------------------------
// Hero stat cell — compact KPI-style cell for the page's hero strip.
// Echoes the dashboard's KPI rhythm in a tighter, single-row form.
// ---------------------------------------------------------------------------
function HeroStat({
label,
value,
tone = "default",
}: {
label: string;
value: React.ReactNode;
tone?: "default" | "accent" | "success";
}) {
const toneClass =
tone === "accent"
? "text-accent"
: tone === "success"
? "text-[hsl(var(--success))]"
: "text-foreground";
return (
<div className="flex-1 min-w-0">
<div className="mono text-[10px] uppercase tracking-[0.18em] font-semibold text-muted-foreground/70">
{label}
</div>
<div
className={cn(
"display mono text-[22px] leading-[1.05] mt-1.5 tracking-tight",
toneClass
)}
>
{value}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// 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 [stream, setStream] = useState<StreamState>({
items: [],
expectedTotal: null,
passed: 0,
failed: 0,
});
const [dragging, setDragging] = useState(false);
const addParsedBatch = useAppStore((s) => s.addParsedBatch);
const parsedBatches = useAppStore((s) => s.parsedBatches);
const parseMutation = useParse(kind);
// Batch-export wiring (SP9 — Upload → ZIP flow). The hook owns the
// selection set, the exporting flag, the export handler, and the
// captured server-side batch id. See `src/hooks/useBatchExport.ts`.
const {
selectedClaimIds,
exporting,
total837InStream,
onToggleSelect,
onToggleAll,
onExport,
recordBatchId,
} = useBatchExport(stream.items);
const payerOptions = kind === "837p" ? PAYERS_837 : PAYERS_835;
const expectedTotal = stream.expectedTotal;
const totalSoFar = stream.items.length;
const running = parseMutation.isPending;
const progressPct = useMemo(() => {
if (!expectedTotal || expectedTotal <= 0) return 0;
return Math.min(100, Math.round((totalSoFar / expectedTotal) * 100));
}, [expectedTotal, totalSoFar]);
// Hero stat derivations — anchors the page in the same rhythm as the
// Dashboard's KPI row. Computed from the parsedBatches store.
const heroStats = useMemo(() => {
const totalClaims = parsedBatches.reduce((s, b) => s + b.claimCount, 0);
const totalPassed = parsedBatches.reduce((s, b) => s + b.passed, 0);
const passRate = totalClaims > 0 ? (totalPassed / totalClaims) * 100 : 0;
const last = parsedBatches[0];
return {
batches: parsedBatches.length,
totalClaims,
passRate,
lastAt: last ? fmt.dateShort(last.parsedAt) : "—",
};
}, [parsedBatches]);
// Auto-select newly streamed 837P claims. Keyed on stream.items.length
// so this only fires when the count changes (i.e. a new claim arrived
// or the stream was reset). 835 streams don't carry the
// claim-id-into-export flow (out of scope for SP9), so their items
// are deliberately ignored.
// (Selection + export logic now lives in useBatchExport — see the
// hook call above. Keeping the comment as a breadcrumb for future
// readers who grep "Auto-select".)
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;
}
setStream({ items: [], expectedTotal: null, passed: 0, failed: 0 });
const items: StreamedClaim[] = [];
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,
}));
}
};
try {
const summary = await parseMutation.mutateAsync({
file,
options: { payer, onProgress },
});
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: new Date().toISOString(),
claimCount: finalSummary.total_claims,
passed: finalSummary.passed,
failed: finalSummary.failed,
claimIds,
summary: finalSummary,
};
addParsedBatch(batch);
// Capture the server-side batch id returned by the parser so
// onExport can call /api/batches/{server_id}/export-837. The
// client-side `id` (BATCH-{timestamp}) is the in-memory key;
// the server uses a UUID stored in the DB.
recordBatchId(finalSummary.batch_id ?? null);
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"
);
}
}
function onDrop(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
setDragging(false);
const f = e.dataTransfer.files?.[0];
if (f) pickFile(f);
}
// -----------------------------------------------------------------
// Stagger choreography — hero lands first, drop zone at 140ms,
// stream at 240ms, batches at 320ms. Total < 500ms so it feels
// like the instrument powering up, not a long intro.
// -----------------------------------------------------------------
const heroDelay = 0;
const dropDelay = 140;
const streamDelay = 240;
const batchesDelay = 320;
return (
<div className="space-y-6 lg:space-y-10">
{/* =================================================================
HERO — same PageHeader pattern as Dashboard, with a subtle
ghost editorial watermark behind it (echoes the Dashboard's
ghost USD watermark, but uses claims count instead so the
Upload page's "scale" reads as "things ingested").
================================================================= */}
<section
className="relative animate-fade-in overflow-hidden"
style={{ animationDelay: `${heroDelay}ms` }}
>
<div
aria-hidden="true"
className="pointer-events-none select-none absolute -right-4 top-1/2 -translate-y-1/2 whitespace-nowrap display text-foreground"
style={{
fontSize: "clamp(72px, 12vw, 140px)",
letterSpacing: "-0.04em",
opacity: 0.045,
lineHeight: 1,
}}
>
{heroStats.totalClaims.toLocaleString()}
</div>
<div className="relative z-10 flex items-end justify-between gap-4 sm:gap-6 flex-wrap">
<div className="min-w-0">
<PageHeader
eyebrow="Ingestion · EDI parser · 837P / 835"
title={
<>
Upload an <span className="italic text-muted-foreground/85">X12</span> file.
</>
}
subtitle={
<>
Drop an 837P professional claim or 835 ERA remittance.
The parser streams claims back as they're produced, so
the instrument updates <span className="text-foreground">live</span>.
</>
}
/>
</div>
<div
className={cn(
"inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-[11px] mono uppercase tracking-[0.12em] backdrop-blur",
api.isConfigured
? "border-border/60 bg-card/60 text-muted-foreground"
: "border-[hsl(var(--warning)/0.4)] bg-[hsl(var(--warning)/0.08)] text-[hsl(var(--warning))]"
)}
>
<span
className={cn(
"relative inline-flex h-1.5 w-1.5 rounded-full",
api.isConfigured
? "bg-[hsl(var(--success))]"
: "bg-[hsl(var(--warning))]"
)}
>
{api.isConfigured ? (
<span className="absolute inline-flex h-full w-full rounded-full bg-[hsl(var(--success))] opacity-60 animate-ping" />
) : null}
</span>
{api.isConfigured ? "Backend ready" : "No backend"}
</div>
</div>
{/* Hero stat strip — four compact cells, anchored to the
parsedBatches store. Lives below the header, separated by
a hairline, in the same rhythm as Dashboard's KPI row but
tighter. */}
<div className="relative z-10 mt-7 grid grid-cols-2 md:grid-cols-4 gap-6 pt-5 border-t border-border/40">
<HeroStat
label="Batches ingested"
value={fmt.num(heroStats.batches)}
tone="accent"
/>
<HeroStat
label="Claims ingested"
value={fmt.num(heroStats.totalClaims)}
/>
<HeroStat
label="Pass rate"
value={
heroStats.totalClaims > 0
? `${heroStats.passRate.toFixed(1)}%`
: "—"
}
tone={heroStats.passRate >= 95 ? "success" : "default"}
/>
<HeroStat label="Last ingest" value={heroStats.lastAt} />
</div>
</section>
{/* =================================================================
DROP ZONE — the page's centerpiece. Single large surface-2
card with an inline payer-config header bar, a centered drop
area, and an action bar at the bottom.
================================================================= */}
<section
aria-label="File upload"
className="surface-2 rounded-xl p-6 lg:p-8 animate-fade-in-up relative overflow-hidden"
style={{ animationDelay: `${dropDelay}ms` }}
>
{/* Top hairline + machine-tag header bar */}
<div className="flex items-center justify-between gap-3 mb-6">
<div className="flex items-center gap-2.5">
<span className="mono text-[10.5px] uppercase tracking-[0.18em] font-semibold text-muted-foreground/70">
Ingest
</span>
<span
className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/40"
aria-hidden
>
· .txt
</span>
<span
className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/40 hidden sm:inline"
aria-hidden
>
· 837P / 835
</span>
</div>
<div
className={cn(
"inline-flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.18em] font-semibold",
api.isConfigured
? "text-[hsl(var(--success))]"
: "text-[hsl(var(--warning))]"
)}
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
api.isConfigured
? "bg-[hsl(var(--success))]"
: "bg-[hsl(var(--warning))]"
)}
/>
{api.isConfigured ? "Ready" : "No backend"}
</div>
</div>
{/* Payer config — Bloomberg-style form field row: small mono
label + control. Two selects side-by-side. */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-5">
<div className="grid gap-1.5">
<label
htmlFor="upload-kind"
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold text-muted-foreground/70"
>
Kind
</label>
<Select
value={kind}
onValueChange={(v) => {
const next = v as ParsedBatchKind;
setKind(next);
setPayer(
next === "837p" ? PAYERS_837[0]!.value : PAYERS_835[0]!.value
);
}}
>
<SelectTrigger id="upload-kind">
<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">
<label
htmlFor="upload-payer"
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold text-muted-foreground/70"
>
Payer config
</label>
<Select value={payer} onValueChange={setPayer}>
<SelectTrigger id="upload-payer">
<SelectValue />
</SelectTrigger>
<SelectContent>
{payerOptions.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Write affordances — dropzone + Parse button — are gated by
RoleGate so a viewer-role account can SEE the configuration
controls (kind, payer) above but can't actually ingest a file. */}
<RoleGate
allow={["admin", "user"]}
fallback={
<div className="relative rounded-lg border border-dashed border-border/70 bg-muted/20 px-6 py-12 min-h-[200px] flex flex-col items-center justify-center gap-2 text-center text-muted-foreground">
<UploadIcon className="h-5 w-5" strokeWidth={1.5} aria-hidden />
<div className="text-[13.5px] font-medium text-foreground">
Read-only access
</div>
<div className="mono text-[10.5px] uppercase tracking-[0.18em]">
Your role (viewer) cannot ingest new files.
</div>
</div>
}
>
{/* Drop area — dashed border at idle, accent glow on drag */}
<div
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
onClick={() => inputRef.current?.click()}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
}}
aria-label="Drop a file here, or click to choose"
className={cn(
"relative rounded-lg border border-dashed transition-all duration-200",
"px-6 py-12 min-h-[200px] cursor-pointer overflow-hidden",
dragging
? "border-accent bg-accent/[0.06] shadow-[inset_0_0_0_1px_hsl(var(--accent)/0.35),0_0_0_4px_hsl(var(--accent)/0.08)]"
: "border-border/70 bg-background/40 hover:bg-background/60 hover:border-border"
)}
>
{/* On drag — an accent scan-line sweeps across to reinforce
that the dropzone is hot. Pure CSS via the `animate-scan`
keyframe already in the Tailwind config. */}
{dragging ? (
<div
aria-hidden
className="pointer-events-none absolute inset-0 overflow-hidden rounded-lg"
>
<div
className="absolute inset-y-0 -left-1/3 w-1/3 animate-scan"
style={{
background:
"linear-gradient(90deg, transparent, hsl(var(--accent) / 0.18), transparent)",
}}
/>
</div>
) : null}
<div className="relative flex flex-col items-center justify-center gap-2.5 text-center">
<div
className={cn(
"h-11 w-11 rounded-md ring-1 ring-inset flex items-center justify-center transition-colors",
dragging
? "bg-accent/15 text-accent ring-accent/40"
: "bg-muted/50 text-muted-foreground ring-border/60"
)}
>
{dragging ? (
<CloudUpload className="h-5 w-5" strokeWidth={1.5} />
) : (
<UploadIcon className="h-5 w-5" strokeWidth={1.5} />
)}
</div>
{file ? (
<div className="flex items-center gap-2 text-[13.5px] mt-1">
<FileText
className="h-4 w-4 text-muted-foreground"
strokeWidth={1.75}
/>
<span className="font-medium text-foreground truncate max-w-[40ch]">
{file.name}
</span>
<span className="mono text-[11px] text-muted-foreground">
· {formatBytes(file.size)}
</span>
</div>
) : (
<>
<div className="display text-[20px] tracking-tight text-foreground mt-1">
{dragging ? (
<>
Release to <span className="italic text-accent">parse</span>
</>
) : (
<>
Drop a file, or click to <span className="italic text-muted-foreground/80">choose</span>
</>
)}
</div>
<div className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
.txt X12 837/835 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>
</RoleGate>
{/* Action bar — backend status on the left, controls on the right */}
<div className="mt-5 pt-5 border-t border-border/40 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="mono text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground/70 min-w-0 truncate">
{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))]" />
<span className="text-muted-foreground/60">Target</span>{" "}
<span className="text-foreground/80 normal-case tracking-normal">
{api.baseUrl}
</span>
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-[hsl(var(--warning))]">
<AlertTriangle className="h-3 w-3" strokeWidth={1.75} />
Set VITE_API_BASE_URL to enable parsing
</span>
)}
</div>
{/* Parse button — the second half of the write affordance.
Wrapped independently so the dropzone's `fallback`
message doesn't sit next to a disabled-looking Parse
button when the role is viewer. */}
<RoleGate allow={["admin", "user"]} fallback={null}>
<div className="flex items-center gap-2 flex-wrap">
{file ? (
<Button
variant="ghost"
size="sm"
onClick={() => pickFile(null)}
disabled={running}
>
<X className="h-3.5 w-3.5" />
Clear
</Button>
) : null}
<Button
onClick={onParse}
disabled={!file || running}
size="sm"
>
{running ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Parsing
</>
) : (
<>
<UploadIcon className="h-3.5 w-3.5" />
Parse file
</>
)}
</Button>
</div>
</RoleGate>
</div>
</section>
{/* =================================================================
STREAM — appears while parsing or after a parse. Standard
Card wrapper so the section reads as the rest of the app.
ClaimCard837 / ClaimCard835 keep their warm-paper surface
so each claim reads as a document inside the panel.
================================================================= */}
{(running || stream.items.length > 0) ? (
<section
aria-label="Parse stream"
className="animate-fade-in-up"
style={{ animationDelay: `${streamDelay}ms` }}
>
<Card>
<CardContent className="p-6 lg:p-7 space-y-5">
{/* Stream header — eyebrow + display title + progress stats */}
<div className="flex items-end justify-between gap-6 flex-wrap">
<div className="min-w-0">
<div className="eyebrow flex items-center gap-2 mb-2">
<span className="inline-block h-px w-6 bg-foreground/20" />
Stream
{running ? (
<span className="relative inline-flex h-1.5 w-1.5 ml-1">
<span className="absolute inline-flex h-full w-full rounded-full bg-accent opacity-60 animate-ping" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-accent" />
</span>
) : null}
</div>
<h2 className="display text-[26px] leading-[1.05] tracking-[-0.02em]">
{running ? (
<>
Parsing <span className="italic text-muted-foreground/85">live.</span>
</>
) : (
<>
Stream <span className="italic text-muted-foreground/85">complete.</span>
</>
)}
</h2>
</div>
<div className="flex items-center gap-7 shrink-0">
<div className="text-right">
<div className="mono text-[10px] uppercase tracking-[0.18em] font-semibold text-muted-foreground/70">
Parsed
</div>
<div className="display mono text-[24px] leading-[1.05] mt-1.5 tracking-tight">
{totalSoFar}
<span className="text-[14px] text-muted-foreground/60 ml-1">
/ {expectedTotal ?? "—"}
</span>
</div>
</div>
<div className="text-right">
<div className="mono text-[10px] uppercase tracking-[0.18em] font-semibold text-muted-foreground/70">
Progress
</div>
<div className="display mono text-[24px] leading-[1.05] mt-1.5 tracking-tight">
{expectedTotal ? `${progressPct}%` : "…"}
</div>
</div>
</div>
</div>
{/* Progress bar — accent gradient, like the Inbox stream bar. */}
<div
className="h-1 w-full rounded-full overflow-hidden bg-border/60"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progressPct}
>
<div
className="h-full transition-[width] duration-200 ease-out"
style={{
width: `${progressPct}%`,
background:
"linear-gradient(to right, hsl(var(--accent)), hsl(var(--success)))",
boxShadow: "0 0 12px hsl(var(--accent) / 0.4)",
}}
/>
</div>
{/* Streamed claim list — ClaimCards stay as "documents".
For 837P streams, render the ExportBar above the list
so the user can select all / pick a subset / export. */}
{kind === "837p" && !running && total837InStream > 0 ? (
<ExportBar
total={total837InStream}
selectedCount={selectedClaimIds.size}
exporting={exporting}
onToggleAll={onToggleAll}
onExport={onExport}
/>
) : null}
<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-10 text-[12.5px] text-muted-foreground/70 mono uppercase tracking-[0.14em]">
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}
selected={selectedClaimIds.has(item.data.claim_id)}
onToggleSelect={onToggleSelect}
/>
) : (
<ClaimCard835
key={`835-${item.data.payer_claim_control_number}-${i}`}
claim={item.data}
/>
)
)}
</div>
</CardContent>
</Card>
</section>
) : null}
{/* =================================================================
RECENT BATCHES — ledger-style list with sequential numbering.
Standard Card wrapper, numbered rows, kind badge + counts.
================================================================= */}
{parsedBatches.length > 0 ? (
<section
aria-label="Recent batches"
className="animate-fade-in-up"
style={{ animationDelay: `${batchesDelay}ms` }}
>
<Card>
<CardContent className="p-6 lg:p-7">
<div className="flex items-end justify-between gap-6 flex-wrap mb-5">
<div>
<div className="eyebrow flex items-center gap-2 mb-2">
<span className="inline-block h-px w-6 bg-foreground/20" />
Recent batches
</div>
<h2 className="display text-[26px] leading-[1.05] tracking-[-0.02em]">
Archive{" "}
<span className="italic text-muted-foreground/85">
· {parsedBatches.length}
</span>
</h2>
</div>
<div className="text-right">
<div className="mono text-[10px] uppercase tracking-[0.18em] font-semibold text-muted-foreground/70">
Pass rate
</div>
<div className="display mono text-[22px] leading-[1.05] mt-1.5 tracking-tight">
{heroStats.totalClaims > 0
? `${heroStats.passRate.toFixed(1)}%`
: "—"}
</div>
</div>
</div>
<ul className="divide-y divide-border/40">
{parsedBatches.map((b, i) => {
const passRatio =
b.claimCount > 0
? ((b.passed / b.claimCount) * 100).toFixed(1)
: "—";
const idx = String(parsedBatches.length - i).padStart(2, "0");
return (
<li
key={b.id}
className="flex items-center gap-4 py-3.5 first:pt-0 last:pb-0"
>
<span className="mono text-[11px] text-muted-foreground/60 w-7 shrink-0">
#{idx}
</span>
<Badge variant={b.kind === "837p" ? "default" : "muted"}>
{b.kind === "837p" ? "837P" : "835"}
</Badge>
<div className="flex-1 min-w-0">
<div className="text-[13.5px] font-medium truncate">
{b.inputFilename}
</div>
<div className="mono text-[10.5px] text-muted-foreground/70 mt-0.5">
{fmt.dateShort(b.parsedAt)} · {b.claimCount}{" "}
{b.kind === "837p" ? "claims" : "payments"}
</div>
</div>
<div className="text-right shrink-0">
<div className="display mono text-[13.5px] tracking-tight">
{b.passed} <span className="text-muted-foreground/50">/</span>{" "}
{b.claimCount}
</div>
<div
className={cn(
"mono text-[10.5px] mt-0.5",
b.failed > 0
? "text-[hsl(var(--warning))]"
: "text-muted-foreground/70"
)}
>
{passRatio}% pass
</div>
</div>
</li>
);
})}
</ul>
</CardContent>
</Card>
</section>
) : null}
{/* =================================================================
EMPTY STATE — shown only on a fresh browser with no parsed
batches and nothing currently streaming.
================================================================= */}
{parsedBatches.length === 0 && stream.items.length === 0 && !running ? (
<section
className="animate-fade-in-up"
style={{ animationDelay: `${batchesDelay}ms` }}
>
<Card>
<CardContent className="p-10 lg:p-14 flex flex-col items-center justify-center text-center">
<div className="h-10 w-10 rounded-md bg-muted/50 ring-1 ring-inset ring-border/60 flex items-center justify-center text-muted-foreground mb-3">
<Inbox className="h-4 w-4" strokeWidth={1.5} />
</div>
<div className="display text-[20px] tracking-tight">
No batches yet.
</div>
<div className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/70 mt-2">
Drop a file above to start a parse.
</div>
</CardContent>
</Card>
</section>
) : null}
</div>
);
}