fbe9940a3f
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.
324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
import { Fragment, useCallback, useMemo, useState } from "react";
|
|
import { ChevronDown, ChevronRight, Receipt } from "lucide-react";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { RemitStatusBadge } from "@/components/StatusBadge";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
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 { 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 { fmt } from "@/lib/format";
|
|
import { cn } from "@/lib/utils";
|
|
import type { CasAdjustment, Remittance, RemittanceStatus } from "@/types";
|
|
|
|
const PAGE_SIZE = 25;
|
|
|
|
const STATUS_OPTIONS: FilterChipOption[] = [
|
|
{ value: "received", label: "Received" },
|
|
{ value: "posted", label: "Posted" },
|
|
{ value: "reconciled", label: "Reconciled" },
|
|
];
|
|
|
|
/**
|
|
* One persisted CAS row, rendered as a "code — label" pair plus the
|
|
* dollar amount. Lives inside the expanded detail row of a remit so
|
|
* the operator can see exactly why the payer adjusted the claim.
|
|
*/
|
|
function AdjustmentRow({ adj }: { adj: CasAdjustment }) {
|
|
return (
|
|
<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="mono text-[10.5px] text-muted-foreground">
|
|
{adj.group}-{adj.reason}
|
|
{adj.quantity !== null ? (
|
|
<span className="ml-2 text-muted-foreground/60">
|
|
qty {adj.quantity}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<div className="text-[12.5px] text-foreground/90 truncate">{adj.label}</div>
|
|
</div>
|
|
<div className="display mono text-[12.5px] tabular-nums whitespace-nowrap text-muted-foreground">
|
|
{fmt.usdPrecise(adj.amount)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Remittances() {
|
|
const [page, setPage] = useState(1);
|
|
const [status, setStatus] = useState<RemittanceStatus | null>(null);
|
|
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
|
const [helpOpen, setHelpOpen] = useState(false);
|
|
|
|
const { data, isLoading, isError, error, refetch, dataUpdatedAt } = useRemittances({
|
|
sort: "receivedDate",
|
|
order: "desc",
|
|
limit: PAGE_SIZE,
|
|
offset: (page - 1) * PAGE_SIZE,
|
|
});
|
|
|
|
const tailFilterFn = useMemo(
|
|
() => (r: Remittance) => {
|
|
if (status && r.status !== status) return false;
|
|
return true;
|
|
},
|
|
[status],
|
|
);
|
|
|
|
const { status: tailStatus, lastEventAt: tailLastEventAt, forceReconnect } =
|
|
useTailStream("remittances");
|
|
const items = useMergedTail("remittances", data?.items ?? [], tailFilterFn);
|
|
|
|
const total = items.reduce(
|
|
(acc, r) => ({
|
|
paid: acc.paid + r.paidAmount,
|
|
adjustments: acc.adjustments + r.adjustmentAmount,
|
|
}),
|
|
{ paid: 0, adjustments: 0 }
|
|
);
|
|
|
|
const toggleExpand = (id: string) => {
|
|
setExpanded((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const moveNext = useCallback(() => {
|
|
setSelectedIndex((i) => {
|
|
if (items.length === 0) return null;
|
|
if (i === null) return 0;
|
|
return (i + 1) % items.length;
|
|
});
|
|
}, [items.length]);
|
|
|
|
const movePrev = useCallback(() => {
|
|
setSelectedIndex((i) => {
|
|
if (items.length === 0) return null;
|
|
if (i === null) return items.length - 1;
|
|
return (i - 1 + items.length) % items.length;
|
|
});
|
|
}, [items.length]);
|
|
|
|
useRowKeyboard({
|
|
enabled: !helpOpen && items.length > 0,
|
|
onNext: moveNext,
|
|
onPrev: movePrev,
|
|
onClose: () => setHelpOpen(false),
|
|
onToggleHelp: () => setHelpOpen((v) => !v),
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<KeyboardCheatsheet
|
|
open={helpOpen}
|
|
onClose={() => setHelpOpen(false)}
|
|
/>
|
|
<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
|
|
message="Couldn't load remittances from the backend."
|
|
detail={error instanceof Error ? error.message : String(error)}
|
|
onRetry={() => refetch()}
|
|
/>
|
|
) : null}
|
|
|
|
<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>
|
|
<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>
|
|
<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>
|
|
</div>
|
|
|
|
<div className="surface rounded-xl p-4">
|
|
<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">
|
|
{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="Remittances · awaiting first 835"
|
|
message="Drop an 835 file on the Upload page to populate this list."
|
|
/>
|
|
) : (
|
|
<>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-8" aria-label="Expand" />
|
|
<TableHead>Remit</TableHead>
|
|
<TableHead>Claim</TableHead>
|
|
<TableHead>Payer</TableHead>
|
|
<TableHead className="text-right">Paid</TableHead>
|
|
<TableHead className="text-right">Adjustment</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Received</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{items.map((r, idx) => {
|
|
const isOpen = expanded.has(r.id);
|
|
const hasAdjustments =
|
|
!!r.adjustments && r.adjustments.length > 0;
|
|
const isSelected = selectedIndex === idx;
|
|
return (
|
|
<Fragment key={`${r.id}-${dataUpdatedAt}`}>
|
|
<TableRow
|
|
data-row-index={idx}
|
|
data-state={isSelected ? "selected" : undefined}
|
|
aria-selected={isSelected}
|
|
className={cn(
|
|
"animate-row-flash",
|
|
isSelected && [
|
|
"bg-accent/10 ring-1 ring-inset ring-accent/40 shadow-[inset_2px_0_0_0_hsl(var(--accent))]",
|
|
],
|
|
)}
|
|
onClick={() =>
|
|
hasAdjustments ? toggleExpand(r.id) : undefined
|
|
}
|
|
aria-expanded={hasAdjustments ? isOpen : undefined}
|
|
style={{ cursor: hasAdjustments ? "pointer" : undefined }}
|
|
>
|
|
<TableCell className="text-muted-foreground">
|
|
{hasAdjustments ? (
|
|
isOpen ? (
|
|
<ChevronDown
|
|
className="h-3.5 w-3.5"
|
|
strokeWidth={1.75}
|
|
aria-hidden
|
|
/>
|
|
) : (
|
|
<ChevronRight
|
|
className="h-3.5 w-3.5"
|
|
strokeWidth={1.75}
|
|
aria-hidden
|
|
/>
|
|
)
|
|
) : null}
|
|
</TableCell>
|
|
<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 mono">
|
|
{fmt.usdPrecise(r.paidAmount)}
|
|
</TableCell>
|
|
<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 mono text-[12px]">
|
|
{fmt.dateShort(r.receivedDate)}
|
|
</TableCell>
|
|
</TableRow>
|
|
{isOpen && hasAdjustments ? (
|
|
<TableRow
|
|
key={`${r.id}-${dataUpdatedAt}-detail`}
|
|
className="bg-muted/20 hover:bg-muted/20"
|
|
>
|
|
<TableCell />
|
|
<TableCell colSpan={7} className="py-3">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Receipt
|
|
className="h-3.5 w-3.5 text-muted-foreground"
|
|
strokeWidth={1.5}
|
|
aria-hidden
|
|
/>
|
|
<div className="eyebrow">
|
|
Adjustments ({r.adjustments!.length})
|
|
</div>
|
|
</div>
|
|
<div className="pl-5">
|
|
{r.adjustments!.map((adj, i) => (
|
|
<AdjustmentRow
|
|
key={`${adj.group}-${adj.reason}-${i}`}
|
|
adj={adj}
|
|
/>
|
|
))}
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : null}
|
|
</Fragment>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
<div className="px-4 pb-4">
|
|
<Pagination
|
|
page={page}
|
|
pageSize={PAGE_SIZE}
|
|
total={data?.total ?? 0}
|
|
onPageChange={setPage}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|