feat(ui): cohesive frontend polish — design system + per-screen refinement

Distill the UI into a single, recognizable voice: a precision
instrument for one operator on one machine. Bloomberg-coded chrome,
warm-paper detail surfaces, mono-heavy numerics, with one editorial
serif accent for moments of weight.

Design system
- Three-font stack: Geist Sans (UI), Geist Mono (data), Instrument
  Serif (editorial display). No Inter, no system fonts.
- Two surfaces: dark chrome (--background, --accent, --signal) and
  warm paper detail surfaces (--surface, --surface-ink*).
- Inbox has its own Ticker Tape terminal palette (--tt-*).
- Shared component classes: .eyebrow, .mono, .display, .surface,
  .surface-2, .row-hover, .nav-active, .kbd, .editorial, .hairline.
- --m-* token aliases for the legacy drawer components so test-
  asserted class strings keep resolving to the same hue family.

Drawers (Claim + Remit)
- Editorial display face for totals (paid/adjustment amounts).
- Color-coded money tiles: green-tinted paid card, amber-tinted
  adjustment card when non-zero, muted otherwise.
- Tabs get accent-blue underline + CSS-driven active state.
- Validation banners with proper background opacity + ring-around-
  dot success badge.
- StateHistoryTimeline: dashed border, ring around dots, ↳ prefix
  for remit ids.
- DiagnosesList, PartiesGrid, MatchedRemitCard, CasAdjustmentsPanel:
  refined typography, dashed dividers, italic descriptions, mono
  amounts, font-semibold totals, hover row tints.

Inbox Ticker Tape
- Custom RowCheckbox with sr-only input + amber accent.
- Alternating row striping, hover tints, accent rail with inset
  shadow on selection.
- Refined sparkline with glow at high values.
- BulkBar: bottom-floating bar with amber count chip, larger shadow.
- CandidateBreakdown: animated progress bars with amber gradient.
- InboxHeader: Instrument Serif headline, mono day/date stamp,
  pulsing amber status dot.

Dialogs & search
- NewClaimDialog: editorial title, mono NPI/CPT/amount fields.
- SearchBar: refined input row with mono, footer with pulsing
  loading indicator.
- KeyboardCheatsheet: monogram icon chip, hover row states, refined
  eyebrow header.

Primitives
- StatusBadge / ClaimStateBadge: per-state dot indicators.
- SelectItem: data-[highlighted]:outline tokens for keyboard a11y.
- Table primitives: refined header treatment, hover/focus states.

Tests + build
- 354/354 tests passing across 59 files.
- Vite build clean (53.84 kB CSS / 560.86 kB JS).
- Eyebrow assertions updated to match the consolidated .eyebrow
  class (intact visual contract, abstracted class string).
- Badge variant tokens updated to the polished bg-muted/80 /
  /0.14 opacity scale.
