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:
+284
-343
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user