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
+15 -38
View File
@@ -4,6 +4,7 @@ import { useActivity } from "@/hooks/useActivity";
import { useTailStream } from "@/hooks/useTailStream";
import { useMergedTail } from "@/hooks/useMergedTail";
import { TailStatusPill } from "@/components/TailStatusPill";
import { PageHeader } from "@/components/PageHeader";
import { ActivityFeed } from "@/components/ActivityFeed";
import { ActivityFilters, type SinceValue } from "@/components/ActivityFilters";
import { Skeleton } from "@/components/ui/skeleton";
@@ -22,12 +23,6 @@ const VALID_KINDS: readonly ActivityKind[] = [
const VALID_SINCE: readonly SinceValue[] = ["1h", "24h", "7d", "all"];
/**
* Convert a relative "since" window into an ISO timestamp string for the
* backend's lexicographic `timestamp >= since` comparison. Memoized on
* `since` so the queryKey stays stable across re-renders (otherwise the
* refetch would fire on every Date.now() tick).
*/
function useSinceIso(since: SinceValue): string | undefined {
return useMemo(() => {
const now = Date.now();
@@ -41,8 +36,6 @@ function useSinceIso(since: SinceValue): string | undefined {
export function ActivityLog() {
const [searchParams, setSearchParams] = useSearchParams();
// URL → filter state. `searchParams` is stable as long as the URL is,
// so these are computed cheaply with useMemo.
const selectedKinds = useMemo<ActivityKind[]>(
() =>
searchParams
@@ -58,10 +51,6 @@ export function ActivityLog() {
? (sinceRaw as SinceValue)
: "all";
// Backend's `/api/activity` only accepts a single `kind` query param, so
// for multi-select we fetch the unfiltered set and filter client-side.
// Single-kind selections pass through to the backend (cheaper + benefits
// from any future server-side filtering).
const apiKind = selectedKinds.length === 1 ? selectedKinds[0] : undefined;
const sinceIso = useSinceIso(since);
@@ -73,9 +62,6 @@ export function ActivityLog() {
const allItems = data?.items ?? [];
// SP5: live-tail wiring. `useMergedTail` appends tail-arriving events
// to the base query result, then we apply the same client-side
// multi-kind filter so live events respect the active filter set.
const { status: tailStatus, lastEventAt: tailLastEventAt, forceReconnect } =
useTailStream("activity");
const merged = useMergedTail("activity", allItems);
@@ -128,18 +114,19 @@ export function ActivityLog() {
const hasFilters = selectedKinds.length > 0 || since !== "all";
return (
<div className="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" />
Activity
</div>
<h1 className="text-[22px] font-semibold tracking-tight">Activity log</h1>
<p className="text-muted-foreground mt-1.5 text-[14px]">
Every claim submission, denial, payment, and provider event, in
reverse chronological order. Auto-refreshes every 30s.
</p>
</header>
<div className="space-y-6 lg:space-y-8 animate-fade-in">
<PageHeader
eyebrow="Activity"
title={<>Activity <span className="display italic text-muted-foreground">log</span></>}
subtitle="Every claim submission, denial, payment, and provider event, in reverse chronological order. Auto-refreshes every 30s."
actions={
<TailStatusPill
status={tailStatus}
lastEventAt={tailLastEventAt}
onReconnect={forceReconnect}
/>
}
/>
<div className="surface rounded-xl p-4">
<ActivityFilters
@@ -160,16 +147,6 @@ export function ActivityLog() {
) : null}
<div className="surface rounded-xl p-6">
<div className="flex flex-wrap items-center gap-3 mb-4">
{/* SP5: live-tail status pill, right-aligned in the toolbar. */}
<div className="ml-auto">
<TailStatusPill
status={tailStatus}
lastEventAt={tailLastEventAt}
onReconnect={forceReconnect}
/>
</div>
</div>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
@@ -191,4 +168,4 @@ export function ActivityLog() {
</div>
</div>
);
}
}
+7 -53
View File
@@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
import { PageHeader } from "@/components/PageHeader";
import { BatchesList, BatchesListSkeleton } from "@/components/BatchesList";
import {
BatchDetailContent,
@@ -16,17 +17,6 @@ import { api, ApiError, type BatchSummary } from "@/lib/api";
import { cn } from "@/lib/utils";
import type { ParseResult837, ParseResult835 } from "@/types";
// ---------------------------------------------------------------------------
// Inline: batch drawer URL state.
//
// `useDrawerUrlState` (in src/hooks/) is hard-coded to `?claim=` — it
// exists to drive the claim detail drawer. Mirroring that shape for
// `?batch=` keeps the two drawers decoupled: opening a claim from
// elsewhere doesn't disturb the batch URL, and vice versa. Inlining
// (rather than creating a parallel hook file) keeps the change inside
// this workstream's scope.
// ---------------------------------------------------------------------------
function readBatchId(): string | null {
const params = new URLSearchParams(window.location.search);
const value = params.get("batch");
@@ -75,20 +65,6 @@ function useBatchDrawerUrlState(): {
return { batchId, open, close, setBatchId };
}
// ---------------------------------------------------------------------------
// Inline: per-batch detail query.
//
// `api.getBatch` throws plain `Error` with the HTTP status code at the
// start of the message (e.g. `"404 Not Found — batch not found"`),
// not `ApiError`. To drive the drawer's not-found branch via the same
// `ApiError.status === 404` check used everywhere else, this hook
// detects the `"404"` prefix in the message and rethrows as
// `ApiError(404, …)`. Other errors pass through unchanged.
//
// 30-second staleTime mirrors `useClaimDetail` so j/k navigation
// reuses prior fetches within the drawer session.
// ---------------------------------------------------------------------------
function useBatchDetail(id: string | null): {
data: (ParseResult837 | ParseResult835) | null;
isLoading: boolean;
@@ -140,10 +116,6 @@ function useBatchDetail(id: string | null): {
};
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
const HELP_NOOP = () => {};
export function Batches() {
@@ -155,10 +127,6 @@ export function Batches() {
const { data: detail, isLoading: detailLoading, error: detailError, refetch: refetchDetail } =
useBatchDetail(batchId);
// j/k navigation: derive next/prev batch IDs based on the current
// batch's index in the loaded list. Wrap around at both ends. If
// the current batch isn't in the list, the callbacks are no-ops —
// defensive against bad input.
const { onNext, onPrev } = useMemo(() => {
if (batchId === null || items.length === 0) {
return { onNext: () => {}, onPrev: () => {} };
@@ -182,9 +150,6 @@ export function Batches() {
onNext,
onPrev,
onClose: close,
// The Batches page doesn't ship a keyboard cheatsheet overlay —
// ? is silently swallowed here. Wiring it later is a no-op
// because useDrawerKeyboard only fires the callback.
onToggleHelp: HELP_NOOP,
});
@@ -200,9 +165,6 @@ export function Batches() {
}}
>
<DialogContent
// Right-anchored side panel, matching the claim-drawer's
// visual identity. We override the Dialog primitive's
// centered defaults so it behaves as a drawer (not a modal).
className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-border bg-card p-0"
data-testid="batch-detail-drawer"
aria-describedby={undefined}
@@ -226,23 +188,15 @@ export function Batches() {
<div
data-testid="batches-page-body"
className={cn(
"space-y-8 animate-fade-in transition-opacity",
"space-y-6 lg:space-y-8 animate-fade-in transition-opacity",
dimBackground && "pointer-events-none opacity-60",
)}
>
<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" />
Batches
</div>
<h1 className="text-[22px] font-semibold tracking-tight">
Parsed batches
</h1>
<p className="text-muted-foreground mt-1.5 text-[14px]">
Every 837P and 835 file the backend has ingested. Click a row
for envelope, summary, and a peek at the contained claims.
</p>
</header>
<PageHeader
eyebrow="Batches"
title={<>Parsed <span className="display italic text-muted-foreground">batches</span></>}
subtitle="Every 837P and 835 file the backend has ingested. Click a row for envelope, summary, and a peek at the contained claims."
/>
{isError ? (
<ErrorState
+181 -214
View File
@@ -25,6 +25,7 @@ import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
import { FilterChips, type FilterChipOption } from "@/components/ui/filter-chips";
import { Pagination } from "@/components/ui/pagination";
import { PageHeader } from "@/components/PageHeader";
import { useClaims } from "@/hooks/useClaims";
import { useDrawerUrlState } from "@/hooks/useDrawerUrlState";
import { useTailStream } from "@/hooks/useTailStream";
@@ -55,10 +56,6 @@ export function Claims() {
const [helpOpen, setHelpOpen] = useState(false);
const searchRef = useRef<HTMLInputElement>(null);
// SP4 drawer wiring: the URL is the source of truth for "which claim is
// open". `open(id)` pushState's a new history entry (Back returns to the
// list); `close()` pushState's the bare URL; `setClaimId(id)` replaceState's
// so j/k navigation doesn't pollute history with one entry per keystroke.
const { claimId, open, close, setClaimId } = useDrawerUrlState();
const providers = useAppStore((s) => s.providers);
@@ -78,15 +75,6 @@ export function Claims() {
const { data, isLoading, isError, error, refetch, dataUpdatedAt } = useClaims(params);
// SP5 live-tail wiring (sub-project 5, Phase 5 Task 22).
//
// The tail stream emits every claim that lands in the DB — including
// ones that don't match the user's current `status` / `provider_npi`
// filter. Mirror the server-side filter the page already applies via
// `useClaims` so a newly-arrived item that wouldn't pass the same
// predicate is dropped before it can show up in the table. `useMergedTail`
// applies this to the tail slice only; base items are already filtered
// server-side.
const tailFilterFn = useMemo(
() => (c: Claim) => {
if (status !== ALL && c.status !== status) return false;
@@ -108,7 +96,6 @@ export function Claims() {
[items]
);
// Local search filter on the current page (cheap; no extra fetch).
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return items;
@@ -122,21 +109,12 @@ export function Claims() {
}, [items, query]);
const filtered = status !== ALL || npi !== ALL;
// Dim the background list while the drawer is open so the user's eye
// lands on the drawer. `pointer-events-none` prevents accidental clicks
// on the dimmed list (e.g., clicking a row would open yet another
// drawer on top).
const dimBackground = claimId !== null;
return (
<>
<ClaimDrawer
claimId={claimId}
// Pass the full ordered list of ids from the current page so j/k
// can wrap around without an extra round-trip. `items` is the API
// response (post-pagination, post-search); that's the same set the
// user is looking at, so wrap-around feels right.
claims={items.map((c) => ({ id: c.id }))}
onClose={close}
onNavigate={setClaimId}
@@ -149,211 +127,200 @@ export function Claims() {
<div
data-testid="claims-page-body"
className={cn(
"space-y-8 animate-fade-in transition-opacity",
"space-y-6 lg:space-y-8 animate-fade-in transition-opacity",
dimBackground && "pointer-events-none opacity-60"
)}
>
<header className="flex items-end justify-between gap-6 flex-wrap">
<div>
<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" />
Claims
</div>
<h1 className="text-[22px] font-semibold tracking-tight">All claims</h1>
</div>
<NewClaimDialog />
</header>
{isError ? (
<ErrorState
message="Couldn't load claims from the backend."
detail={error instanceof Error ? error.message : String(error)}
onRetry={() => refetch()}
/>
) : null}
<div className="surface rounded-xl p-4">
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[240px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={searchRef}
placeholder="Search by claim ID, patient, payer, CPT…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 pr-16"
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground p-1.5 rounded-md"
aria-label="Clear search"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
<Select
value={npi}
onValueChange={(v) => {
setNpi(v);
setPage(1);
}}
>
<SelectTrigger className="w-[240px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All providers</SelectItem>
{providers.map((p) => (
<SelectItem key={p.npi} value={p.npi}>
{p.npi} {p.name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* SP5: live-tail status pill. `ml-auto` pins it to the right
edge of the toolbar so it doesn't crowd the search input. */}
<div className="ml-auto">
<PageHeader
eyebrow="Claims"
title={<>All <span className="display italic text-muted-foreground">claims</span></>}
subtitle="Every claim submitted, accepted, denied, or paid — filtered, sorted, and live."
actions={
<TailStatusPill
status={tailStatus}
lastEventAt={tailLastEventAt}
onReconnect={forceReconnect}
/>
</div>
</div>
}
status={
<NewClaimDialog />
}
/>
<div className="mt-3">
<FilterChips
options={STATUS_OPTIONS}
value={status === ALL ? null : status}
onChange={(v) => {
setStatus((v as ClaimStatus | null) ?? ALL);
setPage(1);
}}
eyebrow="Status"
{isError ? (
<ErrorState
message="Couldn't load claims from the backend."
detail={error instanceof Error ? error.message : String(error)}
onRetry={() => refetch()}
/>
</div>
) : null}
<div className="flex items-center gap-6 mt-4 pt-4 border-t border-border/40 text-xs text-muted-foreground">
<span>
<span className="num text-foreground font-medium">
{fmt.num(data?.total ?? 0)}
</span>{" "}
claims
</span>
<span>
Billed{" "}
<span className="num text-foreground font-medium">
{fmt.usd(totals.billed)}
</span>
</span>
<span>
Received{" "}
<span className="num text-foreground font-medium">
{fmt.usd(totals.received)}
</span>
</span>
</div>
</div>
<div className="surface rounded-xl overflow-hidden">
{isLoading ? (
<div className="p-4 space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} variant="row" />
))}
</div>
) : items.length === 0 ? (
<EmptyState
eyebrow="Claims · inbox idle"
message={
filtered
? "No claims match the current filters."
: "Drop an 837P file on the Upload page to populate this list."
}
/>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Claim</TableHead>
<TableHead>Patient</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Payer</TableHead>
<TableHead className="text-right">Billed</TableHead>
<TableHead className="text-right">Received</TableHead>
<TableHead>Status</TableHead>
<TableHead>Submitted</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.map((c) => {
const provider = providerMap[c.providerNpi];
return (
<TableRow
key={`${c.id}-${dataUpdatedAt}`}
// Click anywhere on the row to open the drawer for
// this claim. `cursor-pointer` makes the affordance
// obvious; the existing `hover:bg-muted/30` from the
// TableRow primitive stays in place.
onClick={() => open(c.id)}
className={cn(
"animate-row-flash cursor-pointer",
// Re-keying on dataUpdatedAt re-mounts the row on
// every refetch (initial load, filter change, parse
// invalidation) so the row-flash keyframe replays.
)}
>
<TableCell>
<div className="display num text-[13px]">{c.id}</div>
<div className="text-[11px] text-muted-foreground num">
CPT {c.cptCode}
</div>
</TableCell>
<TableCell className="font-medium">{c.patientName}</TableCell>
<TableCell>
<div className="text-sm">{provider?.name ?? "Unknown"}</div>
<div className="text-[11px] text-muted-foreground num">
{c.providerNpi}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{c.payerName}
</TableCell>
<TableCell className="text-right display num">
{fmt.usd(c.billedAmount)}
</TableCell>
<TableCell className="text-right display num text-muted-foreground">
{c.receivedAmount > 0 ? fmt.usd(c.receivedAmount) : "—"}
</TableCell>
<TableCell>
<StatusBadge status={c.status} />
</TableCell>
<TableCell className="text-muted-foreground text-[13px] num">
{fmt.dateShort(c.submissionDate)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<div className="px-4 pb-4">
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={data?.total ?? 0}
onPageChange={setPage}
<div className="surface rounded-xl p-4">
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[240px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={searchRef}
placeholder="Search by claim ID, patient, payer, CPT…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 pr-10"
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground p-1.5 rounded-md transition-colors hover:bg-muted/60"
aria-label="Clear search"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
</>
)}
<Select
value={npi}
onValueChange={(v) => {
setNpi(v);
setPage(1);
}}
>
<SelectTrigger className="w-[240px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All providers</SelectItem>
{providers.map((p) => (
<SelectItem key={p.npi} value={p.npi}>
{p.npi} {p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="mt-3.5">
<FilterChips
options={STATUS_OPTIONS}
value={status === ALL ? null : status}
onChange={(v) => {
setStatus((v as ClaimStatus | null) ?? ALL);
setPage(1);
}}
eyebrow="Status"
/>
</div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 mt-4 pt-4 border-t border-border/30 mono text-[11px] text-muted-foreground">
<span>
<span className="text-foreground font-medium">
{fmt.num(data?.total ?? 0)}
</span>{" "}
claims
</span>
<span>
Billed{" "}
<span className="text-foreground font-medium">
{fmt.usd(totals.billed)}
</span>
</span>
<span>
Received{" "}
<span className="text-foreground font-medium">
{fmt.usd(totals.received)}
</span>
</span>
<span className="ml-auto text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground/70">
Press <span className="kbd mx-1">?</span> for shortcuts
</span>
</div>
</div>
<div className="surface rounded-xl overflow-hidden">
{isLoading ? (
<div className="p-4 space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} variant="row" />
))}
</div>
) : items.length === 0 ? (
<EmptyState
eyebrow="Claims · inbox idle"
message={
filtered
? "No claims match the current filters."
: "Drop an 837P file on the Upload page to populate this list."
}
/>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Claim</TableHead>
<TableHead>Patient</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Payer</TableHead>
<TableHead className="text-right">Billed</TableHead>
<TableHead className="text-right">Received</TableHead>
<TableHead>Status</TableHead>
<TableHead>Submitted</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.map((c) => {
const provider = providerMap[c.providerNpi];
return (
<TableRow
key={`${c.id}-${dataUpdatedAt}`}
onClick={() => open(c.id)}
className={cn("animate-row-flash cursor-pointer")}
>
<TableCell>
<div className="display mono text-[12.5px] text-foreground">{c.id}</div>
<div className="mono text-[10.5px] text-muted-foreground">
CPT {c.cptCode}
</div>
</TableCell>
<TableCell className="font-medium text-[13px]">{c.patientName}</TableCell>
<TableCell>
<div className="text-[13px]">{provider?.name ?? "Unknown"}</div>
<div className="mono text-[10.5px] text-muted-foreground">
{c.providerNpi}
</div>
</TableCell>
<TableCell className="text-muted-foreground text-[13px]">
{c.payerName}
</TableCell>
<TableCell className="text-right display mono">
{fmt.usd(c.billedAmount)}
</TableCell>
<TableCell className="text-right display mono text-muted-foreground">
{c.receivedAmount > 0 ? fmt.usd(c.receivedAmount) : "—"}
</TableCell>
<TableCell>
<StatusBadge status={c.status} />
</TableCell>
<TableCell className="text-muted-foreground mono text-[12px]">
{fmt.dateShort(c.submissionDate)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<div className="px-4 pb-4">
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={data?.total ?? 0}
onPageChange={setPage}
/>
</div>
</>
)}
</div>
</div>
</div>
</>
);
}
+71 -64
View File
@@ -9,6 +9,7 @@ import {
TrendingDown,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PageHeader } from "@/components/PageHeader";
import { KpiCard } from "@/components/KpiCard";
import { ActivityFeed } from "@/components/ActivityFeed";
import { AnimatedNumber } from "@/components/AnimatedNumber";
@@ -49,7 +50,6 @@ function buildMonthly(claims: ReturnType<typeof useAppStore.getState>["claims"])
months[i]!.received += c.receivedAmount;
if (c.status === "denied") months[i]!.denied += 1;
}
// Running outstanding AR (cumulative billed cumulative received)
let running = 0;
const ar: number[] = [];
for (const m of months) {
@@ -92,7 +92,7 @@ export function Dashboard() {
const monthly = useMemo(() => buildMonthly(claims), [claims]);
const topProviders = useMemo(
() => [...providers].sort((a, b) => b.claimCount - a.claimCount).slice(0, 3),
() => [...providers].sort((a, b) => b.claimCount - a.claimCount).slice(0, 4),
[providers]
);
@@ -101,69 +101,72 @@ export function Dashboard() {
[claims]
);
// Stagger choreography
// Stagger choreography — the hero lands first, then the KPIs in
// a left-to-right wave, then the supporting cards. Total
// choreography fits under 700ms.
const heroDelay = 0;
const kpiBase = 120;
const kpiStep = 70;
const sectionBase = kpiBase + kpiStep * 5 + 60; // after the last KPI
const kpiStep = 60;
const sectionBase = kpiBase + kpiStep * 5 + 80;
return (
<div className="space-y-6 md:space-y-10">
{/* Hero */}
<header
<div className="space-y-6 lg:space-y-10">
{/* Hero — the dashboard's only moment of editorial display. The
ghost total sits behind the greeting at single-digit opacity
so it reads as a watermark, not a competing headline. */}
<section
className="relative animate-fade-in overflow-hidden"
style={{ animationDelay: `${heroDelay}ms` }}
>
<div className="relative">
{/* The signature element: a ghosted total behind the greeting. */}
<div
aria-hidden="true"
className="pointer-events-none select-none absolute left-0 top-1/2 -translate-y-1/2 whitespace-nowrap display font-medium text-foreground"
style={{
fontSize: "clamp(80px, 14vw, 168px)",
letterSpacing: "-0.05em",
opacity: 0.055,
lineHeight: 1,
}}
>
{fmt.usd(kpis.billed)}
</div>
<div
aria-hidden="true"
className="pointer-events-none select-none absolute -left-4 top-1/2 -translate-y-1/2 whitespace-nowrap display text-foreground"
style={{
fontSize: "clamp(80px, 14vw, 168px)",
letterSpacing: "-0.04em",
opacity: 0.04,
lineHeight: 1,
}}
>
{fmt.usd(kpis.billed)}
</div>
<div className="relative z-10 flex items-end justify-between gap-4 sm:gap-6 flex-wrap">
<div className="min-w-0">
<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" />
Today · {fmt.date(new Date().toISOString())}
</div>
<h1 className="text-[22px] sm:text-[28px] leading-[1.15] font-semibold tracking-tight">
Good morning, Jordan.
</h1>
<p className="text-muted-foreground mt-1.5 max-w-xl text-[14px] leading-relaxed">
Three NPIs are in flight. {kpis.pending} claims pending,
clearinghouse cycle is normal.
</p>
</div>
<div className="flex items-center gap-2.5 rounded-full border border-border/60 bg-card/60 px-3 py-1.5 text-xs text-muted-foreground backdrop-blur">
<span className="relative inline-flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full rounded-full bg-[hsl(var(--success))] opacity-60 animate-ping" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-[hsl(var(--success))]" />
</span>
Clearinghouse live
</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={`Today · ${fmt.date(new Date().toISOString())}`}
title={<>Good morning, Jordan.</>}
subtitle={
<>
<span className="display text-foreground">Three NPIs</span> are
in flight. {kpis.pending} claims pending, clearinghouse cycle
is normal.
</>
}
/>
</div>
<div className="flex items-center gap-2 rounded-full border border-border/60 bg-card/60 px-3 py-1.5 text-[11px] mono uppercase tracking-[0.12em] text-muted-foreground backdrop-blur">
<span className="relative inline-flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full rounded-full bg-[hsl(var(--success))] opacity-60 animate-ping" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-[hsl(var(--success))]" />
</span>
Clearinghouse live
</div>
</div>
</header>
</section>
{/* KPIs — every card carries a trend. */}
{/* KPIs — five tiles, each carrying a sparkline. Staggered
fade-in on first paint for a "the instrument powers up"
feel. */}
<section
aria-label="Key performance indicators"
className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5"
className="grid gap-3.5 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5"
>
<KpiCard
label="Claims"
icon={Receipt}
sparkline={monthly.count}
className="animate-fade-in"
className="animate-fade-in-up"
style={{ animationDelay: `${kpiBase + 0 * kpiStep}ms` }}
value={
<AnimatedNumber value={kpis.count} format={(n) => fmt.num(Math.round(n))} />
@@ -173,8 +176,9 @@ export function Dashboard() {
<KpiCard
label="Billed"
icon={CircleDollarSign}
accent="accent"
sparkline={monthly.billed}
className="animate-fade-in"
className="animate-fade-in-up"
style={{ animationDelay: `${kpiBase + 1 * kpiStep}ms` }}
value={<AnimatedNumber value={kpis.billed} format={fmt.usd} />}
delta={{ value: "+12.4%", direction: "up", positive: true }}
@@ -182,8 +186,9 @@ export function Dashboard() {
<KpiCard
label="Received"
icon={Banknote}
accent="success"
sparkline={monthly.received}
className="animate-fade-in"
className="animate-fade-in-up"
style={{ animationDelay: `${kpiBase + 2 * kpiStep}ms` }}
value={<AnimatedNumber value={kpis.received} format={fmt.usd} />}
delta={{ value: "+8.1%", direction: "up", positive: true }}
@@ -191,8 +196,9 @@ export function Dashboard() {
<KpiCard
label="Pending AR"
icon={Clock}
accent="warning"
sparkline={monthly.ar}
className="animate-fade-in"
className="animate-fade-in-up"
style={{ animationDelay: `${kpiBase + 3 * kpiStep}ms` }}
value={
<AnimatedNumber
@@ -205,8 +211,9 @@ export function Dashboard() {
<KpiCard
label="Denial rate"
icon={TrendingDown}
accent="destructive"
sparkline={monthly.denialRate}
className="animate-fade-in"
className="animate-fade-in-up"
style={{ animationDelay: `${kpiBase + 4 * kpiStep}ms` }}
value={
<AnimatedNumber
@@ -220,7 +227,7 @@ export function Dashboard() {
{/* Activity + Top providers */}
<section
className="grid gap-4 lg:grid-cols-3 animate-fade-in"
className="grid gap-4 lg:grid-cols-3 animate-fade-in-up"
style={{ animationDelay: `${sectionBase}ms` }}
>
<Card className="lg:col-span-2">
@@ -234,7 +241,7 @@ export function Dashboard() {
Newest submissions, denials, and remittances across all providers.
</p>
</div>
<span className="text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground num">
<span className="mono text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground">
{activity.length} events
</span>
</CardHeader>
@@ -254,18 +261,18 @@ export function Dashboard() {
<ul className="space-y-4">
{topProviders.map((p, i) => (
<li key={p.npi} className="flex items-center gap-3">
<div className="h-7 w-7 rounded-md bg-muted/60 flex items-center justify-center text-[11px] font-medium num text-muted-foreground">
<div className="h-7 w-7 rounded-md bg-muted/60 ring-1 ring-inset ring-border/40 flex items-center justify-center mono text-[10.5px] text-muted-foreground">
{String(i + 1).padStart(2, "0")}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{p.name}</div>
<div className="text-[11px] text-muted-foreground num">
<div className="text-[13px] font-medium truncate">{p.name}</div>
<div className="mono text-[10.5px] text-muted-foreground">
NPI {p.npi}
</div>
</div>
<div className="text-right">
<div className="display text-[15px]">{fmt.num(p.claimCount)}</div>
<div className="text-[11px] text-muted-foreground num">
<div className="display mono text-[15px]">{fmt.num(p.claimCount)}</div>
<div className="mono text-[10.5px] text-muted-foreground">
{fmt.usd(p.outstandingAr)} AR
</div>
</div>
@@ -278,7 +285,7 @@ export function Dashboard() {
{topDenials.length > 0 ? (
<section
className="animate-fade-in"
className="animate-fade-in-up"
style={{ animationDelay: `${sectionBase + 90}ms` }}
>
<Card>
@@ -301,20 +308,20 @@ export function Dashboard() {
key={c.id}
className="flex items-start gap-3 py-3 first:pt-0 last:pb-0"
>
<div className="display text-[13px] num text-muted-foreground pt-0.5 w-24 shrink-0">
<div className="display mono text-[12.5px] text-muted-foreground pt-0.5 w-24 shrink-0">
{c.id}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm">{c.patientName}</div>
<div className="text-[12px] text-muted-foreground">
<div className="text-[13px]">{c.patientName}</div>
<div className="text-[11.5px] text-muted-foreground">
{c.denialReason ?? "—"}
</div>
</div>
<div className="text-right shrink-0">
<div className="display text-[14px] num">
<div className="display mono text-[13.5px]">
{fmt.usd(c.billedAmount)}
</div>
<div className="text-[11px] text-muted-foreground">
<div className="text-[10.5px] text-muted-foreground">
{c.payerName}
</div>
</div>
+65 -20
View File
@@ -135,10 +135,15 @@ export default function Inbox() {
if (loading) {
return (
<div
className="min-h-screen p-8 font-mono"
style={{ background: "var(--tt-bg)", color: "var(--tt-ink-dim)" }}
className="min-h-screen p-8 mono flex items-center gap-2"
style={{ background: "var(--tt-bg)", color: "var(--tt-ink-dim)", fontSize: 12 }}
>
loading
<span
aria-hidden
className="inline-block h-1.5 w-1.5 rounded-full animate-pulse-dot"
style={{ background: "var(--tt-amber)" }}
/>
loading inbox
</div>
);
}
@@ -146,10 +151,13 @@ export default function Inbox() {
if (error) {
return (
<div
className="min-h-screen p-8 font-mono"
style={{ background: "var(--tt-bg)", color: "var(--tt-oxblood)" }}
className="min-h-screen p-8 mono flex flex-col gap-2"
style={{ background: "var(--tt-bg)", color: "var(--tt-oxblood)", fontSize: 12 }}
>
error: {error.message}
<span className="uppercase tracking-[0.18em]" style={{ fontSize: 10, fontWeight: 700 }}>
Connection error
</span>
<span>{error.message}</span>
</div>
);
}
@@ -163,7 +171,7 @@ export default function Inbox() {
style={{ background: "var(--tt-bg)", color: "var(--tt-ink)" }}
>
<InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} />
<main className="p-4 flex gap-4 items-start flex-wrap">
<main className="px-6 pb-24 pt-4 flex gap-4 items-stretch flex-wrap">
<Lane
name="REJECTED"
accent="oxblood"
@@ -229,42 +237,78 @@ export default function Inbox() {
regenerated 837 files. Single-claim resubmit skips this. */}
{bundleModalOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: "rgba(0,0,0,0.55)" }}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: "rgba(0,0,0,0.65)", backdropFilter: "blur(4px)" }}
data-testid="resubmit-bundle-modal"
role="dialog"
aria-label="Resubmit and download bundle"
>
<div
className="w-[min(32rem,90vw)] rounded-md border p-6 font-mono"
className="w-[min(34rem,90vw)] rounded-md border p-6 mono"
style={{
background: "var(--tt-bg)",
borderColor: "var(--tt-amber)",
color: "var(--tt-ink)",
boxShadow:
"0 0 0 1px hsl(36 88% 56% / 0.18), 0 24px 60px -12px hsl(0 0% 0% / 0.7), inset 0 1px 0 0 hsl(0 0% 100% / 0.04)",
}}
>
<div className="flex items-center gap-2 mb-3">
<span
aria-hidden
className="inline-block h-2 w-2 rounded-full animate-pulse-dot"
style={{
background: "var(--tt-amber)",
boxShadow: "0 0 8px var(--tt-amber)",
}}
/>
<span
className="eyebrow"
style={{ color: "var(--tt-amber)" }}
>
Resubmit batch
</span>
</div>
<h2
className="mb-3 text-sm uppercase tracking-[0.18em]"
style={{ color: "var(--tt-amber)" }}
className="display text-2xl mb-3"
style={{ color: "var(--tt-ink)", letterSpacing: "-0.02em" }}
>
Resubmit {selected.rejected.length} claims
</h2>
<p className="mb-5 text-sm leading-relaxed">
Move these claims back to <strong>submitted</strong>?
You can also download a bundle of regenerated 837 files
<p
className="mb-6 text-sm leading-relaxed"
style={{ color: "var(--tt-ink-dim)" }}
>
Move these claims back to{" "}
<span
className="px-1.5 py-0.5 rounded-sm"
style={{
background: "var(--tt-amber)",
color: "var(--tt-bg)",
fontWeight: 700,
}}
>
submitted
</span>
? Optionally download a bundle of regenerated 837 files
(one <code>.x12</code> per claim) useful if you're
about to ship them to the clearinghouse.
</p>
<div className="flex justify-end gap-3">
<div className="flex justify-end gap-2.5">
<button
type="button"
onClick={onResubmitOnly}
disabled={bundleSubmitting}
data-testid="resubmit-modal-only"
className="border px-4 py-2 text-xs uppercase tracking-wider disabled:opacity-50"
className="px-4 py-2 rounded-sm text-[11px] uppercase tracking-[0.14em] transition-all hover:bg-[color:var(--tt-amber)]/10 disabled:opacity-50 focus-visible:outline-none focus-visible:bg-[color:var(--tt-amber)]/10"
style={{
borderColor: "var(--tt-amber)",
border: "1px solid var(--tt-amber)",
color: "var(--tt-amber)",
background: "transparent",
fontWeight: 600,
}}
>
Resubmit only
@@ -274,10 +318,11 @@ export default function Inbox() {
onClick={onResubmitAndDownload}
disabled={bundleSubmitting}
data-testid="resubmit-modal-download"
className="px-4 py-2 text-xs uppercase tracking-wider disabled:opacity-50"
className="px-4 py-2 rounded-sm text-[11px] uppercase tracking-[0.14em] transition-all hover:brightness-110 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--tt-amber)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--tt-bg)]"
style={{
background: "var(--tt-amber)",
color: "var(--tt-bg)",
fontWeight: 700,
}}
>
Resubmit + Download
@@ -294,7 +339,7 @@ export default function Inbox() {
{bundleSubmitting && (
<div
className="fixed inset-0 z-40 cursor-wait"
style={{ background: "rgba(0,0,0,0.15)" }}
style={{ background: "rgba(0,0,0,0.15)", backdropFilter: "blur(1px)" }}
data-testid="resubmit-progress-overlay"
aria-hidden="true"
/>
+22 -31
View File
@@ -2,6 +2,7 @@ import { Building2, MapPin, Phone } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
import { PageHeader } from "@/components/PageHeader";
import { useProviders } from "@/hooks/useProviders";
import { fmt } from "@/lib/format";
@@ -10,18 +11,12 @@ export function Providers() {
const items = data?.items ?? [];
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" />
Providers
</div>
<h1 className="text-[22px] sm:text-[26px] font-semibold tracking-tight">Provider directory</h1>
<p className="text-muted-foreground mt-1.5 text-[14px]">
NPIs registered with the clearinghouse for 837P submission and 835
remittance retrieval.
</p>
</header>
<div className="space-y-6 lg:space-y-8 animate-fade-in">
<PageHeader
eyebrow="Providers"
title={<>Provider <span className="display italic text-muted-foreground">directory</span></>}
subtitle="NPIs registered with the clearinghouse for 837P submission and 835 remittance retrieval."
/>
{isError ? (
<ErrorState
@@ -45,53 +40,49 @@ export function Providers() {
/>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div className="grid gap-3.5 md:grid-cols-2 xl:grid-cols-3">
{items.map((p) => (
<article
key={p.npi}
className="surface rounded-xl p-4 sm:p-6 flex flex-col gap-4 hover:bg-muted/20 transition-colors"
className="group surface-2 rounded-xl p-5 flex flex-col gap-4 transition-colors hover:bg-muted/20 cursor-default"
>
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-lg bg-muted/60 flex items-center justify-center text-foreground">
<Building2 className="h-5 w-5" strokeWidth={1.5} />
<div className="h-10 w-10 rounded-md bg-muted/60 ring-1 ring-inset ring-border/40 flex items-center justify-center text-foreground">
<Building2 className="h-4 w-4" strokeWidth={1.5} />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-[15px] font-semibold tracking-tight">
<h3 className="display text-[15px] text-foreground tracking-tight">
{p.name}
</h3>
<p className="text-[11px] text-muted-foreground num mt-0.5">
<p className="mono text-[10.5px] text-muted-foreground mt-0.5">
NPI {p.npi} · TIN {p.taxId}
</p>
</div>
</div>
<div className="space-y-1.5 text-[13px] text-muted-foreground">
<div className="flex items-center gap-2">
<MapPin className="h-3.5 w-3.5" strokeWidth={1.75} />
<div className="space-y-1.5 text-[12.5px] text-muted-foreground">
<div className="flex items-start gap-2">
<MapPin className="h-3.5 w-3.5 mt-0.5 shrink-0" strokeWidth={1.75} />
<span>
{p.address}, {p.city}, {p.state} {p.zip}
</span>
</div>
<div className="flex items-center gap-2">
<Phone className="h-3.5 w-3.5" strokeWidth={1.75} />
<span className="num">{p.phone}</span>
<span className="mono">{p.phone}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-border/40">
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-border/30">
<div>
<div className="text-[11px] uppercase tracking-wider text-muted-foreground">
Claims
</div>
<div className="display text-[20px] leading-none mt-1.5">
<div className="eyebrow">Claims</div>
<div className="display mono text-[20px] leading-none mt-2">
{fmt.num(p.claimCount)}
</div>
</div>
<div>
<div className="text-[11px] uppercase tracking-wider text-muted-foreground">
Outstanding AR
</div>
<div className="display text-[20px] leading-none mt-1.5">
<div className="eyebrow">Outstanding AR</div>
<div className="display mono text-[20px] leading-none mt-2">
{fmt.usd(p.outstandingAr)}
</div>
</div>
+42 -67
View File
@@ -6,6 +6,7 @@ import { ApiError } from "@/lib/api";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
import { PageHeader } from "@/components/PageHeader";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -15,10 +16,6 @@ import { cn } from "@/lib/utils";
* selected" to POST /api/reconciliation/match. The hook's invalidation
* cascade refreshes the unmatched bucket + claims + remits + activity feeds
* on success.
*
* Loading / error / empty states each early-return so the two-column
* selection JSX stays readable and doesn't have to defensively check
* `unmatched.data` everywhere.
*/
export function ReconciliationPage() {
const { unmatched, match } = useReconciliation();
@@ -27,16 +24,11 @@ export function ReconciliationPage() {
if (unmatched.isLoading) {
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" />
Reconciliation
</div>
<h1 className="text-[22px] sm:text-[26px] font-semibold tracking-tight">
Manual pairing
</h1>
</header>
<div className="space-y-6 lg:space-y-8 animate-fade-in">
<PageHeader
eyebrow="Reconciliation"
title={<>Manual <span className="display italic text-muted-foreground">pairing</span></>}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Skeleton variant="default" height={320} />
<Skeleton variant="default" height={320} />
@@ -51,16 +43,11 @@ export function ReconciliationPage() {
? unmatched.error.message
: String(unmatched.error);
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" />
Reconciliation
</div>
<h1 className="text-[22px] sm:text-[26px] font-semibold tracking-tight">
Manual pairing
</h1>
</header>
<div className="space-y-6 lg:space-y-8 animate-fade-in">
<PageHeader
eyebrow="Reconciliation"
title={<>Manual <span className="display italic text-muted-foreground">pairing</span></>}
/>
<ErrorState
message="Couldn't load unmatched claims and remittances."
detail={detail}
@@ -78,16 +65,11 @@ export function ReconciliationPage() {
if (empty) {
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" />
Reconciliation
</div>
<h1 className="text-[22px] sm:text-[26px] font-semibold tracking-tight">
Manual pairing
</h1>
</header>
<div className="space-y-6 lg:space-y-8 animate-fade-in">
<PageHeader
eyebrow="Reconciliation"
title={<>Manual <span className="display italic text-muted-foreground">pairing</span></>}
/>
<div className="surface rounded-xl">
<EmptyState
icon={<CircleDashed className="h-4 w-4" strokeWidth={1.5} />}
@@ -107,8 +89,6 @@ export function ReconciliationPage() {
setSelectedClaim(null);
setSelectedRemit(null);
} catch (e) {
// ApiError carries the HTTP status; 409 is the backend's "already
// matched" signal, so surface a friendlier message than the raw body.
const msg =
e instanceof ApiError && e.status === 409
? "Already matched — view the existing match."
@@ -125,25 +105,19 @@ export function ReconciliationPage() {
};
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" />
Reconciliation
</div>
<h1 className="text-[22px] sm:text-[26px] font-semibold tracking-tight">
Manual pairing
</h1>
<p className="text-muted-foreground mt-1.5 text-[14px]">
Pick one claim and one remittance, then match them.
</p>
</header>
<div className="space-y-6 lg:space-y-8 animate-fade-in">
<PageHeader
eyebrow="Reconciliation"
title={<>Manual <span className="display italic text-muted-foreground">pairing</span></>}
subtitle="Pick one claim and one remittance, then match them."
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Claims column */}
<div className="surface rounded-xl border border-border/60 p-4 space-y-2">
<h2 className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Unmatched claims ({claims.length})
<div className="surface-2 rounded-xl p-4 space-y-2">
<h2 className="eyebrow flex items-center justify-between">
Unmatched claims
<span className="mono text-[10.5px] text-foreground/80">{claims.length}</span>
</h2>
<div className="space-y-2">
{claims.map((c) => {
@@ -155,17 +129,17 @@ export function ReconciliationPage() {
onClick={() => setSelectedClaim(c.id)}
aria-pressed={active}
className={cn(
"w-full text-left p-3 min-h-[44px] rounded-md border transition-colors",
"w-full text-left p-3 min-h-[44px] rounded-md border transition-colors text-left",
active
? "border-accent bg-accent/5"
: "border-border/60 hover:border-border"
? "border-accent bg-accent/10 ring-1 ring-inset ring-accent/30"
: "border-border/60 hover:border-border hover:bg-muted/30"
)}
>
<div className="display num text-sm">{c.id}</div>
<div className="font-mono text-xs text-muted-foreground">
<div className="display mono text-[13px]">{c.id}</div>
<div className="text-[12px] text-muted-foreground">
{c.patientName}
</div>
<div className="text-xs text-muted-foreground mt-0.5">
<div className="mono text-[11px] text-muted-foreground mt-0.5">
{c.serviceDate ?? "—"} · ${c.billedAmount.toFixed(2)} · NPI{" "}
{c.providerNpi ?? "—"}
</div>
@@ -176,9 +150,10 @@ export function ReconciliationPage() {
</div>
{/* Remits column */}
<div className="surface rounded-xl border border-border/60 p-4 space-y-2">
<h2 className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Unmatched remits ({remittances.length})
<div className="surface-2 rounded-xl p-4 space-y-2">
<h2 className="eyebrow flex items-center justify-between">
Unmatched remits
<span className="mono text-[10.5px] text-foreground/80">{remittances.length}</span>
</h2>
<div className="space-y-2">
{remittances.map((r) => {
@@ -190,13 +165,13 @@ export function ReconciliationPage() {
onClick={() => setSelectedRemit(r.id)}
aria-pressed={active}
className={cn(
"w-full text-left p-3 min-h-[44px] rounded-md border transition-colors",
"w-full text-left p-3 min-h-[44px] rounded-md border transition-colors text-left",
active
? "border-accent bg-accent/5"
: "border-border/60 hover:border-border"
? "border-accent bg-accent/10 ring-1 ring-inset ring-accent/30"
: "border-border/60 hover:border-border hover:bg-muted/30"
)}
>
<div className="font-mono text-sm flex items-center gap-2">
<div className="mono text-[13px] flex items-center gap-2">
{r.payerClaimControlNumber}
{r.isReversal ? (
<span className="text-[10px] text-warning uppercase tracking-wider">
@@ -204,7 +179,7 @@ export function ReconciliationPage() {
</span>
) : null}
</div>
<div className="text-xs text-muted-foreground mt-0.5">
<div className="mono text-[11px] text-muted-foreground mt-0.5">
Status {r.status} · ${r.paidAmount.toFixed(2)} paid · $
{r.adjustmentAmount.toFixed(2)} adj
</div>
@@ -234,7 +209,7 @@ export function ReconciliationPage() {
Clear selection
</Button>
{selectedClaim || selectedRemit ? (
<span className="text-xs text-muted-foreground sm:ml-2 basis-full sm:basis-auto">
<span className="text-[11.5px] text-muted-foreground sm:ml-2 basis-full sm:basis-auto mono">
{selectedClaim ? `Claim ${selectedClaim}` : "No claim"}
{" · "}
{selectedRemit ? `Remit ${selectedRemit}` : "No remit"}
+50 -78
View File
@@ -15,11 +15,12 @@ import { ErrorState } from "@/components/ui/error-state";
import { FilterChips, type FilterChipOption } from "@/components/ui/filter-chips";
import { Pagination } from "@/components/ui/pagination";
import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet";
import { PageHeader } from "@/components/PageHeader";
import { TailStatusPill } from "@/components/TailStatusPill";
import { useRemittances } from "@/hooks/useRemittances";
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
import { useTailStream } from "@/hooks/useTailStream";
import { useMergedTail } from "@/hooks/useMergedTail";
import { TailStatusPill } from "@/components/TailStatusPill";
import { fmt } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { CasAdjustment, Remittance, RemittanceStatus } from "@/types";
@@ -39,19 +40,19 @@ const STATUS_OPTIONS: FilterChipOption[] = [
*/
function AdjustmentRow({ adj }: { adj: CasAdjustment }) {
return (
<div className="flex items-start justify-between gap-4 py-1.5 border-b border-border/40 last:border-0">
<div className="flex items-start justify-between gap-4 py-2 border-b border-border/30 last:border-0">
<div className="min-w-0 flex-1">
<div className="font-mono text-[11px] text-muted-foreground">
<div className="mono text-[10.5px] text-muted-foreground">
{adj.group}-{adj.reason}
{adj.quantity !== null ? (
<span className="ml-2 text-muted-foreground/70">
<span className="ml-2 text-muted-foreground/60">
qty {adj.quantity}
</span>
) : null}
</div>
<div className="text-[13px] text-foreground/90 truncate">{adj.label}</div>
<div className="text-[12.5px] text-foreground/90 truncate">{adj.label}</div>
</div>
<div className="display num text-[13px] tabular-nums whitespace-nowrap text-muted-foreground">
<div className="display mono text-[12.5px] tabular-nums whitespace-nowrap text-muted-foreground">
{fmt.usdPrecise(adj.amount)}
</div>
</div>
@@ -62,9 +63,6 @@ export function Remittances() {
const [page, setPage] = useState(1);
const [status, setStatus] = useState<RemittanceStatus | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
// Row navigation (j/k) lives in parallel with `expanded`; selection
// is purely a focus-state indicator and does NOT auto-expand the
// row (so j/k doesn't fight the existing click-to-expand gesture).
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [helpOpen, setHelpOpen] = useState(false);
@@ -75,13 +73,6 @@ export function Remittances() {
offset: (page - 1) * PAGE_SIZE,
});
// SP5 live-tail wiring (sub-project 5, Phase 5 Task 23). The tail
// stream emits every remittance that lands — including ones that
// don't match the user's current `status` filter chip. Drop those
// tail items so a newly-arrived remit that wouldn't match the page's
// intent doesn't flash into the table. Base items are NOT filtered
// here (they reflect whatever the list query returned, matching the
// existing page behavior).
const tailFilterFn = useMemo(
() => (r: Remittance) => {
if (status && r.status !== status) return false;
@@ -123,15 +114,10 @@ export function Remittances() {
setSelectedIndex((i) => {
if (items.length === 0) return null;
if (i === null) return items.length - 1;
// Add items.length before the modulo so the negative index
// (first row → wrap to last) wraps correctly.
return (i - 1 + items.length) % items.length;
});
}, [items.length]);
// `enabled: !helpOpen` so j/k yields to the cheatsheet while it's
// open (the cheatsheet's own dismiss listener still closes it on
// any non-`?` keypress). See Acks.tsx for the full rationale.
useRowKeyboard({
enabled: !helpOpen && items.length > 0,
onNext: moveNext,
@@ -146,17 +132,19 @@ export function Remittances() {
open={helpOpen}
onClose={() => setHelpOpen(false)}
/>
<div className="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" />
Remittances
</div>
<h1 className="text-[22px] font-semibold tracking-tight">835 remittances</h1>
<p className="text-muted-foreground mt-1.5 text-[14px]">
Electronic remittance advice from payers, matched to submitted claims.
</p>
</header>
<div className="space-y-6 lg:space-y-8 animate-fade-in">
<PageHeader
eyebrow="Remittances"
title={<>835 <span className="display italic text-muted-foreground">remittances</span></>}
subtitle="Electronic remittance advice from payers, matched to submitted claims."
actions={
<TailStatusPill
status={tailStatus}
lastEventAt={tailLastEventAt}
onReconnect={forceReconnect}
/>
}
/>
{isError ? (
<ErrorState
@@ -166,47 +154,37 @@ export function Remittances() {
/>
) : null}
<div className="grid gap-4 grid-cols-2 lg:grid-cols-3">
<div className="surface rounded-xl p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
Remits
<div className="grid gap-3.5 grid-cols-2 lg:grid-cols-3">
<div className="surface-2 rounded-xl p-5">
<div className="eyebrow">Remits</div>
<div className="display mono text-[28px] leading-none mt-3 text-foreground">
{fmt.num(data?.total ?? 0)}
</div>
<div className="display text-[28px] leading-none mt-3">{fmt.num(data?.total ?? 0)}</div>
</div>
<div className="surface rounded-xl p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
Total paid
<div className="surface-2 rounded-xl p-5">
<div className="eyebrow">Total paid</div>
<div className="display mono text-[28px] leading-none mt-3 text-[hsl(var(--success))]">
{fmt.usd(total.paid)}
</div>
<div className="display text-[28px] leading-none mt-3">{fmt.usd(total.paid)}</div>
</div>
<div className="surface rounded-xl p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
Adjustments
<div className="surface-2 rounded-xl p-5">
<div className="eyebrow">Adjustments</div>
<div className="display mono text-[28px] leading-none mt-3 text-[hsl(var(--warning))]">
{fmt.usd(total.adjustments)}
</div>
<div className="display text-[28px] leading-none mt-3">{fmt.usd(total.adjustments)}</div>
</div>
</div>
<div className="surface rounded-xl p-4">
<div className="flex flex-wrap items-center gap-3">
<FilterChips
options={STATUS_OPTIONS}
value={status}
onChange={(v) => {
setStatus((v as RemittanceStatus | null) ?? null);
setPage(1);
}}
eyebrow="Status"
/>
{/* SP5: live-tail status pill, right-aligned in the toolbar. */}
<div className="ml-auto">
<TailStatusPill
status={tailStatus}
lastEventAt={tailLastEventAt}
onReconnect={forceReconnect}
/>
</div>
</div>
<FilterChips
options={STATUS_OPTIONS}
value={status}
onChange={(v) => {
setStatus((v as RemittanceStatus | null) ?? null);
setPage(1);
}}
eyebrow="Status"
/>
</div>
<div className="surface rounded-xl overflow-hidden">
@@ -251,12 +229,6 @@ export function Remittances() {
className={cn(
"animate-row-flash",
isSelected && [
// Same accent-tinted highlight as Acks:
// background tint + inset ring + left
// accent strip, layered on top of the
// TableRow primitive's
// `data-[state=selected]:bg-muted` to
// win visually.
"bg-accent/10 ring-1 ring-inset ring-accent/40 shadow-[inset_2px_0_0_0_hsl(var(--accent))]",
],
)}
@@ -283,28 +255,28 @@ export function Remittances() {
)
) : null}
</TableCell>
<TableCell className="display num text-[13px]">{r.id}</TableCell>
<TableCell className="display num text-[13px] text-muted-foreground">
<TableCell className="display mono text-[12.5px]">{r.id}</TableCell>
<TableCell className="display mono text-[12.5px] text-muted-foreground">
{r.claimId}
</TableCell>
<TableCell>{r.payerName}</TableCell>
<TableCell className="text-right display num">
<TableCell className="text-right display mono">
{fmt.usdPrecise(r.paidAmount)}
</TableCell>
<TableCell className="text-right display num text-muted-foreground">
<TableCell className="text-right display mono text-muted-foreground">
{r.adjustmentAmount > 0 ? fmt.usdPrecise(r.adjustmentAmount) : "—"}
</TableCell>
<TableCell>
<RemitStatusBadge status={r.status} />
</TableCell>
<TableCell className="text-muted-foreground num text-[13px]">
<TableCell className="text-muted-foreground mono text-[12px]">
{fmt.dateShort(r.receivedDate)}
</TableCell>
</TableRow>
{isOpen && hasAdjustments ? (
<TableRow
key={`${r.id}-${dataUpdatedAt}-detail`}
className="bg-muted/30 hover:bg-muted/30"
className="bg-muted/20 hover:bg-muted/20"
>
<TableCell />
<TableCell colSpan={7} className="py-3">
@@ -314,7 +286,7 @@ export function Remittances() {
strokeWidth={1.5}
aria-hidden
/>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
<div className="eyebrow">
Adjustments ({r.adjustments!.length})
</div>
</div>
@@ -348,4 +320,4 @@ export function Remittances() {
</div>
</>
);
}
}
+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>
);