This commit is contained in:
Tyler
2026-06-20 22:27:01 -06:00
parent 02841d7e6e
commit fbe9940a3f
70 changed files with 2582 additions and 1663 deletions
+284 -343
View File
@@ -11,13 +11,7 @@ import {
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { PageHeader } from "@/components/PageHeader";
import {
Select,
SelectContent,
@@ -72,10 +66,6 @@ function formatBytes(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
// ---------------------------------------------------------------------------
// Small reusable card bits
// ---------------------------------------------------------------------------
function ValidationDot({
passed,
hasWarnings,
@@ -86,7 +76,7 @@ function ValidationDot({
if (passed) {
return (
<span
className="inline-flex items-center gap-1.5 text-[11px] text-[hsl(var(--success))] font-medium"
className="inline-flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.12em] text-[hsl(var(--success))] font-medium"
title="Validation passed"
>
<CheckCircle2 className="h-3.5 w-3.5" strokeWidth={1.75} />
@@ -97,7 +87,7 @@ function ValidationDot({
if (hasWarnings) {
return (
<span
className="inline-flex items-center gap-1.5 text-[11px] text-[hsl(var(--warning))] font-medium"
className="inline-flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.12em] text-[hsl(var(--warning))] font-medium"
title="Validation passed with warnings"
>
<AlertTriangle className="h-3.5 w-3.5" strokeWidth={1.75} />
@@ -107,7 +97,7 @@ function ValidationDot({
}
return (
<span
className="inline-flex items-center gap-1.5 text-[11px] text-destructive font-medium"
className="inline-flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.12em] text-destructive font-medium"
title="Validation failed"
>
<XCircle className="h-3.5 w-3.5" strokeWidth={1.75} />
@@ -126,14 +116,12 @@ function StatPill({
mono?: boolean;
}) {
return (
<div className="flex flex-col gap-0.5">
<div className="text-[10.5px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
{label}
</div>
<div className="flex flex-col gap-1">
<div className="eyebrow">{label}</div>
<div
className={cn(
"text-[13px] text-foreground",
mono && "display num"
"text-[12.5px] text-foreground",
mono && "display mono"
)}
>
{value}
@@ -147,7 +135,7 @@ function ClaimCard837({ claim }: { claim: ClaimOutput }) {
const passed = claim.validation.passed;
const hasWarnings = claim.validation.warnings.length > 0;
return (
<div className="surface rounded-lg overflow-hidden">
<div className="surface-2 rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setOpen((v) => !v)}
@@ -164,7 +152,7 @@ function ClaimCard837({ claim }: { claim: ClaimOutput }) {
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 flex-wrap">
<span className="display num text-[13px]">{claim.claim_id}</span>
<span className="display mono text-[12.5px]">{claim.claim_id}</span>
<span className="text-[12px] text-muted-foreground truncate">
{claim.subscriber.first_name} {claim.subscriber.last_name}
</span>
@@ -172,23 +160,19 @@ function ClaimCard837({ claim }: { claim: ClaimOutput }) {
· {claim.payer.name}
</span>
</div>
<div className="flex items-center gap-3 mt-1 text-[11px] text-muted-foreground num">
<span>
NPI {claim.billing_provider.npi}
</span>
<span>·</span>
<div className="mono text-[10.5px] text-muted-foreground flex items-center gap-2 mt-1">
<span>NPI {claim.billing_provider.npi}</span>
<span className="opacity-40">·</span>
<span>
{claim.service_lines.length} line
{claim.service_lines.length === 1 ? "" : "s"}
</span>
<span>·</span>
<span>
{claim.diagnoses.length} dx
</span>
<span className="opacity-40">·</span>
<span>{claim.diagnoses.length} dx</span>
</div>
</div>
<div className="text-right shrink-0">
<div className="display num text-[14px]">
<div className="display mono text-[14px]">
{fmt.usdDecimal(claim.claim.total_charge)}
</div>
<div className="mt-0.5">
@@ -198,7 +182,7 @@ function ClaimCard837({ claim }: { claim: ClaimOutput }) {
</div>
</button>
{open ? (
<div className="border-t border-border/40 px-4 py-3 grid gap-4 bg-muted/20 animate-fade-in">
<div className="border-t border-border/30 px-4 py-3 grid gap-4 bg-muted/15 animate-fade-in">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatPill label="Member" value={claim.subscriber.member_id} />
<StatPill
@@ -214,9 +198,7 @@ function ClaimCard837({ claim }: { claim: ClaimOutput }) {
{claim.diagnoses.length > 0 ? (
<div>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-1.5">
Diagnoses
</div>
<div className="eyebrow mb-1.5">Diagnoses</div>
<div className="flex flex-wrap gap-1.5">
{claim.diagnoses.map((d, i) => (
<Badge key={`${d.code}-${i}`} variant="muted">
@@ -230,13 +212,11 @@ function ClaimCard837({ claim }: { claim: ClaimOutput }) {
{claim.service_lines.length > 0 ? (
<div>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-1.5">
Service lines
</div>
<div className="eyebrow mb-1.5">Service lines</div>
<div className="rounded-md border border-border/40 overflow-x-auto">
<table className="w-full text-[12px]">
<thead className="bg-muted/30">
<tr className="text-left text-muted-foreground">
<thead className="bg-muted/20 text-muted-foreground">
<tr className="text-left">
<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>
@@ -258,9 +238,7 @@ function ClaimCard837({ claim }: { claim: ClaimOutput }) {
{(claim.validation.errors.length > 0 ||
claim.validation.warnings.length > 0) ? (
<div>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-1.5">
Validation
</div>
<div className="eyebrow mb-1.5">Validation</div>
<ul className="space-y-1 text-[12px]">
{claim.validation.errors.map((issue, i) => (
<li
@@ -272,7 +250,7 @@ function ClaimCard837({ claim }: { claim: ClaimOutput }) {
strokeWidth={1.75}
/>
<span>
<span className="num">{issue.rule}</span> {issue.message}
<span className="mono">{issue.rule}</span> {issue.message}
</span>
</li>
))}
@@ -286,7 +264,7 @@ function ClaimCard837({ claim }: { claim: ClaimOutput }) {
strokeWidth={1.75}
/>
<span>
<span className="num">{issue.rule}</span> {issue.message}
<span className="mono">{issue.rule}</span> {issue.message}
</span>
</li>
))}
@@ -302,22 +280,22 @@ function ClaimCard837({ claim }: { claim: ClaimOutput }) {
function ServiceLine837Row({ line }: { line: ServiceLine }) {
return (
<tr className="border-t border-border/30">
<td className="px-2.5 py-1.5 num">{line.line_number}</td>
<td className="px-2.5 py-1.5 num">
<td className="px-2.5 py-1.5 mono">{line.line_number}</td>
<td className="px-2.5 py-1.5 mono">
{line.procedure.qualifier}·{line.procedure.code}
</td>
<td className="px-2.5 py-1.5 num text-muted-foreground">
<td className="px-2.5 py-1.5 mono text-muted-foreground">
{line.procedure.modifiers.length > 0
? line.procedure.modifiers.join(", ")
: "—"}
</td>
<td className="px-2.5 py-1.5 num text-muted-foreground">
<td className="px-2.5 py-1.5 mono text-muted-foreground">
{line.service_date ?? "—"}
</td>
<td className="px-2.5 py-1.5 num text-muted-foreground">
<td className="px-2.5 py-1.5 mono text-muted-foreground">
{line.units ? `${toNum(line.units)} ${line.unit_type ?? ""}`.trim() : "—"}
</td>
<td className="px-2.5 py-1.5 num text-right display">
<td className="px-2.5 py-1.5 mono text-right display">
{fmt.usdDecimal(line.charge)}
</td>
</tr>
@@ -326,12 +304,9 @@ function ServiceLine837Row({ line }: { line: ServiceLine }) {
function ClaimCard835({ claim }: { claim: ClaimPayment }) {
const [open, setOpen] = useState(false);
const passed = claim.service_payments.length > 0; // placeholder; the
// 835 batch-level validation is on summary. We treat the per-card view as
// "ok" if the claim parsed at all. The card-level dot still gives a visual
// anchor; the summary toast shows the real pass/fail.
const passed = claim.service_payments.length > 0;
return (
<div className="surface rounded-lg overflow-hidden">
<div className="surface-2 rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setOpen((v) => !v)}
@@ -348,32 +323,30 @@ function ClaimCard835({ claim }: { claim: ClaimPayment }) {
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 flex-wrap">
<span className="display num text-[13px]">
<span className="display mono text-[12.5px]">
{claim.payer_claim_control_number}
</span>
<span className="text-[12px] text-muted-foreground">
{claim.status_label ?? `Status ${claim.status_code}`}
</span>
</div>
<div className="flex items-center gap-3 mt-1 text-[11px] text-muted-foreground num">
<div className="mono text-[10.5px] text-muted-foreground flex items-center gap-2 mt-1">
<span>CLP {claim.status_code}</span>
{claim.original_claim_id ? (
<>
<span>·</span>
<span className="opacity-40">·</span>
<span>Orig {claim.original_claim_id}</span>
</>
) : null}
<span>·</span>
<span>
{claim.service_payments.length} svc
</span>
<span className="opacity-40">·</span>
<span>{claim.service_payments.length} svc</span>
</div>
</div>
<div className="text-right shrink-0">
<div className="display num text-[14px] text-[hsl(var(--success))]">
<div className="display mono text-[14px] text-[hsl(var(--success))]">
{fmt.usdDecimal(claim.total_paid)}
</div>
<div className="text-[11px] text-muted-foreground num">
<div className="mono text-[10.5px] text-muted-foreground">
of {fmt.usdDecimal(claim.total_charge)}
</div>
<div className="mt-0.5">
@@ -383,7 +356,7 @@ function ClaimCard835({ claim }: { claim: ClaimPayment }) {
</div>
</button>
{open ? (
<div className="border-t border-border/40 px-4 py-3 grid gap-4 bg-muted/20 animate-fade-in">
<div className="border-t border-border/30 px-4 py-3 grid gap-4 bg-muted/15 animate-fade-in">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatPill label="Filing" value={claim.claim_filing_indicator ?? "—"} />
<StatPill label="Facility" value={claim.facility_type ?? "—"} />
@@ -400,13 +373,11 @@ function ClaimCard835({ claim }: { claim: ClaimPayment }) {
{claim.service_payments.length > 0 ? (
<div>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-1.5">
Service payments
</div>
<div className="eyebrow mb-1.5">Service payments</div>
<div className="rounded-md border border-border/40 overflow-x-auto">
<table className="w-full text-[12px]">
<thead className="bg-muted/30">
<tr className="text-left text-muted-foreground">
<thead className="bg-muted/20 text-muted-foreground">
<tr className="text-left">
<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>
@@ -434,23 +405,23 @@ function ClaimCard835({ claim }: { claim: ClaimPayment }) {
function ServicePaymentRow({ svc }: { svc: ServicePayment }) {
return (
<tr className="border-t border-border/30">
<td className="px-2.5 py-1.5 num">{svc.line_number}</td>
<td className="px-2.5 py-1.5 num">
<td className="px-2.5 py-1.5 mono">{svc.line_number}</td>
<td className="px-2.5 py-1.5 mono">
{svc.procedure_qualifier}·{svc.procedure_code}
</td>
<td className="px-2.5 py-1.5 num text-muted-foreground">
<td className="px-2.5 py-1.5 mono text-muted-foreground">
{svc.modifiers.length > 0 ? svc.modifiers.join(", ") : "—"}
</td>
<td className="px-2.5 py-1.5 num text-muted-foreground">
<td className="px-2.5 py-1.5 mono text-muted-foreground">
{svc.service_date ?? "—"}
</td>
<td className="px-2.5 py-1.5 num text-muted-foreground">
<td className="px-2.5 py-1.5 mono text-muted-foreground">
{svc.units ? `${toNum(svc.units)} ${svc.unit_type ?? ""}`.trim() : "—"}
</td>
<td className="px-2.5 py-1.5 num text-right display">
<td className="px-2.5 py-1.5 mono text-right display">
{fmt.usdDecimal(svc.charge)}
</td>
<td className="px-2.5 py-1.5 num text-right display text-[hsl(var(--success))]">
<td className="px-2.5 py-1.5 mono text-right display text-[hsl(var(--success))]">
{fmt.usdDecimal(svc.payment)}
</td>
</tr>
@@ -472,11 +443,9 @@ export function Upload() {
passed: 0,
failed: 0,
});
const [dragging, setDragging] = useState(false);
const addParsedBatch = useAppStore((s) => s.addParsedBatch);
const parsedBatches = useAppStore((s) => s.parsedBatches);
// useParse wires the mutation; on success it invalidates every list query
// (batches, claims, remittances, providers, activity) so the other pages
// see the new data without manual refetching.
const parseMutation = useParse(kind);
const payerOptions = kind === "837p" ? PAYERS_837 : PAYERS_835;
@@ -525,20 +494,14 @@ export function Upload() {
failed: event.data.failed,
}));
}
// financial_info / trace / payer / payee are accepted silently;
// the page summary at the end reports the real pass/fail counts.
};
try {
// mutateAsync triggers the mutation; the streaming callbacks above
// populate `stream` as claims arrive, and the resolver returns the
// final BatchSummary from the "summary" NDJSON line.
const summary = await parseMutation.mutateAsync({
file,
options: { payer, onProgress },
});
// The final return value is a BatchSummary in the streaming case.
const finalSummary =
summary && typeof summary === "object" && "total_claims" in summary
? summary
@@ -579,286 +542,264 @@ export function Upload() {
function onDrop(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
setDragging(false);
const f = e.dataTransfer.files?.[0];
if (f) pickFile(f);
}
return (
<div className="space-y-6 md:space-y-8 animate-fade-in">
<header>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
<span className="inline-block h-px w-6 bg-border" />
Upload · EDI parser
</div>
<h1 className="text-[22px] sm:text-[26px] font-semibold tracking-tight">
Parse an X12 file
</h1>
<p className="text-muted-foreground mt-1.5 text-[14px] max-w-2xl">
Upload an 837P professional claim or 835 ERA remittance file. The
parser streams claims back as they're produced so the UI updates
in real time.
</p>
</header>
<div className="space-y-6 lg:space-y-8 animate-fade-in">
<PageHeader
eyebrow="Upload · EDI parser"
title={<>Parse an <span className="display italic text-muted-foreground">X12</span> file</>}
subtitle="Upload an 837P professional claim or 835 ERA remittance file. The parser streams claims back as they're produced so the UI updates in real time."
/>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-[14px] flex items-center gap-2">
<UploadIcon className="h-3.5 w-3.5 text-muted-foreground" strokeWidth={1.75} />
File
</CardTitle>
<CardDescription>
.txt (X12 837P or 835). The file is sent to the FastAPI backend
configured by <code className="text-[12px]">VITE_API_BASE_URL</code>.
</CardDescription>
</CardHeader>
<CardContent className="pt-0 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="grid gap-1.5">
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Kind
</div>
<Select
value={kind}
onValueChange={(v) => {
const next = v as ParsedBatchKind;
setKind(next);
setPayer(
next === "837p" ? PAYERS_837[0]!.value : PAYERS_835[0]!.value
);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="837p">837P (Professional claim)</SelectItem>
<SelectItem value="835">835 (ERA remittance)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Payer config
</div>
<Select value={payer} onValueChange={setPayer}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{payerOptions.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="surface-2 rounded-xl p-5">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div className="grid gap-1.5">
<div className="eyebrow">Kind</div>
<Select
value={kind}
onValueChange={(v) => {
const next = v as ParsedBatchKind;
setKind(next);
setPayer(
next === "837p" ? PAYERS_837[0]!.value : PAYERS_835[0]!.value
);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="837p">837P (Professional claim)</SelectItem>
<SelectItem value="835">835 (ERA remittance)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<div className="eyebrow">Payer config</div>
<Select value={payer} onValueChange={setPayer}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{payerOptions.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div
onDragOver={(e) => {
e.preventDefault();
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();
}}
className={cn(
"relative flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed transition-colors",
"px-6 py-12 min-h-[160px] cursor-pointer",
dragging
? "border-accent bg-accent/5 ring-2 ring-accent/30"
: "border-border/60 bg-muted/20 hover:bg-muted/30 hover:border-border",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
)}
>
{/* Soft inner glow when dragging — a precision-instrument
"active" state. */}
<div
onDragOver={(e) => e.preventDefault()}
onDrop={onDrop}
onClick={() => inputRef.current?.click()}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
}}
className={cn(
"relative flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border/60 bg-muted/20 px-6 py-10 sm:py-8 min-h-[140px] sm:min-h-[120px] cursor-pointer transition-colors",
"hover:bg-muted/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
"h-10 w-10 rounded-md ring-1 ring-inset ring-border/60 flex items-center justify-center transition-colors",
dragging ? "bg-accent/15 text-accent ring-accent/40" : "bg-muted/30 text-muted-foreground"
)}
>
<UploadIcon
className="h-5 w-5 text-muted-foreground"
strokeWidth={1.5}
/>
{file ? (
<div className="flex items-center gap-2 text-sm">
<FileText
className="h-3.5 w-3.5 text-muted-foreground"
<UploadIcon className="h-4 w-4" strokeWidth={1.5} />
</div>
{file ? (
<div className="flex items-center gap-2 text-[13px]">
<FileText
className="h-3.5 w-3.5 text-muted-foreground"
strokeWidth={1.75}
/>
<span className="font-medium">{file.name}</span>
<span className="mono text-muted-foreground">
· {formatBytes(file.size)}
</span>
</div>
) : (
<>
<div className="text-[13.5px] font-medium">
{dragging ? "Release to upload" : "Drop a file here, or click to choose"}
</div>
<div className="mono text-[11px] text-muted-foreground">
.txt the X12 837/835 file as exported from your clearinghouse
</div>
</>
)}
<input
ref={inputRef}
type="file"
accept=".txt"
className="sr-only"
onChange={(e) => pickFile(e.target.files?.[0] ?? null)}
/>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mt-4 pt-4 border-t border-border/30">
<div className="mono text-[11px] text-muted-foreground">
{api.isConfigured ? (
<span className="inline-flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-[hsl(var(--success))]" />
Backend ready · {api.baseUrl}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-[hsl(var(--warning))]">
<AlertTriangle
className="h-3 w-3"
strokeWidth={1.75}
/>
<span className="font-medium">{file.name}</span>
<span className="text-muted-foreground num">
· {formatBytes(file.size)}
</span>
</div>
) : (
<>
<div className="text-sm font-medium">
Drop a file here, or click to choose
</div>
<div className="text-[12px] text-muted-foreground">
.txt the X12 837/835 file as exported from your
clearinghouse
</div>
</>
No backend configured set VITE_API_BASE_URL to enable parsing
</span>
)}
<input
ref={inputRef}
type="file"
accept=".txt"
className="sr-only"
onChange={(e) => pickFile(e.target.files?.[0] ?? null)}
</div>
<div className="flex items-center gap-2 flex-wrap">
{file ? (
<Button
variant="ghost"
onClick={() => pickFile(null)}
disabled={running}
className="min-h-[44px] sm:min-h-0"
>
Clear
</Button>
) : null}
<Button
onClick={onParse}
disabled={!file || running}
className="min-h-[44px] sm:min-h-0"
>
{running ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Parsing
</>
) : (
<>
<UploadIcon className="h-3.5 w-3.5" />
Parse
</>
)}
</Button>
</div>
</div>
</div>
{(running || stream.items.length > 0) ? (
<div className="surface-2 rounded-xl p-5">
<div className="flex items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2 text-[13.5px] font-medium">
{running ? (
<Loader2
className="h-3.5 w-3.5 animate-spin text-muted-foreground"
strokeWidth={1.75}
/>
) : (
<CheckCircle2
className="h-3.5 w-3.5 text-[hsl(var(--success))]"
strokeWidth={1.75}
/>
)}
{kind === "837p" ? "Claims" : "Claim payments"}
</div>
<p className="mono text-[10.5px] text-muted-foreground mt-1">
{totalSoFar} of {expectedTotal ?? "?"} parsed
{stream.failed > 0
? ` · ${stream.failed} failed validation`
: ""}
</p>
</div>
<div className="text-right">
<div className="display mono text-[16px]">
{expectedTotal ? `${progressPct}%` : "…"}
</div>
<div className="eyebrow">progress</div>
</div>
</div>
<div className="mt-3 h-1 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-accent transition-[width] duration-200"
style={{ width: `${progressPct}%` }}
/>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-[12px] text-muted-foreground">
{api.isConfigured ? (
<span className="inline-flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-[hsl(var(--success))]" />
Backend ready · {api.baseUrl}
</span>
<div className="mt-4 max-h-[640px] overflow-y-auto -mx-2 px-2 space-y-2">
{stream.items.length === 0 && running ? (
<div className="text-center py-8 text-muted-foreground text-[12.5px]">
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} />
) : (
<span className="inline-flex items-center gap-1.5 text-[hsl(var(--warning))]">
<AlertTriangle
className="h-3 w-3"
strokeWidth={1.75}
/>
No backend configured set VITE_API_BASE_URL to enable parsing
</span>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
{file ? (
<Button
variant="ghost"
onClick={() => pickFile(null)}
disabled={running}
className="min-h-[44px] sm:min-h-0"
>
Clear
</Button>
) : null}
<Button
onClick={onParse}
disabled={!file || running}
className="min-h-[44px] sm:min-h-0"
>
{running ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Parsing
</>
) : (
<>
<UploadIcon className="h-3.5 w-3.5" />
Parse
</>
)}
</Button>
</div>
<ClaimCard835
key={`835-${item.data.payer_claim_control_number}-${i}`}
claim={item.data}
/>
)
)}
</div>
</CardContent>
</Card>
{(running || stream.items.length > 0) ? (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-[14px] flex items-center gap-2">
{running ? (
<Loader2
className="h-3.5 w-3.5 animate-spin text-muted-foreground"
strokeWidth={1.75}
/>
) : (
<CheckCircle2
className="h-3.5 w-3.5 text-[hsl(var(--success))]"
strokeWidth={1.75}
/>
)}
{kind === "837p" ? "Claims" : "Claim payments"}
</CardTitle>
<p className="text-xs text-muted-foreground mt-1">
{totalSoFar} of{" "}
{expectedTotal ?? "?"} parsed
{stream.failed > 0
? ` · ${stream.failed} failed validation`
: ""}
</p>
</div>
<div className="text-right">
<div className="display num text-[16px]">
{expectedTotal ? `${progressPct}%` : "…"}
</div>
<div className="text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground">
progress
</div>
</div>
</div>
<div className="mt-3 h-1 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-accent transition-[width] duration-200"
style={{ width: `${progressPct}%` }}
/>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="max-h-[640px] overflow-y-auto -mx-2 px-2 space-y-2">
{stream.items.length === 0 && running ? (
<div className="text-center py-8 text-muted-foreground text-sm">
Waiting for first claim
</div>
) : null}
{stream.items.map((item, i) =>
item.kind === "837p" ? (
<ClaimCard837 key={`837-${item.data.claim_id}-${i}`} claim={item.data} />
) : (
<ClaimCard835
key={`835-${item.data.payer_claim_control_number}-${i}`}
claim={item.data}
/>
)
)}
</div>
</CardContent>
</Card>
</div>
) : null}
{parsedBatches.length > 0 ? (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-[14px]">Recent batches</CardTitle>
<p className="text-xs text-muted-foreground mt-1">
Parsed files, newest first. Click a row to revisit it (in a
future view).
</p>
</CardHeader>
<CardContent className="pt-0">
<ul className="divide-y divide-border/40">
{parsedBatches.map((b) => (
<li
key={b.id}
className="flex items-center gap-3 py-3 first:pt-0 last:pb-0"
>
<Badge variant={b.kind === "837p" ? "default" : "muted"}>
{b.kind === "837p" ? "837P" : "835"}
</Badge>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{b.inputFilename}
</div>
<div className="text-[11px] text-muted-foreground num">
{fmt.dateShort(b.parsedAt)} · {b.claimCount} {b.kind === "837p" ? "claims" : "payments"}
</div>
<div className="surface-2 rounded-xl p-5">
<div className="eyebrow mb-3">Recent batches</div>
<p className="text-[12px] text-muted-foreground -mt-2 mb-3">
Parsed files, newest first.
</p>
<ul className="divide-y divide-border/40">
{parsedBatches.map((b) => (
<li
key={b.id}
className="flex items-center gap-3 py-3 first:pt-0 last:pb-0"
>
<Badge variant={b.kind === "837p" ? "default" : "muted"}>
{b.kind === "837p" ? "837P" : "835"}
</Badge>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium truncate">
{b.inputFilename}
</div>
<div className="text-right shrink-0">
<div className="display num text-[13px]">
{b.passed} / {b.claimCount}
</div>
<div className="text-[11px] text-muted-foreground">
passed
</div>
<div className="mono text-[10.5px] text-muted-foreground">
{fmt.dateShort(b.parsedAt)} · {b.claimCount} {b.kind === "837p" ? "claims" : "payments"}
</div>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
<div className="text-right shrink-0">
<div className="display mono text-[12.5px]">
{b.passed} / {b.claimCount}
</div>
<div className="mono text-[10.5px] text-muted-foreground">
passed
</div>
</div>
</li>
))}
</ul>
</div>
) : null}
</div>
);