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:
+15
-38
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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