3bc5740e8b
Layer A of the SP35 defense-in-depth fix. Before SP35 the dropdown
silently defaulted to '837p' and never changed when a file was dropped
on the page — uploading an 835 file routed it to /api/parse-837 which
(prior to SP35 Task 2) silently persisted an empty batch.
The change:
1. New pure helper src/lib/x12-detect.ts:
- detectKindFromText(text) reads the first ~4KB and returns the
DetectedKind ('837p' | '835' | '999' | '277ca' | 'ta1' | 'unknown')
by matching the ST01 segment (or the bare TA1 segment for the
no-ST envelope). Cheap substring scan; never invokes tokenize().
- detectKindFromFile(file) is the File-aware wrapper used by the UI.
- detectedKindToParsedBatchKind maps the DetectedKind to the kind
the Upload dropdown supports. Returns null for 999/277CA/TA1 so
the UI can surface a clean 'this file isn't supported here' hint.
2. Upload.tsx: pickFile is now async and reads the file before storing
it. If the detected kind differs from the dropdown's current value,
it switches the dropdown and toasts a hint. If the detected kind is
999/277CA/TA1 (Upload doesn't ingest those), it shows an error toast.
20 new tests in src/lib/x12-detect.test.ts cover the 6 DetectedKind
paths, the File wrapper, case-insensitivity, garbage input, the
ST*8370 false-positive guard, and the detectedKindToParsedBatchKind
mapping.
1478 lines
56 KiB
TypeScript
1478 lines
56 KiB
TypeScript
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 (
|
|
<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 [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<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);
|
|
|
|
// 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<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>
|
|
|
|
{/* =================================================================
|
|
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.
|
|
================================================================= */}
|
|
<Tabs.Root
|
|
value={tab}
|
|
onValueChange={(v) => setTab(v as "upload" | "history")}
|
|
>
|
|
<Tabs.List aria-label="Upload page sections">
|
|
<Tabs.Trigger value="upload">Upload</Tabs.Trigger>
|
|
<Tabs.Trigger value="history">
|
|
History
|
|
{historyCount > 0 ? (
|
|
<span
|
|
aria-hidden
|
|
className="mono text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground/60 ml-1.5"
|
|
>
|
|
· {historyCount}
|
|
</span>
|
|
) : null}
|
|
</Tabs.Trigger>
|
|
</Tabs.List>
|
|
|
|
<Tabs.Content value="upload">
|
|
{/* =================================================================
|
|
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}
|
|
</Tabs.Content>
|
|
|
|
<Tabs.Content value="history">
|
|
<UploadHistory />
|
|
</Tabs.Content>
|
|
</Tabs.Root>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<BatchSummary>((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 (
|
|
<Card>
|
|
<CardContent className="p-10 lg:p-14 flex flex-col items-center justify-center text-center">
|
|
<Loader2
|
|
className="h-4 w-4 animate-spin text-muted-foreground"
|
|
aria-hidden
|
|
/>
|
|
<div className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/70 mt-3">
|
|
Loading archive…
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
if (batchesQuery.isError) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="p-10 lg:p-14 flex flex-col items-center justify-center text-center">
|
|
<XCircle
|
|
className="h-5 w-5 text-[hsl(var(--destructive))]"
|
|
strokeWidth={1.5}
|
|
aria-hidden
|
|
/>
|
|
<div className="display text-[18px] tracking-tight mt-3">
|
|
Couldn't load the archive.
|
|
</div>
|
|
<div className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/70 mt-2">
|
|
{batchesQuery.error instanceof Error
|
|
? batchesQuery.error.message
|
|
: "Network error"}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
if (liveBatches.length === 0) {
|
|
return (
|
|
<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">
|
|
<HistoryIcon className="h-4 w-4" strokeWidth={1.5} />
|
|
</div>
|
|
<div className="display text-[20px] tracking-tight">
|
|
No batches in the archive yet.
|
|
</div>
|
|
<div className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/70 mt-2">
|
|
Switch to Upload and drop a file to ingest your first batch.
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return <HistoryTable batches={liveBatches} />;
|
|
}
|
|
|
|
function HistoryTable({ batches }: { batches: BatchSummary[] }) {
|
|
const totalClaims = batches.reduce((s, b) => s + b.claimCount, 0);
|
|
const lastAt = batches[0] ? fmt.dateShort(batches[0].parsedAt) : "—";
|
|
|
|
return (
|
|
<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" />
|
|
Batch history
|
|
</div>
|
|
<h2 className="display text-[26px] leading-[1.05] tracking-[-0.02em]">
|
|
Archive{" "}
|
|
<span className="italic text-muted-foreground/85">
|
|
· {batches.length}
|
|
</span>
|
|
</h2>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="mono text-[10px] uppercase tracking-[0.18em] font-semibold text-muted-foreground/70">
|
|
Last ingest
|
|
</div>
|
|
<div className="display mono text-[22px] leading-[1.05] mt-1.5 tracking-tight">
|
|
{lastAt}
|
|
</div>
|
|
<div className="mono text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground/60 mt-1">
|
|
{fmt.num(totalClaims)} claims
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className="rounded-md border overflow-hidden"
|
|
style={{ borderColor: "hsl(30 14% 14% / 0.10)" }}
|
|
>
|
|
<table className="w-full text-[12.5px]">
|
|
<thead style={{ backgroundColor: "hsl(36 22% 90%)" }}>
|
|
<tr
|
|
className="text-left"
|
|
style={{ color: "hsl(var(--surface-ink-2))" }}
|
|
>
|
|
<th className="px-3 py-2 font-medium mono uppercase tracking-[0.14em] text-[10.5px] w-12">
|
|
#
|
|
</th>
|
|
<th className="px-3 py-2 font-medium mono uppercase tracking-[0.14em] text-[10.5px] w-20">
|
|
Kind
|
|
</th>
|
|
<th className="px-3 py-2 font-medium mono uppercase tracking-[0.14em] text-[10.5px]">
|
|
File
|
|
</th>
|
|
<th className="px-3 py-2 font-medium mono uppercase tracking-[0.14em] text-[10.5px] w-20 text-right">
|
|
Claims
|
|
</th>
|
|
<th className="px-3 py-2 font-medium mono uppercase tracking-[0.14em] text-[10.5px] w-28">
|
|
Parsed
|
|
</th>
|
|
<th className="px-3 py-2 font-medium mono uppercase tracking-[0.14em] text-[10.5px] w-44 text-right">
|
|
Action
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{batches.map((b, i) => (
|
|
<HistoryRow
|
|
key={b.id}
|
|
batch={b}
|
|
index={batches.length - i}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function HistoryRow({
|
|
batch,
|
|
index,
|
|
}: {
|
|
batch: BatchSummary;
|
|
index: number;
|
|
}) {
|
|
const [exporting, setExporting] = useState(false);
|
|
const canReexport = batch.kind === "837p" && batch.claimIds.length > 0;
|
|
|
|
async function onReexport() {
|
|
if (!canReexport || exporting) return;
|
|
setExporting(true);
|
|
try {
|
|
const result = await api.exportBatch837(batch.id, batch.claimIds);
|
|
downloadBlob(result.filename, result.blob);
|
|
const warn =
|
|
result.serializeErrors.length > 0
|
|
? ` · ${result.serializeErrors.length} skipped`
|
|
: "";
|
|
toast.success(`Re-exported ${batch.claimIds.length} claims${warn}`, {
|
|
description: result.filename,
|
|
});
|
|
} catch (err) {
|
|
toast.error(
|
|
err instanceof ApiError
|
|
? `Re-export failed (${err.status})`
|
|
: err instanceof Error
|
|
? err.message
|
|
: "Re-export failed",
|
|
);
|
|
} finally {
|
|
setExporting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<tr
|
|
className="border-t align-middle"
|
|
style={{ borderColor: "hsl(30 14% 14% / 0.08)" }}
|
|
>
|
|
<td
|
|
className="px-3 py-2.5 mono text-[11px] text-muted-foreground/60"
|
|
>
|
|
#{String(index).padStart(2, "0")}
|
|
</td>
|
|
<td className="px-3 py-2.5">
|
|
<Badge variant={batch.kind === "837p" ? "default" : "muted"}>
|
|
{batch.kind === "837p" ? "837P" : "835"}
|
|
</Badge>
|
|
</td>
|
|
<td className="px-3 py-2.5">
|
|
<div className="font-medium truncate max-w-[42ch]">
|
|
{batch.inputFilename}
|
|
</div>
|
|
</td>
|
|
<td className="px-3 py-2.5 text-right display mono">
|
|
{fmt.num(batch.claimCount)}
|
|
</td>
|
|
<td
|
|
className="px-3 py-2.5 mono text-[11px]"
|
|
style={{ color: "hsl(var(--surface-ink-3))" }}
|
|
>
|
|
{fmt.dateShort(batch.parsedAt)}
|
|
</td>
|
|
<td className="px-3 py-2.5 text-right">
|
|
{canReexport ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={onReexport}
|
|
disabled={exporting}
|
|
data-testid="history-row-reexport"
|
|
data-batch-id={batch.id}
|
|
>
|
|
{exporting ? (
|
|
<>
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
Exporting…
|
|
</>
|
|
) : (
|
|
<>
|
|
<Download className="h-3.5 w-3.5" />
|
|
Re-export ZIP
|
|
</>
|
|
)}
|
|
</Button>
|
|
) : (
|
|
<span className="mono text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground/50">
|
|
No re-export
|
|
</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
} |