import { useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import {
AlertTriangle,
ArrowRight,
ChevronRight,
CloudUpload,
Download,
FileText,
History as HistoryIcon,
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 { Tabs } from "@/components/ui/tabs";
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, ApiError, type BatchSummary, type ParseProgress } from "@/lib/api";
import { downloadBlob } from "@/lib/download";
import { fmt, toNum } from "@/lib/format";
import {
detectKindFromFile,
detectedKindToParsedBatchKind,
} from "@/lib/x12-detect";
import { useAppStore } from "@/store";
import { useParse } from "@/hooks/useParse";
import { useBatchExport } from "@/hooks/useBatchExport";
import { useBatches } from "@/hooks/useBatches";
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 (
{open ? (
{claim.service_payments.length > 0 ? (
Service payments
#
Code
Mods
Date
Units
Charge
Paid
{claim.service_payments.map((svc) => (
))}
) : 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 ? (
);
}
// ---------------------------------------------------------------------------
// 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 (
{label}
{value}
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export function Upload() {
const inputRef = useRef(null);
const [searchParams, setSearchParams] = useSearchParams();
// Tab URL state — `?tab=history` deep-links the History tab;
// `?tab=upload` (or missing) is the default. Stored in the URL so
// the back button round-trips between the two surfaces.
const tab = searchParams.get("tab") === "history" ? "history" : "upload";
const setTab = (next: "upload" | "history") => {
setSearchParams(
(prev) => {
if (next === "upload") prev.delete("tab");
else prev.set("tab", next);
return prev;
},
{ replace: true },
);
};
const [file, setFile] = useState(null);
const [kind, setKind] = useState("837p");
const [payer, setPayer] = useState(PAYERS_837[0]!.value);
const [stream, setStream] = useState({
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);
// Persisted batch count for the History tab badge. Disabled when
// there's no backend configured (sample-data mode); in that case we
// fall back to the in-memory parsedBatches so the badge still reads.
const batchesQuery = useBatches();
const persistedBatchCount = batchesQuery.data?.length ?? 0;
const historyCount =
persistedBatchCount > 0 ? persistedBatchCount : parsedBatches.length;
// 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".)
async function pickFile(f: File | null) {
setFile(f);
setStream({ items: [], expectedTotal: null, passed: 0, failed: 0 });
if (!f) return;
// SP35: auto-detect the kind from the file's first few KB and switch
// the dropdown before the user hits Parse. This is layer A of the
// defense-in-depth fix (layer B is the server-side envelope guard
// in cyclone.api). Before SP35 the dropdown defaulted to "837p"
// silently — dropping an 835 file on the page routed it to
// /api/parse-837 and produced a bogus empty batch. Detection runs
// asynchronously on a 4KB slice; if it can't determine the kind
// (unknown file, read error) we keep the user's current selection
// and the server guard will surface a clean 400 if it's wrong.
const detected = await detectKindFromFile(f);
const matched = detectedKindToParsedBatchKind(detected);
if (matched && matched !== kind) {
setKind(matched);
setPayer(matched === "837p" ? PAYERS_837[0]!.value : PAYERS_835[0]!.value);
toast.message(
`Detected ${matched === "837p" ? "837P" : "835"} file — switched the dropdown.`,
{ description: f.name },
);
} else if (detected === "999" || detected === "277ca" || detected === "ta1") {
// The Upload page only supports 837P and 835; for the other X12
// kinds (which have their own endpoints), surface a hint instead
// of silently leaving the dropdown on whatever the user picked.
toast.error(
`Detected ${detected.toUpperCase()} file — the Upload page only accepts 837P or 835.`,
{ description: "Use the matching endpoint from the History tab or the API." },
);
}
}
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) {
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 (
{/* =================================================================
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").
================================================================= */}
{heroStats.totalClaims.toLocaleString()}
Upload an X12 file.
>
}
subtitle={
<>
Drop an 837P professional claim or 835 ERA remittance.
The parser streams claims back as they're produced, so
the instrument updates live.
>
}
/>
{/* 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. */}
{/* =================================================================
TABS — the page's content switcher. Two surfaces:
· Upload — drop zone + stream + recent batches (the
"in-flight" instrument)
· History — persisted batch archive with one-click
Re-export ZIP per row
Tab is mirrored to `?tab=` so deep-links round-trip.
================================================================= */}
setTab(v as "upload" | "history")}
>
Upload
History
{historyCount > 0 ? (
· {historyCount}
) : null}
{/* =================================================================
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.
================================================================= */}
{/* Top hairline + machine-tag header bar */}
Ingest
· .txt
· 837P / 835
{api.isConfigured ? "Ready" : "No backend"}
{/* Payer config — Bloomberg-style form field row: small mono
label + control. Two selects side-by-side. */}
{/* 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. */}
Read-only access
Your role (viewer) cannot ingest new files.
}
>
{/* Drop area — dashed border at idle, accent glow on drag */}
{
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 ? (
) : null}
{dragging ? (
) : (
)}
{file ? (
{file.name}
· {formatBytes(file.size)}
) : (
<>
{dragging ? (
<>
Release to parse
>
) : (
<>
Drop a file, or click to choose
>
)}
.txt — X12 837/835 as exported from your clearinghouse
>
)}
pickFile(e.target.files?.[0] ?? null)}
/>
{/* Action bar — backend status on the left, controls on the right */}
{api.isConfigured ? (
Target{" "}
{api.baseUrl}
) : (
Set VITE_API_BASE_URL to enable parsing
)}
{/* 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. */}
{file ? (
) : null}
{/* =================================================================
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) ? (
{/* Stream header — eyebrow + display title + progress stats */}
{/* Progress bar — accent gradient, like the Inbox stream bar. */}
{/* 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 ? (
) : null}
) : 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 ? (
No batches yet.
Drop a file above to start a parse.
) : null}
);
}
// ---------------------------------------------------------------------------
// UploadHistory — persisted batch archive rendered inside the History tab.
//
// Source: `useBatches()` (the backend's `/api/batches` list). Independent of
// the in-memory `parsedBatches` store so it survives a page reload — the
// store is purely session-local. When the backend isn't configured (sample-
// data mode) we fall back to the in-memory list so the tab still renders.
// ---------------------------------------------------------------------------
function UploadHistory() {
const parsedBatches = useAppStore((s) => s.parsedBatches);
const batchesQuery = useBatches();
const liveBatches: BatchSummary[] = useMemo(() => {
if (batchesQuery.data && batchesQuery.data.length > 0) {
return batchesQuery.data;
}
// Sample-data fallback — synthesize BatchSummary rows from the
// in-memory parsedBatches so the History tab is never empty in
// a demo. Newest first.
return [...parsedBatches]
.reverse()
.map((b) => ({
id: b.id,
kind: b.kind,
inputFilename: b.inputFilename,
parsedAt: b.parsedAt,
claimCount: b.claimCount,
claimIds: b.claimIds,
}));
}, [batchesQuery.data, parsedBatches]);
if (batchesQuery.isLoading && batchesQuery.data === undefined) {
return (