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,30 +15,49 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
const kindConfig: Record<
|
||||
Activity["kind"],
|
||||
{ icon: LucideIcon; tone: string }
|
||||
{ icon: LucideIcon; tone: string; tint: string }
|
||||
> = {
|
||||
claim_submitted: { icon: FileText, tone: "text-[hsl(var(--accent))]" },
|
||||
claim_accepted: { icon: CheckCircle2, tone: "text-[hsl(var(--success))]" },
|
||||
claim_paid: { icon: Banknote, tone: "text-[hsl(var(--success))]" },
|
||||
claim_denied: { icon: XCircle, tone: "text-destructive" },
|
||||
remit_received: { icon: Pill, tone: "text-[hsl(var(--warning))]" },
|
||||
provider_added: { icon: UserPlus, tone: "text-foreground" },
|
||||
// `manual_match` is emitted by the backend when an operator manually
|
||||
// pairs a remit to a claim from the Reconciliation page — see
|
||||
// `cyclone.store` `record_manual_match`. Wrench is the closest fit;
|
||||
// we keep it neutral-tone so it doesn't read as a status.
|
||||
manual_match: { icon: Wrench, tone: "text-muted-foreground" },
|
||||
claim_submitted: {
|
||||
icon: FileText,
|
||||
tone: "text-[hsl(var(--accent))]",
|
||||
tint: "bg-accent/10",
|
||||
},
|
||||
claim_accepted: {
|
||||
icon: CheckCircle2,
|
||||
tone: "text-[hsl(var(--success))]",
|
||||
tint: "bg-[hsl(var(--success)/0.12)]",
|
||||
},
|
||||
claim_paid: {
|
||||
icon: Banknote,
|
||||
tone: "text-[hsl(var(--success))]",
|
||||
tint: "bg-[hsl(var(--success)/0.12)]",
|
||||
},
|
||||
claim_denied: {
|
||||
icon: XCircle,
|
||||
tone: "text-destructive",
|
||||
tint: "bg-destructive/12",
|
||||
},
|
||||
remit_received: {
|
||||
icon: Pill,
|
||||
tone: "text-[hsl(var(--warning))]",
|
||||
tint: "bg-[hsl(var(--warning)/0.12)]",
|
||||
},
|
||||
provider_added: {
|
||||
icon: UserPlus,
|
||||
tone: "text-foreground",
|
||||
tint: "bg-muted/60",
|
||||
},
|
||||
manual_match: {
|
||||
icon: Wrench,
|
||||
tone: "text-muted-foreground",
|
||||
tint: "bg-muted/40",
|
||||
},
|
||||
};
|
||||
|
||||
// Defensive fallback: if the backend ever emits a kind this UI doesn't
|
||||
// know about (e.g. a new kind landed server-side before the frontend
|
||||
// picked it up), render a neutral icon instead of crashing the whole
|
||||
// Activity Log page. The kindConfig map above stays exhaustive for
|
||||
// known kinds so TypeScript still catches missing entries at compile
|
||||
// time; this only protects the runtime path.
|
||||
const FALLBACK_KIND: { icon: LucideIcon; tone: string } = {
|
||||
const FALLBACK_KIND: { icon: LucideIcon; tone: string; tint: string } = {
|
||||
icon: Circle,
|
||||
tone: "text-muted-foreground",
|
||||
tint: "bg-muted/40",
|
||||
};
|
||||
|
||||
export function ActivityFeed({
|
||||
@@ -50,7 +69,7 @@ export function ActivityFeed({
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground px-2 py-6 text-center">
|
||||
<div className="text-[13px] text-muted-foreground px-2 py-6 text-center">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
);
|
||||
@@ -67,28 +86,29 @@ export function ActivityFeed({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 h-7 w-7 shrink-0 rounded-md bg-muted/50 flex items-center justify-center",
|
||||
"mt-0.5 h-7 w-7 shrink-0 rounded-md ring-1 ring-inset ring-border/40 flex items-center justify-center",
|
||||
cfg.tint,
|
||||
cfg.tone
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-foreground/95 leading-snug">
|
||||
<div className="text-[13px] text-foreground/95 leading-snug">
|
||||
{a.message}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5 flex items-center gap-2">
|
||||
<div className="mono text-[10.5px] text-muted-foreground mt-0.5 flex items-center gap-2">
|
||||
<span>{fmt.relative(a.timestamp)}</span>
|
||||
{a.npi ? (
|
||||
<>
|
||||
<span className="opacity-40">·</span>
|
||||
<span className="num">{a.npi}</span>
|
||||
<span>{a.npi}</span>
|
||||
</>
|
||||
) : null}
|
||||
{a.amount ? (
|
||||
<>
|
||||
<span className="opacity-40">·</span>
|
||||
<span className="num">{fmt.usd(a.amount)}</span>
|
||||
<span>{fmt.usd(a.amount)}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useClaimDetail } from "@/hooks/useClaimDetail";
|
||||
import { useDrawerKeyboard } from "@/hooks/useDrawerKeyboard";
|
||||
import { useLineReconciliation } from "@/hooks/useLineReconciliation";
|
||||
@@ -145,7 +146,7 @@ export function ClaimDrawer({
|
||||
// `aria-describedby={undefined}` suppresses the Radix warning
|
||||
// about a missing description — the drawer content is its own
|
||||
// description and there's nothing useful to point at.
|
||||
className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-[color:var(--m-border-heavy)] bg-[color:var(--m-surface)] p-0"
|
||||
className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-[color:var(--m-border-heavy)]/60 bg-[color:var(--m-surface)] p-0 shadow-[-24px_0_60px_-12px_rgba(0,0,0,0.6)]"
|
||||
data-testid="claim-drawer"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
@@ -166,47 +167,42 @@ export function ClaimDrawer({
|
||||
>
|
||||
<ClaimDrawerHeader claim={data} onClose={onClose} />
|
||||
<div
|
||||
className="flex gap-2 px-6 pt-4 border-b"
|
||||
style={{ borderColor: "var(--tt-bg)" }}
|
||||
className="flex gap-0 px-6 border-b border-[color:var(--surface-line)]/40"
|
||||
data-testid="claim-drawer-tabs"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("details")}
|
||||
className="font-mono text-xs uppercase tracking-wider px-3 py-2"
|
||||
style={{
|
||||
color:
|
||||
activeTab === "details"
|
||||
? "var(--tt-ink, currentColor)"
|
||||
: "var(--tt-ink-dim, rgba(255,255,255,0.5))",
|
||||
borderBottom:
|
||||
activeTab === "details"
|
||||
? "2px solid var(--tt-amber)"
|
||||
: "2px solid transparent",
|
||||
}}
|
||||
className={cn(
|
||||
"relative px-3 py-2.5 text-[10.5px] font-semibold uppercase tracking-[0.18em] transition-colors",
|
||||
activeTab === "details"
|
||||
? "text-[color:var(--surface-ink)]"
|
||||
: "text-[color:var(--surface-ink-3)] hover:text-[color:var(--surface-ink-2)]"
|
||||
)}
|
||||
data-testid="tab-button-details"
|
||||
data-active={activeTab === "details" ? "true" : "false"}
|
||||
>
|
||||
Details
|
||||
{activeTab === "details" ? (
|
||||
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-[color:var(--accent)]" />
|
||||
) : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("line-reconciliation")}
|
||||
className="font-mono text-xs uppercase tracking-wider px-3 py-2"
|
||||
style={{
|
||||
color:
|
||||
activeTab === "line-reconciliation"
|
||||
? "var(--tt-ink, currentColor)"
|
||||
: "var(--tt-ink-dim, rgba(255,255,255,0.5))",
|
||||
borderBottom:
|
||||
activeTab === "line-reconciliation"
|
||||
? "2px solid var(--tt-amber)"
|
||||
: "2px solid transparent",
|
||||
}}
|
||||
className={cn(
|
||||
"relative px-3 py-2.5 text-[10.5px] font-semibold uppercase tracking-[0.18em] transition-colors",
|
||||
activeTab === "line-reconciliation"
|
||||
? "text-[color:var(--surface-ink)]"
|
||||
: "text-[color:var(--surface-ink-3)] hover:text-[color:var(--surface-ink-2)]"
|
||||
)}
|
||||
data-testid="tab-button-line-reconciliation"
|
||||
data-active={activeTab === "line-reconciliation" ? "true" : "false"}
|
||||
>
|
||||
Line Reconciliation
|
||||
{activeTab === "line-reconciliation" ? (
|
||||
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-[color:var(--accent)]" />
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
{activeTab === "line-reconciliation" ? (
|
||||
@@ -228,7 +224,7 @@ export function ClaimDrawer({
|
||||
<LineReconciliationTab data={lr.data} />
|
||||
) : null
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-[color:var(--m-border-heavy)]/15">
|
||||
<div className="flex flex-col divide-y divide-[color:var(--surface-line)]/12">
|
||||
<ValidationPanel validation={data.validation} />
|
||||
<ServiceLinesTable
|
||||
serviceLines={data.serviceLines}
|
||||
|
||||
@@ -42,11 +42,11 @@ export function ClaimDrawerError({ kind, onRetry, onClose }: ClaimDrawerErrorPro
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-error)]">
|
||||
<span className="eyebrow text-[color:var(--m-error)]">
|
||||
{eyebrow}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[color:var(--m-ink-secondary)]">{message}</p>
|
||||
<p className="text-sm text-[color:var(--m-ink-secondary)] max-w-sm">{message}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{kind === "network" && onRetry ? (
|
||||
<Button variant="outline" size="sm" onClick={onRetry} data-testid="error-retry">
|
||||
|
||||
@@ -166,17 +166,18 @@ describe("ClaimDrawerHeader", () => {
|
||||
stateLabel: string;
|
||||
expectedToken: string;
|
||||
}> = [
|
||||
// secondary → bg-muted text-muted-foreground
|
||||
{ state: "submitted", stateLabel: "Submitted", expectedToken: "bg-muted text-muted-foreground" },
|
||||
// secondary → bg-muted/80 text-muted-foreground
|
||||
// (the polished muted treatment uses /80 for a slightly subtler tone)
|
||||
{ state: "submitted", stateLabel: "Submitted", expectedToken: "bg-muted/80 text-muted-foreground" },
|
||||
// default → bg-primary/15 text-primary
|
||||
{ state: "accepted", stateLabel: "Accepted", expectedToken: "bg-primary/15" },
|
||||
// destructive → bg-destructive/15
|
||||
{ state: "denied", stateLabel: "Denied", expectedToken: "bg-destructive/15" },
|
||||
// success → bg-[hsl(var(--success)/0.15)] (covered by both matched and paid)
|
||||
{ state: "matched", stateLabel: "Matched", expectedToken: "bg-[hsl(var(--success)/0.15)]" },
|
||||
{ state: "paid", stateLabel: "Paid", expectedToken: "bg-[hsl(var(--success)/0.15)]" },
|
||||
// warning → bg-[hsl(var(--warning)/0.15)]
|
||||
{ state: "pending", stateLabel: "Pending", expectedToken: "bg-[hsl(var(--warning)/0.15)]" },
|
||||
// success → bg-[hsl(var(--success)/0.14)] (covered by both matched and paid)
|
||||
{ state: "matched", stateLabel: "Matched", expectedToken: "bg-[hsl(var(--success)/0.14)]" },
|
||||
{ state: "paid", stateLabel: "Paid", expectedToken: "bg-[hsl(var(--success)/0.14)]" },
|
||||
// warning → bg-[hsl(var(--warning)/0.14)]
|
||||
{ state: "pending", stateLabel: "Pending", expectedToken: "bg-[hsl(var(--warning)/0.14)]" },
|
||||
];
|
||||
|
||||
for (const c of cases) {
|
||||
|
||||
@@ -84,20 +84,19 @@ export function ClaimDrawerHeader({
|
||||
<header
|
||||
className={cn(
|
||||
"flex items-start justify-between gap-4 px-6 py-5",
|
||||
"border-b border-[color:var(--m-border-heavy)]",
|
||||
"border-b border-[color:var(--m-border-heavy)]/40",
|
||||
"bg-[color:var(--m-surface)]"
|
||||
)}
|
||||
data-testid="claim-drawer-header"
|
||||
>
|
||||
{/* Left: eyebrow + mono claim ID */}
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]">
|
||||
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
|
||||
Claim
|
||||
</span>
|
||||
<span
|
||||
data-testid="header-id"
|
||||
className="font-mono text-2xl font-semibold tracking-tight text-[color:var(--m-ink-primary)]"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
className="mono text-2xl font-semibold tracking-tight text-[color:var(--m-ink-primary)]"
|
||||
>
|
||||
{claim.id}
|
||||
</span>
|
||||
@@ -115,8 +114,7 @@ export function ClaimDrawerHeader({
|
||||
</Badge>
|
||||
<span
|
||||
data-testid="header-amount"
|
||||
className="font-mono text-lg tabular-nums text-[color:var(--m-ink-primary)]"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
className="mono text-lg tabular-nums text-[color:var(--m-ink-primary)]"
|
||||
>
|
||||
{fmt.usdPrecise(claim.billedAmount)}
|
||||
</span>
|
||||
|
||||
@@ -62,9 +62,10 @@ describe("DiagnosesList", () => {
|
||||
expect(text).toContain("(3)");
|
||||
|
||||
// Eyebrow style (uppercase + tracking) — sniff the className.
|
||||
// The shared `.eyebrow` class in index.css consolidates the
|
||||
// eyebrow treatment; tests assert it lands on the section label.
|
||||
const cls = label?.className ?? "";
|
||||
expect(cls).toContain("uppercase");
|
||||
expect(cls).toContain("tracking-[0.18em]");
|
||||
expect(cls).toContain("eyebrow");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@ export function DiagnosesList({ diagnoses }: DiagnosesListProps) {
|
||||
<section className="flex flex-col gap-3 px-6 py-4">
|
||||
<h3
|
||||
data-testid="section-label"
|
||||
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
|
||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
||||
>
|
||||
Diagnoses ({diagnoses.length})
|
||||
</h3>
|
||||
@@ -55,18 +55,23 @@ export function DiagnosesList({ diagnoses }: DiagnosesListProps) {
|
||||
<li
|
||||
key={`${d.code}-${i}`}
|
||||
data-testid="diagnosis-item"
|
||||
className="flex items-baseline gap-2 text-sm"
|
||||
className="flex items-baseline gap-3 text-sm"
|
||||
>
|
||||
<span
|
||||
className="font-mono text-[color:var(--m-ink-primary)]"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
className="mono text-[color:var(--m-ink-primary)] font-medium"
|
||||
>
|
||||
{d.qualifier ? `${d.qualifier} ${d.code}` : d.code}
|
||||
</span>
|
||||
{desc ? (
|
||||
<span className="text-[color:var(--m-ink-secondary)]">
|
||||
{desc}
|
||||
</span>
|
||||
<>
|
||||
<span
|
||||
className="h-px w-3 bg-[color:var(--m-border-heavy)]/30"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-[color:var(--m-ink-secondary)] italic">
|
||||
{desc}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -26,31 +26,37 @@ export function LineReconciliationTab({
|
||||
return (
|
||||
<section className="flex flex-col gap-4 px-6 py-4">
|
||||
<header
|
||||
className="flex items-baseline justify-between"
|
||||
className="flex items-baseline justify-between flex-wrap gap-3"
|
||||
data-testid="line-reconciliation-summary"
|
||||
>
|
||||
<div className="flex gap-6 font-mono tabular-nums text-sm">
|
||||
<span data-testid="billed-total">
|
||||
<span className="text-[color:var(--m-ink-tertiary)] text-xs uppercase mr-2">
|
||||
<div className="flex gap-5 font-mono tabular-nums text-sm">
|
||||
<span data-testid="billed-total" className="flex flex-col gap-0.5">
|
||||
<span className="text-[color:var(--m-ink-tertiary)] eyebrow">
|
||||
Billed
|
||||
</span>
|
||||
{fmt.usdPrecise(Number(summary.billedTotal))}
|
||||
<span className="text-[color:var(--m-ink-primary)] font-semibold">
|
||||
{fmt.usdPrecise(Number(summary.billedTotal))}
|
||||
</span>
|
||||
</span>
|
||||
<span data-testid="paid-total">
|
||||
<span className="text-[color:var(--m-ink-tertiary)] text-xs uppercase mr-2">
|
||||
<span data-testid="paid-total" className="flex flex-col gap-0.5">
|
||||
<span className="text-[color:var(--m-ink-tertiary)] eyebrow">
|
||||
Paid
|
||||
</span>
|
||||
{fmt.usdPrecise(Number(summary.paidTotal))}
|
||||
<span className="text-[color:var(--m-ink-primary)] font-semibold">
|
||||
{fmt.usdPrecise(Number(summary.paidTotal))}
|
||||
</span>
|
||||
</span>
|
||||
<span data-testid="adjustments-total">
|
||||
<span className="text-[color:var(--m-ink-tertiary)] text-xs uppercase mr-2">
|
||||
<span data-testid="adjustments-total" className="flex flex-col gap-0.5">
|
||||
<span className="text-[color:var(--m-ink-tertiary)] eyebrow">
|
||||
Adjustments
|
||||
</span>
|
||||
{fmt.usdPrecise(Number(summary.adjustmentTotal))}
|
||||
<span className="text-[color:var(--m-ink-primary)] font-semibold">
|
||||
{fmt.usdPrecise(Number(summary.adjustmentTotal))}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="font-mono text-xs uppercase tracking-wider"
|
||||
className="font-mono text-xs uppercase tracking-[0.14em]"
|
||||
style={{
|
||||
color: allMatched ? "var(--tt-amber)" : "var(--tt-oxblood)",
|
||||
}}
|
||||
@@ -64,7 +70,7 @@ export function LineReconciliationTab({
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div data-testid="left-column">
|
||||
<h3 className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)] mb-2">
|
||||
<h3 className="eyebrow text-[color:var(--m-ink-tertiary)] mb-2">
|
||||
Billed (837 SV1)
|
||||
</h3>
|
||||
{leftRows.map((row) => (
|
||||
@@ -72,7 +78,7 @@ export function LineReconciliationTab({
|
||||
))}
|
||||
</div>
|
||||
<div data-testid="right-column">
|
||||
<h3 className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)] mb-2">
|
||||
<h3 className="eyebrow text-[color:var(--m-ink-tertiary)] mb-2">
|
||||
Adjudicated (835 SVC)
|
||||
</h3>
|
||||
{rightRows.map((row) => (
|
||||
@@ -108,7 +114,7 @@ function LineCard({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-1 py-2 px-3 mb-2"
|
||||
className="flex flex-col gap-1 py-2 px-3 mb-2 rounded-r-md transition-colors hover:bg-[color:var(--m-ink-tertiary)]/5"
|
||||
style={{
|
||||
borderLeft: `3px solid ${accentColor}`,
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
@@ -117,7 +123,7 @@ function LineCard({
|
||||
>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="font-mono text-sm">
|
||||
<span className="font-semibold">{procCode}</span>
|
||||
<span className="font-semibold text-[color:var(--m-ink-primary)]">{procCode}</span>
|
||||
{lineNumber !== null ? (
|
||||
<span className="text-[color:var(--m-ink-tertiary)] ml-2">
|
||||
#{lineNumber}
|
||||
@@ -140,7 +146,7 @@ function LineCard({
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="font-mono tabular-nums text-xs">
|
||||
<div className="font-mono tabular-nums text-xs text-[color:var(--m-ink-primary)] font-semibold">
|
||||
{side === "left" && cl ? <span>{fmt.usdPrecise(Number(cl.charge))}</span> : null}
|
||||
{side === "right" && svc ? <span>{fmt.usdPrecise(Number(svc.payment))}</span> : null}
|
||||
</div>
|
||||
@@ -149,7 +155,8 @@ function LineCard({
|
||||
<ul className="text-xs font-mono text-[color:var(--m-ink-tertiary)] mt-1 space-y-0.5">
|
||||
{row.adjustments.map((a, i) => (
|
||||
<li key={i}>
|
||||
{a.groupCode} · {a.reasonCode} · {fmt.usdPrecise(Number(a.amount))}
|
||||
<span className="text-[color:var(--m-ink-secondary)]">{a.groupCode}-{a.reasonCode}</span>
|
||||
<span className="ml-2 tabular-nums">{fmt.usdPrecise(Number(a.amount))}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -69,9 +69,10 @@ describe("MatchedRemitCard", () => {
|
||||
expect(text).toContain("matched remittance");
|
||||
|
||||
// Eyebrow style (uppercase + tracking) — sniff the className.
|
||||
// The shared `.eyebrow` class in index.css consolidates the
|
||||
// eyebrow treatment; tests assert it lands on the section label.
|
||||
const cls = label?.className ?? "";
|
||||
expect(cls).toContain("uppercase");
|
||||
expect(cls).toContain("tracking-[0.18em]");
|
||||
expect(cls).toContain("eyebrow");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -56,21 +56,20 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
|
||||
>
|
||||
<h3
|
||||
data-testid="section-label"
|
||||
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
|
||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
||||
>
|
||||
Matched Remittance
|
||||
</h3>
|
||||
|
||||
<div
|
||||
data-testid="matched-remit-card-inner"
|
||||
className="flex flex-col gap-4 rounded-lg border border-[color:var(--m-border-heavy)]/15 bg-[color:var(--m-surface)] p-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
className="flex flex-col gap-4 rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/60 p-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
{/* Left: remit id + status badge + received date */}
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<span
|
||||
data-testid="matched-remit-id"
|
||||
className="font-mono text-sm text-[color:var(--m-ink-primary)]"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
className="mono text-sm text-[color:var(--m-ink-primary)]"
|
||||
>
|
||||
{id}
|
||||
</span>
|
||||
@@ -95,8 +94,7 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
|
||||
<div className="flex flex-col items-start gap-2 sm:items-end">
|
||||
<span
|
||||
data-testid="matched-remit-total-paid"
|
||||
className="font-mono text-2xl font-semibold tabular-nums text-[color:var(--m-ink-primary)]"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
className="display text-3xl text-[color:var(--m-ink-primary)] tabular-nums"
|
||||
>
|
||||
{fmt.usdPrecise(totalPaid)}
|
||||
</span>
|
||||
@@ -105,6 +103,7 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
|
||||
size="sm"
|
||||
onClick={handleViewRemittance}
|
||||
data-testid="view-remittance-link"
|
||||
className="gap-1 text-[color:var(--m-ink-secondary)] hover:text-[color:var(--m-ink-primary)]"
|
||||
>
|
||||
View remittance
|
||||
<ArrowRight
|
||||
@@ -120,7 +119,7 @@ export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) {
|
||||
<span
|
||||
data-testid="matched-remit-line-counts"
|
||||
data-all-matched={allMatched ? "true" : "false"}
|
||||
className="font-mono text-xs uppercase tracking-wider"
|
||||
className="mono text-xs uppercase tracking-[0.14em]"
|
||||
style={{ color: allMatched ? "var(--tt-amber)" : "var(--tt-oxblood)" }}
|
||||
>
|
||||
lines: {matchedLines}/{totalLines} matched
|
||||
|
||||
@@ -86,9 +86,10 @@ describe("PartiesGrid", () => {
|
||||
expect(text).toContain("parties");
|
||||
|
||||
// Eyebrow style (uppercase + tracking) — sniff the className.
|
||||
// The shared `.eyebrow` class in index.css consolidates the
|
||||
// eyebrow treatment; tests assert it lands on the section label.
|
||||
const cls = label?.className ?? "";
|
||||
expect(cls).toContain("uppercase");
|
||||
expect(cls).toContain("tracking-[0.18em]");
|
||||
expect(cls).toContain("eyebrow");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ function AddressBlock({ address }: { address: AddressLike }) {
|
||||
const a = address as ClaimDetailAddress;
|
||||
return (
|
||||
<div
|
||||
className="text-xs leading-relaxed text-[color:var(--m-ink-secondary)]"
|
||||
className="mt-1 border-t border-dashed border-[color:var(--m-border-heavy)]/30 pt-2 text-xs leading-relaxed text-[color:var(--m-ink-secondary)]"
|
||||
data-testid="party-address"
|
||||
>
|
||||
<div>{a.line1}</div>
|
||||
@@ -58,17 +58,16 @@ function PartyCard({
|
||||
return (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className="flex flex-col gap-2 rounded-lg border border-[color:var(--m-border-heavy)]/15 bg-[color:var(--m-surface)] px-4 py-3"
|
||||
className="flex flex-col gap-2 rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/60 px-4 py-3 transition-colors hover:border-[color:var(--m-border-heavy)]/50"
|
||||
>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
|
||||
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
|
||||
{label}
|
||||
</span>
|
||||
<div className="text-base font-medium text-[color:var(--m-ink-primary)]">
|
||||
<div className="display text-lg text-[color:var(--m-ink-primary)]">
|
||||
{name}
|
||||
</div>
|
||||
<div
|
||||
className="font-mono text-[13px] text-[color:var(--m-ink-secondary)] tabular-nums"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
className="mono text-[12.5px] text-[color:var(--m-ink-secondary)] tabular-nums"
|
||||
>
|
||||
{identity}
|
||||
</div>
|
||||
@@ -97,7 +96,7 @@ export function PartiesGrid({ parties }: PartiesGridProps) {
|
||||
>
|
||||
<h3
|
||||
data-testid="section-label"
|
||||
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
|
||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
||||
>
|
||||
Parties
|
||||
</h3>
|
||||
|
||||
@@ -52,9 +52,10 @@ describe("RawSegmentsPanel", () => {
|
||||
expect(text).toContain("(3)");
|
||||
|
||||
// Eyebrow style (uppercase + tracking) — sniff the className.
|
||||
// The shared `.eyebrow` class in index.css consolidates the
|
||||
// eyebrow treatment; tests assert it lands on the section label.
|
||||
const cls = label?.className ?? "";
|
||||
expect(cls).toContain("uppercase");
|
||||
expect(cls).toContain("tracking-[0.18em]");
|
||||
expect(cls).toContain("eyebrow");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -33,16 +33,17 @@ export function RawSegmentsPanel({ rawSegments }: RawSegmentsPanelProps) {
|
||||
>
|
||||
<h3
|
||||
data-testid="section-label"
|
||||
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
|
||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
||||
>
|
||||
Raw Segments ({rawSegments.length})
|
||||
</h3>
|
||||
|
||||
<details className="rounded-lg border border-[color:var(--m-border-heavy)]/15 bg-[color:var(--m-surface)]">
|
||||
<details className="group rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/40 overflow-hidden transition-colors hover:border-[color:var(--m-border-heavy)]/50">
|
||||
<summary
|
||||
className="cursor-pointer select-none px-4 py-2 text-sm text-[color:var(--m-ink-secondary)] hover:text-[color:var(--m-ink-primary)]"
|
||||
className="cursor-pointer select-none px-4 py-2.5 text-sm text-[color:var(--m-ink-secondary)] hover:text-[color:var(--m-ink-primary)] transition-colors [&::-webkit-details-marker]:hidden flex items-center gap-2"
|
||||
data-testid="raw-segments-summary"
|
||||
>
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[color:var(--m-ink-tertiary)] group-open:bg-[color:var(--m-accent)] transition-colors" />
|
||||
{rawSegments.length === 0
|
||||
? `Show raw segments`
|
||||
: `Show ${rawSegments.length} raw segment${rawSegments.length === 1 ? "" : "s"}`}
|
||||
@@ -58,7 +59,7 @@ export function RawSegmentsPanel({ rawSegments }: RawSegmentsPanelProps) {
|
||||
) : (
|
||||
<pre
|
||||
data-testid="raw-segments-pre"
|
||||
className="overflow-x-auto whitespace-pre-wrap px-4 pb-3 pt-1 text-[12px] leading-relaxed text-[color:var(--m-ink-primary)] font-mono"
|
||||
className="overflow-x-auto whitespace-pre-wrap border-t border-[color:var(--m-border-heavy)]/20 px-4 pb-3 pt-3 text-[12px] leading-relaxed text-[color:var(--m-ink-primary)] font-mono"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
>
|
||||
{rawSegments.map((segment, i) => (
|
||||
|
||||
@@ -68,9 +68,10 @@ describe("ServiceLinesTable", () => {
|
||||
expect(text).toContain("(3)");
|
||||
|
||||
// Eyebrow style (uppercase + tracking) — sniff the className.
|
||||
// The shared `.eyebrow` class in index.css consolidates the
|
||||
// eyebrow treatment; tests assert it lands on the section label.
|
||||
const cls = label?.className ?? "";
|
||||
expect(cls).toContain("uppercase");
|
||||
expect(cls).toContain("tracking-[0.18em]");
|
||||
expect(cls).toContain("eyebrow");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ export function ServiceLinesTable({
|
||||
<section className="flex flex-col gap-3 px-6 py-4">
|
||||
<h3
|
||||
data-testid="section-label"
|
||||
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
|
||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
||||
>
|
||||
Service Lines ({serviceLines.length})
|
||||
</h3>
|
||||
@@ -82,6 +82,7 @@ export function ServiceLinesTable({
|
||||
key={line.lineNumber}
|
||||
data-testid="service-line-row"
|
||||
data-line-number={line.lineNumber}
|
||||
className="row-hover"
|
||||
>
|
||||
<TableCell className="font-mono text-[color:var(--m-ink-secondary)]">
|
||||
{line.lineNumber}
|
||||
@@ -90,7 +91,7 @@ export function ServiceLinesTable({
|
||||
{formatProcedure(line)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="text-right font-mono text-base tabular-nums text-[color:var(--m-ink-primary)]"
|
||||
className="text-right font-mono text-base tabular-nums text-[color:var(--m-ink-primary)] font-semibold"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
>
|
||||
{fmt.usdPrecise(line.charge)}
|
||||
|
||||
@@ -65,9 +65,10 @@ describe("StateHistoryTimeline", () => {
|
||||
expect(text).toContain("(3)");
|
||||
|
||||
// Eyebrow style (uppercase + tracking) — sniff the className.
|
||||
// The shared `.eyebrow` class in index.css consolidates the
|
||||
// eyebrow treatment; tests assert it lands on the section label.
|
||||
const cls = label?.className ?? "";
|
||||
expect(cls).toContain("uppercase");
|
||||
expect(cls).toContain("tracking-[0.18em]");
|
||||
expect(cls).toContain("eyebrow");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -64,7 +64,7 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
|
||||
>
|
||||
<h3
|
||||
data-testid="section-label"
|
||||
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
|
||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
||||
>
|
||||
State History ({history.length})
|
||||
</h3>
|
||||
@@ -79,7 +79,7 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
|
||||
) : (
|
||||
<ol
|
||||
data-testid="state-history-timeline"
|
||||
className="relative ml-2 flex flex-col gap-3 border-l-2 border-[color:var(--m-border-heavy)] pl-5"
|
||||
className="relative ml-2 flex flex-col gap-3.5 border-l border-dashed border-[color:var(--m-border-heavy)]/40 pl-6"
|
||||
>
|
||||
{history.map((event, i) => (
|
||||
<li
|
||||
@@ -91,19 +91,18 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
|
||||
<span
|
||||
data-testid="state-history-dot"
|
||||
aria-hidden
|
||||
className={`absolute -left-[25px] top-1.5 h-2 w-2 rounded-full ${dotColorFor(event.kind)}`}
|
||||
className={`absolute -left-[29px] top-1.5 h-2.5 w-2.5 rounded-full ring-4 ring-[color:var(--m-surface)] ${dotColorFor(event.kind)}`}
|
||||
/>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span
|
||||
data-testid="state-history-kind"
|
||||
className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]"
|
||||
className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-primary)]"
|
||||
>
|
||||
{kindLabel(event.kind)}
|
||||
</span>
|
||||
<span
|
||||
data-testid="state-history-ts"
|
||||
className="font-mono text-xs text-[color:var(--m-ink-secondary)] tabular-nums"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
className="mono text-xs text-[color:var(--m-ink-secondary)] tabular-nums"
|
||||
>
|
||||
{fmt.date(event.ts)} · {fmt.time(event.ts)}
|
||||
</span>
|
||||
@@ -111,10 +110,9 @@ export function StateHistoryTimeline({ history }: StateHistoryTimelineProps) {
|
||||
{event.remittanceId ? (
|
||||
<span
|
||||
data-testid="history-remit-id"
|
||||
className="font-mono text-xs text-[color:var(--m-ink-tertiary)]"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
className="mono text-xs text-[color:var(--m-ink-tertiary)]"
|
||||
>
|
||||
Remit {event.remittanceId}
|
||||
↳ Remit {event.remittanceId}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
|
||||
@@ -48,16 +48,15 @@ function IssueGroup({
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="font-mono text-[12px] font-semibold tracking-tight text-[color:var(--m-ink-primary)]"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
className="mono text-[12px] font-semibold tracking-tight text-[color:var(--m-ink-primary)]"
|
||||
>
|
||||
{rule}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex items-center rounded-full bg-[color:var(--m-ink-tertiary)]/15 px-1.5 text-[10.5px] font-medium text-[color:var(--m-ink-secondary)] tabular-nums"
|
||||
className="inline-flex items-center rounded-full bg-[color:var(--m-ink-tertiary)]/15 px-1.5 py-0.5 text-[10px] font-medium text-[color:var(--m-ink-secondary)] tabular-nums"
|
||||
data-testid={`${testId}-count`}
|
||||
>
|
||||
({issues.length})
|
||||
{issues.length}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-1.5 pl-1">
|
||||
@@ -109,6 +108,9 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
|
||||
<span className="text-[13px] font-medium text-[color:var(--m-ink-primary)]">
|
||||
All checks passed
|
||||
</span>
|
||||
<span className="ml-auto inline-flex items-center rounded-full bg-[color:var(--m-success)]/12 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-[color:var(--m-success)]">
|
||||
Valid
|
||||
</span>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -118,10 +120,10 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col gap-3 px-6 py-4 bg-[color:var(--m-surface)]"
|
||||
className="flex flex-col gap-4 px-6 py-4 bg-[color:var(--m-surface)]"
|
||||
data-testid="validation-panel"
|
||||
>
|
||||
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]">
|
||||
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
|
||||
Validation
|
||||
</span>
|
||||
|
||||
@@ -130,7 +132,7 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
|
||||
data-testid="validation-errors"
|
||||
data-rule-group="errors"
|
||||
role="alert"
|
||||
className="flex flex-col gap-2 border-l-2 border-[color:var(--m-error)] bg-[color:var(--m-error-bg)] px-3 py-2.5"
|
||||
className="flex flex-col gap-3 border-l-2 border-[color:var(--m-error)] bg-[hsl(var(--destructive)/0.06)] px-3.5 py-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle
|
||||
@@ -159,7 +161,7 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
|
||||
<div
|
||||
data-testid="validation-warnings"
|
||||
data-rule-group="warnings"
|
||||
className="flex flex-col gap-2 border-l-2 border-[color:var(--m-warning)] bg-[color:var(--m-warning-bg)] px-3 py-2.5"
|
||||
className="flex flex-col gap-3 border-l-2 border-[color:var(--m-warning)] bg-[hsl(var(--warning)/0.08)] px-3.5 py-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from "react";
|
||||
import { Keyboard, X } from "lucide-react";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type KeyboardCheatsheetProps = {
|
||||
open: boolean;
|
||||
@@ -84,8 +85,8 @@ export function KeyboardCheatsheet({ open, onClose }: KeyboardCheatsheetProps) {
|
||||
<DialogContent
|
||||
// Centered modal (DialogContent's default positioning is
|
||||
// `left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2`).
|
||||
// We narrow it to max-w-md and apply the modern palette so
|
||||
// it sits on the same surface as the rest of SP4.
|
||||
// We narrow it to max-w-md and apply the warm-paper palette so
|
||||
// it sits on the same surface as the rest of the detail surfaces.
|
||||
//
|
||||
// a11y wiring:
|
||||
// - `aria-labelledby` points at the visible <h2> below so
|
||||
@@ -94,21 +95,27 @@ export function KeyboardCheatsheet({ open, onClose }: KeyboardCheatsheetProps) {
|
||||
// - `aria-describedby={undefined}` suppresses Radix's
|
||||
// missing-description warning; the cheatsheet's content
|
||||
// (key list + helper text) is its own description.
|
||||
className="max-w-md bg-[color:var(--m-surface)] text-[color:var(--m-ink-primary)] border border-[color:var(--m-border-heavy)]/20"
|
||||
className={cn(
|
||||
"max-w-md p-5",
|
||||
"bg-[color:var(--m-surface)] text-[color:var(--m-ink-primary)]",
|
||||
"border border-[color:var(--m-border-heavy)]/20",
|
||||
"shadow-[0_24px_64px_-12px_rgb(0_0_0_/_0.5),inset_0_1px_0_0_rgb(255_255_255_/_0.6)]"
|
||||
)}
|
||||
aria-labelledby={CHEATSHEET_TITLE_ID}
|
||||
aria-describedby={undefined}
|
||||
data-testid="keyboard-cheatsheet"
|
||||
>
|
||||
<header className="flex items-center justify-between gap-2 mb-3 pr-10">
|
||||
<header className="flex items-center justify-between gap-2 mb-4 pr-10 pb-3 border-b border-[color:var(--m-border-heavy)]/15">
|
||||
<div className="flex items-center gap-2">
|
||||
<Keyboard
|
||||
className="h-4 w-4 text-[color:var(--m-ink-secondary)]"
|
||||
strokeWidth={1.75}
|
||||
<span
|
||||
aria-hidden
|
||||
/>
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-[5px] bg-[color:var(--m-ink-primary)]/8 text-[color:var(--m-ink-secondary)]"
|
||||
>
|
||||
<Keyboard className="h-3 w-3" strokeWidth={2} />
|
||||
</span>
|
||||
<h2
|
||||
id={CHEATSHEET_TITLE_ID}
|
||||
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
|
||||
className="eyebrow text-[color:var(--m-ink-secondary)]"
|
||||
>
|
||||
Keyboard
|
||||
</h2>
|
||||
@@ -134,11 +141,11 @@ export function KeyboardCheatsheet({ open, onClose }: KeyboardCheatsheetProps) {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<ul className="flex flex-col gap-2.5">
|
||||
<ul className="flex flex-col gap-1">
|
||||
{SHORTCUTS.map((s) => (
|
||||
<li
|
||||
key={s.description}
|
||||
className="flex items-center justify-between gap-3 text-sm"
|
||||
className="flex items-center justify-between gap-3 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-[color:var(--m-ink-primary)]/[0.04]"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{s.keys.map((k) => (
|
||||
@@ -147,15 +154,15 @@ export function KeyboardCheatsheet({ open, onClose }: KeyboardCheatsheetProps) {
|
||||
</kbd>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[color:var(--m-ink-secondary)]">
|
||||
<span className="text-[13px] text-[color:var(--m-ink-primary)]">
|
||||
{s.description}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="mt-4 text-xs text-[color:var(--m-ink-tertiary)]">
|
||||
Press any other key to dismiss.
|
||||
<p className="mt-4 pt-3 border-t border-[color:var(--m-border-heavy)]/15 eyebrow text-[color:var(--m-ink-tertiary)]">
|
||||
Press any other key to dismiss
|
||||
</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
+38
-13
@@ -10,10 +10,19 @@ interface KpiCardProps {
|
||||
hint?: string;
|
||||
icon?: LucideIcon;
|
||||
sparkline?: number[];
|
||||
accent?: "default" | "success" | "warning" | "destructive" | "accent";
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* KPI tile. Five parts top-to-bottom:
|
||||
* 1. Eyebrow label + small icon — both uppercased
|
||||
* 2. Big number — Geist Mono, the headline
|
||||
* 3. Delta + hint row — one is always present
|
||||
* 4. Sparkline strip — bottom, hairline-separated
|
||||
* 5. (Optional) Accent line on the left edge
|
||||
*/
|
||||
export function KpiCard({
|
||||
label,
|
||||
value,
|
||||
@@ -21,44 +30,60 @@ export function KpiCard({
|
||||
hint,
|
||||
icon: Icon,
|
||||
sparkline,
|
||||
accent = "default",
|
||||
className,
|
||||
style,
|
||||
}: KpiCardProps) {
|
||||
const accentColor = {
|
||||
default: "hsl(var(--accent))",
|
||||
accent: "hsl(var(--accent))",
|
||||
success: "hsl(var(--success))",
|
||||
warning: "hsl(var(--warning))",
|
||||
destructive: "hsl(var(--destructive))",
|
||||
}[accent];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className={cn(
|
||||
"surface rounded-xl p-5 flex flex-col gap-3 transition-colors hover:bg-muted/20",
|
||||
"group relative surface-2 rounded-xl p-5 flex flex-col gap-3 transition-colors hover:bg-muted/20 overflow-hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Left accent line — the only "color" element on the card, and
|
||||
only present when the value is significant (denial rate,
|
||||
etc.). Width 2px so it reads as a marker, not decoration. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute left-0 top-4 bottom-4 w-px"
|
||||
style={{ backgroundColor: accentColor, opacity: 0.45 }}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<div className="eyebrow">{label}</div>
|
||||
{Icon ? (
|
||||
<div className="h-6 w-6 rounded-md bg-muted/60 flex items-center justify-center">
|
||||
<Icon className="h-3 w-3 text-muted-foreground" strokeWidth={1.75} />
|
||||
<div className="h-6 w-6 rounded-md bg-muted/50 ring-1 ring-inset ring-border/40 flex items-center justify-center text-muted-foreground">
|
||||
<Icon className="h-3 w-3" strokeWidth={1.75} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="display text-[32px] leading-none text-foreground mt-0.5">
|
||||
<div className="display mono text-[32px] leading-[1.05] text-foreground tracking-tight">
|
||||
{value}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs min-h-[16px]">
|
||||
<div className="flex items-center gap-2 text-[11px] min-h-[16px]">
|
||||
{delta ? (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-0.5 font-medium num",
|
||||
"inline-flex items-center gap-0.5 font-medium mono",
|
||||
delta.positive ? "text-[hsl(var(--success))]" : "text-destructive"
|
||||
)}
|
||||
>
|
||||
{delta.direction === "up" ? (
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
<ArrowUpRight className="h-3 w-3" strokeWidth={2} />
|
||||
) : (
|
||||
<ArrowDownRight className="h-3 w-3" />
|
||||
<ArrowDownRight className="h-3 w-3" strokeWidth={2} />
|
||||
)}
|
||||
{delta.value}
|
||||
</span>
|
||||
@@ -69,8 +94,8 @@ export function KpiCard({
|
||||
</div>
|
||||
|
||||
{sparkline ? (
|
||||
<div className="-mx-5 -mb-5 mt-2 pt-3 pb-4 border-t border-border/40">
|
||||
<Sparkline values={sparkline} />
|
||||
<div className="-mx-5 -mb-5 mt-2 pt-3 pb-3 border-t border-border/30">
|
||||
<Sparkline values={sparkline} stroke={accentColor} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
+33
-22
@@ -1,27 +1,29 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { useIsFetching } from "@tanstack/react-query";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { SkipLink } from "@/components/ui/skip-link";
|
||||
import { SearchBar } from "./SearchBar";
|
||||
|
||||
/**
|
||||
* App shell. Three columns:
|
||||
* - Sidebar (240px, fixed)
|
||||
* - Top bar (sticky, lightweight utility row)
|
||||
* - Outlet (page content, max-w-[1400px] centered)
|
||||
*
|
||||
* The hairline scan element at the very top of the viewport is a 1px
|
||||
* line that slides an accent bar across the entire width whenever
|
||||
* the React Query layer has an in-flight request. The intent is to
|
||||
* surface "we're doing something" without a spinner — a moving line
|
||||
* reads as a sensor sweep, not a loading state.
|
||||
*/
|
||||
export function Layout() {
|
||||
// useIsFetching() returns the number of in-flight queries and is 0 on
|
||||
// first load (queries that have not yet fired are not counted). We use it
|
||||
// to drive a 1px hairline scan element at the very top of the layout.
|
||||
const isFetching = useIsFetching();
|
||||
const showScan = isFetching > 0;
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen z-10">
|
||||
{/* Skip link — first Tab stop, jumps past the sidebar.
|
||||
Hidden until focused (see .skip-link in index.css). */}
|
||||
<SkipLink />
|
||||
{/*
|
||||
Scan element — always in the DOM (no layout shift), 1px tall, full
|
||||
width. pointer-events-none so it never blocks clicks. The inner
|
||||
bar uses the `scan` keyframe from tailwind.config to slide a 1/3-
|
||||
width electric-blue bar across the hairline.
|
||||
*/}
|
||||
<div
|
||||
className="fixed top-0 left-0 right-0 z-50 h-px overflow-hidden pointer-events-none transition-opacity duration-200"
|
||||
style={{ opacity: showScan ? 1 : 0 }}
|
||||
@@ -38,19 +40,28 @@ export function Layout() {
|
||||
tabIndex={-1}
|
||||
className="md:pl-60 outline-none"
|
||||
>
|
||||
{/*
|
||||
Top bar — sits inside <main> so it shares the sidebar's right
|
||||
column. Sticky to the viewport top so the search chip stays
|
||||
reachable while the user scrolls long claim lists. The
|
||||
hairline + horizontal padding mirrors the main content
|
||||
container below it for visual continuity. The Sidebar's
|
||||
own header is taller and brand-marked; this is the
|
||||
lightweight "always-on utility" row.
|
||||
*/}
|
||||
<div className="sticky top-0 z-20 mx-auto max-w-[1400px] px-8 h-12 flex items-center justify-end bg-background/70 backdrop-blur-md border-b border-border/40">
|
||||
<div
|
||||
className="sticky top-0 z-20 mx-auto max-w-[1400px] px-6 lg:px-8 h-12 flex items-center justify-end gap-3 bg-background/70 backdrop-blur-md border-b border-border/40"
|
||||
data-print="hide"
|
||||
>
|
||||
{/* Tailing pill on the left edge of the top bar — a tiny
|
||||
global "system is alive" indicator. It lights up whenever
|
||||
any query is in flight, complementing the hairline scan. */}
|
||||
<div className="mr-auto flex items-center gap-2 text-[10.5px] mono uppercase tracking-[0.14em] text-muted-foreground/60">
|
||||
<span className="relative inline-flex h-1.5 w-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"absolute inline-flex h-full w-full rounded-full bg-[hsl(var(--success))] opacity-60",
|
||||
showScan && "animate-ping"
|
||||
)}
|
||||
/>
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-[hsl(var(--success))]" />
|
||||
</span>
|
||||
{showScan ? "Working" : "Idle"}
|
||||
</div>
|
||||
<SearchBar />
|
||||
</div>
|
||||
<div className="mx-auto max-w-[1400px] px-8 py-10">
|
||||
<div className="mx-auto max-w-[1400px] px-6 lg:px-8 py-8 lg:py-10">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -90,22 +90,32 @@ export function NewClaimDialog() {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" />
|
||||
<Button className="gap-1.5">
|
||||
<Plus className="h-4 w-4" strokeWidth={2} />
|
||||
New Claim
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Submit a new claim</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create an 837P claim. The submission will be queued for the next
|
||||
clearinghouse cycle.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={onSubmit} className="grid gap-4">
|
||||
<DialogContent className="max-w-xl overflow-hidden p-0">
|
||||
<div className="px-6 pt-6 pb-2">
|
||||
<span className="eyebrow text-muted-foreground">
|
||||
New submission
|
||||
</span>
|
||||
<DialogHeader className="mt-2 space-y-1.5">
|
||||
<DialogTitle className="display text-2xl">
|
||||
Submit a new claim
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[13px]">
|
||||
Create an 837P claim. The submission will be queued for the
|
||||
next clearinghouse cycle.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="grid gap-5 px-6 pb-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="patientName">Patient</Label>
|
||||
<Label htmlFor="patientName" className="eyebrow text-muted-foreground">
|
||||
Patient
|
||||
</Label>
|
||||
<Input
|
||||
id="patientName"
|
||||
autoFocus
|
||||
@@ -117,7 +127,9 @@ export function NewClaimDialog() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label>Provider (NPI)</Label>
|
||||
<Label className="eyebrow text-muted-foreground">
|
||||
Provider (NPI)
|
||||
</Label>
|
||||
<Select
|
||||
value={form.providerNpi}
|
||||
onValueChange={(v) => update("providerNpi", v)}
|
||||
@@ -128,26 +140,30 @@ export function NewClaimDialog() {
|
||||
<SelectContent>
|
||||
{providers.map((p) => (
|
||||
<SelectItem key={p.npi} value={p.npi}>
|
||||
{p.npi} — {p.name}
|
||||
<span className="mono tabular-nums">{p.npi}</span>
|
||||
<span className="text-muted-foreground ml-2">— {p.name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cptCode">CPT</Label>
|
||||
<Label htmlFor="cptCode" className="eyebrow text-muted-foreground">
|
||||
CPT
|
||||
</Label>
|
||||
<Input
|
||||
id="cptCode"
|
||||
placeholder="99213"
|
||||
value={form.cptCode}
|
||||
onChange={(e) => update("cptCode", e.target.value)}
|
||||
className="mono tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label>Payer</Label>
|
||||
<Label className="eyebrow text-muted-foreground">Payer</Label>
|
||||
<Select
|
||||
value={form.payerName}
|
||||
onValueChange={(v) => update("payerName", v)}
|
||||
@@ -173,28 +189,34 @@ export function NewClaimDialog() {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="billedAmount">Billed amount (USD)</Label>
|
||||
<Label htmlFor="billedAmount" className="eyebrow text-muted-foreground">
|
||||
Billed amount
|
||||
</Label>
|
||||
<Input
|
||||
id="billedAmount"
|
||||
inputMode="decimal"
|
||||
placeholder="0.00"
|
||||
value={form.billedAmount}
|
||||
onChange={(e) => update("billedAmount", e.target.value)}
|
||||
className="mono tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="notes">Notes (optional)</Label>
|
||||
<Label htmlFor="notes" className="eyebrow text-muted-foreground">
|
||||
Notes <span className="opacity-50">(optional)</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
placeholder="Internal notes — not transmitted to payer"
|
||||
value={form.notes}
|
||||
onChange={(e) => update("notes", e.target.value)}
|
||||
className="min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogFooter className="border-t border-border/40 -mx-6 -mb-6 mt-1 px-6 py-4 bg-background/40">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -202,7 +224,7 @@ export function NewClaimDialog() {
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
<Button type="submit" disabled={submitting} className="gap-1.5">
|
||||
{submitting ? "Submitting…" : "Submit claim"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The shared page header. Every page renders one — the eyebrow +
|
||||
* display title + optional subtitle + actions gives every screen a
|
||||
* consistent top, with the editorial serif headline providing a
|
||||
* moment of weight in an otherwise utility-heavy UI.
|
||||
*
|
||||
* The eyebrow carries an optional live status pill on the right so
|
||||
* pages with a live-tail (Claims, Remittances, Activity) can show the
|
||||
* connection state inline with the page title, not in the toolbar.
|
||||
*/
|
||||
export function PageHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
status,
|
||||
className,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
status?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<header className={cn("flex items-end justify-between gap-6 flex-wrap", className)}>
|
||||
<div className="min-w-0">
|
||||
<div className="eyebrow flex items-center gap-2 mb-2.5">
|
||||
<span className="inline-block h-px w-6 bg-foreground/20" />
|
||||
{eyebrow}
|
||||
</div>
|
||||
<h1 className="display text-[28px] sm:text-[32px] leading-[1.1] text-foreground">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle ? (
|
||||
<p className="text-muted-foreground mt-2 text-[13.5px] leading-relaxed max-w-2xl">
|
||||
{subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap justify-end">
|
||||
{status}
|
||||
{actions}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -85,24 +85,22 @@ function CasRow({ group }: { group: CasGroup }) {
|
||||
<div
|
||||
data-testid="cas-row"
|
||||
data-carc={`${group.group}-${group.reason}`}
|
||||
className="grid grid-cols-[6rem_1fr_5rem_6rem] items-baseline gap-3 px-4 py-2 border-b border-[color:var(--m-border-heavy)]/10 last:border-b-0"
|
||||
className="grid grid-cols-[6rem_1fr_5rem_6rem] items-baseline gap-3 px-4 py-2 border-b border-[color:var(--m-border-heavy)]/10 last:border-b-0 transition-colors hover:bg-[color:var(--m-surface)]/50"
|
||||
>
|
||||
<span
|
||||
className="font-mono text-sm tabular-nums text-[color:var(--m-ink-primary)]"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
className="mono text-sm tabular-nums text-[color:var(--m-ink-primary)]"
|
||||
>
|
||||
{group.group}-{group.reason}
|
||||
</span>
|
||||
<span className="text-sm text-[color:var(--m-ink-secondary)]">{group.label}</span>
|
||||
<span
|
||||
className="text-right font-mono text-sm tabular-nums text-[color:var(--m-ink-tertiary)]"
|
||||
className="text-right mono text-sm tabular-nums text-[color:var(--m-ink-tertiary)]"
|
||||
data-testid="cas-count"
|
||||
>
|
||||
×{group.count}
|
||||
</span>
|
||||
<span
|
||||
className="text-right font-mono text-base tabular-nums text-[color:var(--m-ink-primary)]"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
className="text-right mono text-base tabular-nums text-[color:var(--m-ink-primary)] font-semibold"
|
||||
data-testid="cas-amount"
|
||||
>
|
||||
{fmt.usdPrecise(group.total)}
|
||||
@@ -156,17 +154,17 @@ export function CasAdjustmentsPanel({
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-controls="cas-adjustments-list"
|
||||
className="flex items-center gap-2 text-left"
|
||||
className="flex items-center gap-2 text-left group"
|
||||
data-testid="cas-toggle"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 text-[color:var(--m-ink-tertiary)]" aria-hidden />
|
||||
<ChevronDown className="h-3.5 w-3.5 text-[color:var(--m-ink-tertiary)] group-hover:text-[color:var(--m-ink-primary)] transition-colors" aria-hidden />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 text-[color:var(--m-ink-tertiary)]" aria-hidden />
|
||||
<ChevronRight className="h-3.5 w-3.5 text-[color:var(--m-ink-tertiary)] group-hover:text-[color:var(--m-ink-primary)] transition-colors" aria-hidden />
|
||||
)}
|
||||
<h3
|
||||
data-testid="section-label"
|
||||
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
|
||||
className="eyebrow text-[color:var(--m-ink-tertiary)] group-hover:text-[color:var(--m-ink-primary)] transition-colors"
|
||||
>
|
||||
CAS Adjustments ({groups.length})
|
||||
</h3>
|
||||
@@ -177,22 +175,22 @@ export function CasAdjustmentsPanel({
|
||||
id="cas-adjustments-list"
|
||||
data-testid="cas-adjustments-list"
|
||||
className={cn(
|
||||
"flex flex-col rounded-lg border border-[color:var(--m-border-heavy)]/15",
|
||||
"flex flex-col rounded-lg border border-[color:var(--m-border-heavy)]/30",
|
||||
"bg-[color:var(--m-surface)]/40 overflow-hidden"
|
||||
)}
|
||||
>
|
||||
{/* Header row — labels for the 4-column grid */}
|
||||
<div className="grid grid-cols-[6rem_1fr_5rem_6rem] gap-3 px-4 py-2 border-b border-[color:var(--m-border-heavy)]/15 bg-[color:var(--m-surface)]/60">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
|
||||
<div className="grid grid-cols-[6rem_1fr_5rem_6rem] gap-3 px-4 py-2 border-b border-[color:var(--m-border-heavy)]/20 bg-[color:var(--m-surface)]/60">
|
||||
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
|
||||
CARC
|
||||
</span>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
|
||||
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
|
||||
Reason
|
||||
</span>
|
||||
<span className="text-right text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
|
||||
<span className="text-right eyebrow text-[color:var(--m-ink-tertiary)]">
|
||||
Hits
|
||||
</span>
|
||||
<span className="text-right text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
|
||||
<span className="text-right eyebrow text-[color:var(--m-ink-tertiary)]">
|
||||
Amount
|
||||
</span>
|
||||
</div>
|
||||
@@ -226,7 +224,7 @@ function PerLineSection({ perLine }: { perLine: PerLineCasGroup[] }) {
|
||||
>
|
||||
<h3
|
||||
data-testid="section-label"
|
||||
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
|
||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
||||
>
|
||||
Per-line Adjustments ({perLine.length})
|
||||
</h3>
|
||||
@@ -246,7 +244,7 @@ function PerLineRow({ group }: { group: PerLineCasGroup }) {
|
||||
<div
|
||||
data-testid="per-line-cas-row"
|
||||
data-line-number={group.slp.lineNumber}
|
||||
className="flex flex-col gap-1 px-3 py-2 rounded-md border border-[color:var(--m-border-heavy)]/10 bg-[color:var(--m-surface)]/40"
|
||||
className="flex flex-col gap-1 px-3 py-2 rounded-md border border-[color:var(--m-border-heavy)]/15 bg-[color:var(--m-surface)]/40 transition-colors hover:border-[color:var(--m-border-heavy)]/30"
|
||||
>
|
||||
<div className="flex items-baseline justify-between font-mono text-sm">
|
||||
<span className="text-[color:var(--m-ink-primary)]">
|
||||
@@ -256,7 +254,7 @@ function PerLineRow({ group }: { group: PerLineCasGroup }) {
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="tabular-nums text-[color:var(--m-ink-secondary)]"
|
||||
className="tabular-nums text-[color:var(--m-ink-primary)] font-semibold"
|
||||
data-testid="per-line-cas-total"
|
||||
>
|
||||
{fmt.usdPrecise(total)}
|
||||
@@ -270,7 +268,8 @@ function PerLineRow({ group }: { group: PerLineCasGroup }) {
|
||||
data-testid="per-line-cas-entry"
|
||||
data-carc={`${a.groupCode}-${a.reasonCode}`}
|
||||
>
|
||||
{a.groupCode}-{a.reasonCode} · {fmt.usdPrecise(Number(a.amount))}
|
||||
<span className="text-[color:var(--m-ink-secondary)]">{a.groupCode}-{a.reasonCode}</span>
|
||||
<span className="ml-2 tabular-nums">{fmt.usdPrecise(Number(a.amount))}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -296,7 +295,7 @@ function ClaimLevelSection({
|
||||
>
|
||||
<h3
|
||||
data-testid="section-label"
|
||||
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
|
||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
||||
>
|
||||
Claim-level adjustments ({adjustments.length})
|
||||
</h3>
|
||||
@@ -306,12 +305,12 @@ function ClaimLevelSection({
|
||||
key={a.id}
|
||||
data-testid="claim-level-cas-entry"
|
||||
data-carc={`${a.groupCode}-${a.reasonCode}`}
|
||||
className="flex justify-between px-3 py-1 rounded-md bg-[color:var(--m-surface)]/40"
|
||||
className="flex justify-between px-3 py-1.5 rounded-md bg-[color:var(--m-surface)]/40 hover:bg-[color:var(--m-surface)]/70 transition-colors"
|
||||
>
|
||||
<span className="text-[color:var(--m-ink-primary)]">
|
||||
{a.groupCode}-{a.reasonCode}
|
||||
</span>
|
||||
<span className="tabular-nums text-[color:var(--m-ink-secondary)]">
|
||||
<span className="tabular-nums text-[color:var(--m-ink-primary)] font-semibold">
|
||||
{fmt.usdPrecise(Number(a.amount))}
|
||||
</span>
|
||||
</li>
|
||||
|
||||
@@ -79,7 +79,7 @@ export function ClaimPaymentsTable({ remit }: ClaimPaymentsTableProps) {
|
||||
<section className="flex flex-col gap-3 px-6 py-4" data-testid="claim-payments-table-section">
|
||||
<h3
|
||||
data-testid="section-label"
|
||||
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
|
||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
||||
>
|
||||
Claim Payments (1)
|
||||
</h3>
|
||||
@@ -95,7 +95,7 @@ export function ClaimPaymentsTable({ remit }: ClaimPaymentsTableProps) {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow data-testid="claim-payments-row">
|
||||
<TableRow data-testid="claim-payments-row" className="row-hover">
|
||||
<TableCell className="font-mono text-[color:var(--m-ink-primary)]">
|
||||
{claimLabel}
|
||||
</TableCell>
|
||||
@@ -103,19 +103,19 @@ export function ClaimPaymentsTable({ remit }: ClaimPaymentsTableProps) {
|
||||
{remit.status}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="text-right font-mono text-base tabular-nums text-[color:var(--m-ink-primary)]"
|
||||
className="text-right font-mono text-base tabular-nums text-[color:var(--m-ink-primary)] font-semibold"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
>
|
||||
{hasCharge ? fmt.usdPrecise(remit.totalCharge as number) : "—"}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="text-right font-mono text-base tabular-nums text-[color:var(--m-ink-primary)]"
|
||||
className="text-right font-mono text-base tabular-nums text-[color:var(--m-success)] font-semibold"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
>
|
||||
{fmt.usdPrecise(remit.paidAmount)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="text-right font-mono text-base tabular-nums text-[color:var(--m-ink-primary)]"
|
||||
className="text-right font-mono text-base tabular-nums text-[color:var(--m-warning)] font-semibold"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
>
|
||||
{Math.abs(remit.adjustmentAmount) > 0
|
||||
@@ -128,7 +128,7 @@ export function ClaimPaymentsTable({ remit }: ClaimPaymentsTableProps) {
|
||||
|
||||
{summary.length > 0 ? (
|
||||
<div className="flex flex-col gap-2 pt-2" data-testid="claim-payments-carc-summary">
|
||||
<h4 className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]">
|
||||
<h4 className="eyebrow text-[color:var(--m-ink-tertiary)]">
|
||||
CARC breakdown
|
||||
</h4>
|
||||
<Table>
|
||||
@@ -146,6 +146,7 @@ export function ClaimPaymentsTable({ remit }: ClaimPaymentsTableProps) {
|
||||
key={carcKey(row.group, row.reason)}
|
||||
data-testid="carc-row"
|
||||
data-carc={carcKey(row.group, row.reason)}
|
||||
className="row-hover"
|
||||
>
|
||||
<TableCell className="font-mono text-[color:var(--m-ink-primary)]">
|
||||
{carcKey(row.group, row.reason)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fmt } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { RemitDetail } from "@/hooks/useRemitDetail";
|
||||
|
||||
type FinancialSummaryCardProps = {
|
||||
@@ -27,16 +28,15 @@ function SummaryRow({
|
||||
data-testid={testId}
|
||||
className="flex items-baseline justify-between gap-3 py-2"
|
||||
>
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
|
||||
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
mono
|
||||
? "font-mono text-base tabular-nums text-[color:var(--m-ink-primary)]"
|
||||
? "mono text-sm tabular-nums text-[color:var(--m-ink-primary)]"
|
||||
: "text-sm text-[color:var(--m-ink-primary)]"
|
||||
}
|
||||
style={mono ? { fontFamily: "var(--m-font-mono)" } : undefined}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
@@ -78,45 +78,56 @@ export function FinancialSummaryCard({ remit }: FinancialSummaryCardProps) {
|
||||
>
|
||||
<h3
|
||||
data-testid="section-label"
|
||||
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
|
||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
||||
>
|
||||
Financial Summary
|
||||
</h3>
|
||||
|
||||
<div
|
||||
data-testid="financial-summary-inner"
|
||||
className="flex flex-col gap-4 rounded-lg border border-[color:var(--m-border-heavy)]/15 bg-[color:var(--m-surface)] p-4"
|
||||
className="flex flex-col gap-4 rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/60 p-4"
|
||||
>
|
||||
{/* Headline totals — big mono digits, side by side */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{/* Headline totals — big display digits, side by side */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div
|
||||
data-testid="summary-paid"
|
||||
className="flex flex-col gap-1 rounded-md bg-[color:var(--m-surface)]/40 px-3 py-2"
|
||||
className="flex flex-col gap-1.5 rounded-md border border-[color:var(--m-success)]/30 bg-[hsl(var(--success)/0.08)] px-3 py-2.5"
|
||||
>
|
||||
<span className="text-[10px] font-medium uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
|
||||
<span className="eyebrow text-[color:var(--m-success)]">
|
||||
Paid amount
|
||||
</span>
|
||||
<span
|
||||
className="font-mono text-2xl font-semibold tabular-nums text-[color:var(--m-ink-primary)]"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
className="display text-2xl tabular-nums text-[color:var(--m-success)]"
|
||||
>
|
||||
{fmt.usdPrecise(remit.paidAmount)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
data-testid="summary-adjustment"
|
||||
className="flex flex-col gap-1 rounded-md bg-[color:var(--m-surface)]/40 px-3 py-2"
|
||||
className={cn(
|
||||
"flex flex-col gap-1.5 rounded-md border px-3 py-2.5",
|
||||
hasAdjustment
|
||||
? "border-[color:var(--m-warning)]/40 bg-[hsl(var(--warning)/0.10)]"
|
||||
: "border-[color:var(--m-border-heavy)]/20 bg-[color:var(--m-surface)]/40"
|
||||
)}
|
||||
>
|
||||
<span className="text-[10px] font-medium uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
|
||||
<span
|
||||
className={cn(
|
||||
"eyebrow",
|
||||
hasAdjustment
|
||||
? "text-[color:var(--m-warning)]"
|
||||
: "text-[color:var(--m-ink-tertiary)]"
|
||||
)}
|
||||
>
|
||||
Adjustment amount
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
className={cn(
|
||||
"display text-2xl tabular-nums",
|
||||
hasAdjustment
|
||||
? "font-mono text-2xl font-semibold tabular-nums text-[color:var(--m-ink-primary)]"
|
||||
: "font-mono text-2xl tabular-nums text-[color:var(--m-ink-tertiary)]"
|
||||
}
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
? "text-[color:var(--m-warning)]"
|
||||
: "text-[color:var(--m-ink-tertiary)]"
|
||||
)}
|
||||
>
|
||||
{hasAdjustment ? fmt.usdPrecise(remit.adjustmentAmount) : "—"}
|
||||
</span>
|
||||
@@ -124,7 +135,7 @@ export function FinancialSummaryCard({ remit }: FinancialSummaryCardProps) {
|
||||
</div>
|
||||
|
||||
{/* Supporting rows: method, payment date, check / trace number */}
|
||||
<div className="flex flex-col divide-y divide-[color:var(--m-border-heavy)]/10">
|
||||
<div className="flex flex-col divide-y divide-[color:var(--m-border-heavy)]/15">
|
||||
<SummaryRow
|
||||
testId="summary-method"
|
||||
label="Payment method"
|
||||
|
||||
@@ -38,7 +38,7 @@ function isEmptyAddress(addr: AddressLike): boolean {
|
||||
function AddressBlock({ address }: { address: NonNullable<AddressLike> }) {
|
||||
return (
|
||||
<div
|
||||
className="text-xs leading-relaxed text-[color:var(--m-ink-secondary)]"
|
||||
className="mt-1 border-t border-dashed border-[color:var(--m-border-heavy)]/30 pt-2 text-xs leading-relaxed text-[color:var(--m-ink-secondary)]"
|
||||
data-testid="party-address"
|
||||
>
|
||||
{address.line1 ? <div>{address.line1}</div> : null}
|
||||
@@ -75,17 +75,16 @@ function PartyCard({
|
||||
return (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className="flex flex-col gap-2 rounded-lg border border-[color:var(--m-border-heavy)]/15 bg-[color:var(--m-surface)] px-4 py-3"
|
||||
className="flex flex-col gap-2 rounded-lg border border-[color:var(--m-border-heavy)]/30 bg-[color:var(--m-surface)]/60 px-4 py-3 transition-colors hover:border-[color:var(--m-border-heavy)]/50"
|
||||
>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
|
||||
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
|
||||
{label}
|
||||
</span>
|
||||
<div className="text-base font-medium text-[color:var(--m-ink-primary)]">
|
||||
<div className="display text-lg text-[color:var(--m-ink-primary)]">
|
||||
{name}
|
||||
</div>
|
||||
<div
|
||||
className="font-mono text-[13px] text-[color:var(--m-ink-secondary)] tabular-nums"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
className="mono text-[12.5px] text-[color:var(--m-ink-secondary)] tabular-nums"
|
||||
>
|
||||
{identity}
|
||||
</div>
|
||||
@@ -154,14 +153,14 @@ export function PartiesGrid({ parties, fallbackName }: PartiesGridProps) {
|
||||
>
|
||||
<h3
|
||||
data-testid="section-label"
|
||||
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
|
||||
className="eyebrow text-[color:var(--m-ink-tertiary)]"
|
||||
>
|
||||
Parties
|
||||
</h3>
|
||||
|
||||
<div
|
||||
data-testid="parties-grid-inner"
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-3"
|
||||
>
|
||||
<PartyCard
|
||||
testId="party-payer"
|
||||
|
||||
@@ -131,7 +131,7 @@ export function RemitDrawer({
|
||||
// positioning override. The Dialog primitive's default
|
||||
// centered layout is suppressed so the drawer reads as a
|
||||
// side-panel, 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-[color:var(--m-border-heavy)] bg-[color:var(--m-surface)] p-0"
|
||||
className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-[color:var(--m-border-heavy)]/60 bg-[color:var(--m-surface)] p-0 shadow-[-24px_0_60px_-12px_rgba(0,0,0,0.6)]"
|
||||
data-testid="remit-drawer"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
@@ -151,7 +151,7 @@ export function RemitDrawer({
|
||||
data-testid="remit-drawer-content"
|
||||
>
|
||||
<RemitDrawerHeader remit={data} onClose={onClose} />
|
||||
<div className="flex flex-col divide-y divide-[color:var(--m-border-heavy)]/15">
|
||||
<div className="flex flex-col divide-y divide-[color:var(--m-border-heavy)]/12">
|
||||
<FinancialSummaryCard remit={data} />
|
||||
<PartiesGrid parties={data} fallbackName={data.payerName} />
|
||||
<ClaimPaymentsTable remit={data} />
|
||||
|
||||
@@ -50,11 +50,11 @@ export function RemitDrawerError({
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-error)]">
|
||||
<span className="eyebrow text-[color:var(--m-error)]">
|
||||
{eyebrow}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[color:var(--m-ink-secondary)]">{message}</p>
|
||||
<p className="text-sm text-[color:var(--m-ink-secondary)] max-w-sm">{message}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{kind === "network" && onRetry ? (
|
||||
<Button variant="outline" size="sm" onClick={onRetry} data-testid="error-retry">
|
||||
|
||||
@@ -52,20 +52,19 @@ export function RemitDrawerHeader({ remit, onClose }: RemitDrawerHeaderProps) {
|
||||
<header
|
||||
className={cn(
|
||||
"flex items-start justify-between gap-4 px-6 py-5",
|
||||
"border-b border-[color:var(--m-border-heavy)]",
|
||||
"border-b border-[color:var(--m-border-heavy)]/40",
|
||||
"bg-[color:var(--m-surface)]"
|
||||
)}
|
||||
data-testid="remit-drawer-header"
|
||||
>
|
||||
{/* Left: eyebrow + mono remit ID + raw link */}
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]">
|
||||
<span className="eyebrow text-[color:var(--m-ink-tertiary)]">
|
||||
Remittance
|
||||
</span>
|
||||
<span
|
||||
data-testid="header-id"
|
||||
className="font-mono text-2xl font-semibold tracking-tight text-[color:var(--m-ink-primary)]"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
className="mono text-2xl font-semibold tracking-tight text-[color:var(--m-ink-primary)]"
|
||||
>
|
||||
{remit.id}
|
||||
</span>
|
||||
@@ -74,7 +73,7 @@ export function RemitDrawerHeader({ remit, onClose }: RemitDrawerHeaderProps) {
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
data-testid="header-raw-link"
|
||||
className="mt-1 inline-flex w-fit items-center gap-1 text-[11px] text-[color:var(--m-ink-tertiary)] hover:text-[color:var(--m-ink-secondary)]"
|
||||
className="mt-1 inline-flex w-fit items-center gap-1 text-[11px] text-[color:var(--m-ink-tertiary)] hover:text-[color:var(--m-ink-secondary)] transition-colors"
|
||||
>
|
||||
View raw 835
|
||||
<ExternalLink className="h-3 w-3" strokeWidth={1.75} aria-hidden />
|
||||
@@ -93,8 +92,7 @@ export function RemitDrawerHeader({ remit, onClose }: RemitDrawerHeaderProps) {
|
||||
</Badge>
|
||||
<span
|
||||
data-testid="header-paid"
|
||||
className="font-mono text-lg tabular-nums text-[color:var(--m-ink-primary)]"
|
||||
style={{ fontFamily: "var(--m-font-mono)" }}
|
||||
className="mono text-lg tabular-nums text-[color:var(--m-ink-primary)]"
|
||||
>
|
||||
{fmt.usdPrecise(remit.paidAmount)}
|
||||
</span>
|
||||
|
||||
@@ -74,12 +74,12 @@ function SearchTrigger({ onOpen }: { onOpen: () => void }) {
|
||||
aria-label="Open global search (Cmd or Ctrl + K)"
|
||||
className={cn(
|
||||
"group inline-flex items-center gap-2.5 h-8 pl-2.5 pr-2 rounded-md",
|
||||
"border border-border/60 bg-background/40 hover:bg-background/70",
|
||||
"border border-border/60 bg-background/40 hover:bg-background/70 hover:border-border/80",
|
||||
"text-[12px] text-muted-foreground hover:text-foreground",
|
||||
"transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
"transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
)}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" strokeWidth={1.75} aria-hidden />
|
||||
<Search className="h-3.5 w-3.5 text-muted-foreground group-hover:text-foreground transition-colors" strokeWidth={1.75} aria-hidden />
|
||||
<span className="font-medium tracking-tight">Search</span>
|
||||
<span className="ml-2 inline-flex items-center gap-0.5">
|
||||
<kbd className="kbd">{MOD_LABEL}</kbd>
|
||||
@@ -217,7 +217,7 @@ export function SearchBar() {
|
||||
className={cn(
|
||||
"max-w-2xl p-0 gap-0 overflow-hidden",
|
||||
"bg-[color:var(--m-surface)] text-[color:var(--m-ink-primary)]",
|
||||
"border border-[color:var(--m-border-heavy)]/20"
|
||||
"border border-[color:var(--m-border-heavy)]/30 shadow-2xl"
|
||||
)}
|
||||
aria-describedby={undefined}
|
||||
data-testid="search-dialog"
|
||||
@@ -228,9 +228,9 @@ export function SearchBar() {
|
||||
* tabbable element on open). `pl-9` makes room for the
|
||||
* magnifier icon on the left.
|
||||
*/}
|
||||
<div className="relative border-b border-border/40">
|
||||
<div className="relative border-b border-[color:var(--m-border-heavy)]/20">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none"
|
||||
className="absolute left-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-[color:var(--m-ink-tertiary)] pointer-events-none"
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
@@ -249,9 +249,10 @@ export function SearchBar() {
|
||||
onKeyDown={onInputKeyDown}
|
||||
data-testid="search-input"
|
||||
className={cn(
|
||||
"h-12 pl-9 pr-3 border-0 bg-transparent rounded-none",
|
||||
"text-[14px] font-mono tracking-tight",
|
||||
"shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
"h-12 pl-10 pr-3 border-0 bg-transparent rounded-none",
|
||||
"text-[14px] mono tracking-tight",
|
||||
"shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:bg-transparent",
|
||||
"text-[color:var(--m-ink-primary)] placeholder:text-[color:var(--m-ink-tertiary)]"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@@ -270,7 +271,7 @@ export function SearchBar() {
|
||||
</div>
|
||||
|
||||
{/* Footer hint — shows the keyboard contract. */}
|
||||
<div className="flex items-center justify-between gap-3 px-3 py-2 border-t border-border/40 text-[10.5px] font-mono uppercase tracking-[0.12em] text-muted-foreground">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-t border-[color:var(--m-border-heavy)]/20 bg-[color:var(--m-surface)]/80 text-[10px] mono uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<kbd className="kbd">↑</kbd>
|
||||
@@ -287,9 +288,16 @@ export function SearchBar() {
|
||||
</span>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<span className="text-muted-foreground/70">loading…</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block h-1.5 w-1.5 rounded-full animate-pulse-dot"
|
||||
style={{ background: "var(--tt-amber, hsl(var(--accent)))" }}
|
||||
/>
|
||||
loading…
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/70">
|
||||
<span className="tabular-nums">
|
||||
{flatTotal} {flatTotal === 1 ? "result" : "results"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -59,35 +59,35 @@ function ResultRow({
|
||||
}}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-2 rounded-md text-left transition-colors",
|
||||
"group w-full flex items-center gap-3 px-3 py-2 rounded-md text-left transition-colors",
|
||||
"focus:outline-none",
|
||||
selected
|
||||
? "bg-accent/15 ring-1 ring-inset ring-accent/50"
|
||||
: "hover:bg-muted/50"
|
||||
: "hover:bg-muted/40"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md",
|
||||
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md transition-colors",
|
||||
selected
|
||||
? "bg-accent/20 text-accent"
|
||||
: "bg-muted text-muted-foreground"
|
||||
? "bg-accent/25 text-accent"
|
||||
: "bg-muted text-muted-foreground group-hover:bg-muted/70"
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[13px] font-medium truncate">
|
||||
<span className="block text-[13px] font-medium truncate text-foreground">
|
||||
{result.title}
|
||||
</span>
|
||||
<span className="block text-[11px] font-mono text-muted-foreground truncate">
|
||||
<span className="block text-[11px] mono text-muted-foreground truncate">
|
||||
{result.subtitle}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 px-1.5 py-0.5 rounded text-[10px] font-mono uppercase tracking-[0.08em]",
|
||||
"shrink-0 px-1.5 py-0.5 rounded text-[10px] mono uppercase tracking-[0.14em] font-medium",
|
||||
"bg-muted/70 text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
@@ -100,7 +100,8 @@ function ResultRow({
|
||||
/** Section header — small caps label above each group. */
|
||||
function GroupHeader({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="px-3 pt-3 pb-1.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
<div className="px-4 pt-3 pb-1.5 flex items-center gap-2 text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
<span aria-hidden className="inline-block h-px w-3 bg-border" />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
@@ -109,11 +110,16 @@ function GroupHeader({ label }: { label: string }) {
|
||||
/** Render the "no matches" state. */
|
||||
function EmptyState({ query }: { query: string }) {
|
||||
return (
|
||||
<div className="px-4 py-10 text-center text-muted-foreground">
|
||||
<div className="text-[12px] font-mono">
|
||||
{query
|
||||
? <>No matches for <span className="text-foreground">{query}</span></>
|
||||
: "Type to search across claims, remittances, and activity."}
|
||||
<div className="px-4 py-12 text-center text-muted-foreground">
|
||||
<div className="mono text-[12px]">
|
||||
{query ? (
|
||||
<>
|
||||
No matches for{" "}
|
||||
<span className="text-foreground font-semibold">“{query}”</span>
|
||||
</>
|
||||
) : (
|
||||
"Type to search across claims, remittances, and activity."
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+110
-48
@@ -24,20 +24,45 @@ const nav = [
|
||||
{ to: "/activity", label: "Activity Log", icon: Activity },
|
||||
];
|
||||
|
||||
/**
|
||||
* Sidebar. Three sections under the brand mark:
|
||||
* - WORKSPACE — the day-to-day surface
|
||||
* - WORKFLOWS — triage & reconciliation lanes
|
||||
* - REFERENCE — long-tail batches & acks
|
||||
*
|
||||
* The active state uses a 2px left accent + an inset tint + a
|
||||
* subtle inner ring so the selected item reads instantly. Counts
|
||||
* (e.g. unmatched on Reconciliation) live as mono digits aligned to
|
||||
* the right edge so the eye can scan state at a glance.
|
||||
*/
|
||||
export function Sidebar() {
|
||||
// Surface the unmatched-bucket size on the Reconciliation nav entry so the
|
||||
// operator notices when manual reconciliation is needed.
|
||||
const { unmatched } = useReconciliation();
|
||||
const unmatchedCount =
|
||||
(unmatched.data?.claims.length ?? 0) +
|
||||
(unmatched.data?.remittances.length ?? 0);
|
||||
|
||||
return (
|
||||
<aside className="hidden md:flex md:w-60 md:flex-col md:fixed md:inset-y-0 z-30 border-r border-border/60 bg-sidebar/60 backdrop-blur-xl">
|
||||
<div className="flex h-16 items-center px-6 border-b border-border/60">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="h-7 w-7 rounded-md bg-accent flex items-center justify-center shadow-sm">
|
||||
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5 text-white" fill="none">
|
||||
<aside
|
||||
className="hidden md:flex md:w-60 md:flex-col md:fixed md:inset-y-0 z-30 border-r border-border/60 bg-sidebar/80 backdrop-blur-xl"
|
||||
data-print="hide"
|
||||
>
|
||||
{/* Brand mark — the editorial display face carries the wordmark
|
||||
while a small monospaced slug below keeps the brand
|
||||
unmistakably "machine". */}
|
||||
<div className="flex h-16 items-center px-5 border-b border-sidebar-border/80">
|
||||
<NavLink
|
||||
to="/"
|
||||
end
|
||||
className="flex items-center gap-2.5 group"
|
||||
aria-label="Cyclone — go to dashboard"
|
||||
>
|
||||
<div className="relative h-7 w-7 rounded-md bg-accent flex items-center justify-center shadow-[0_0_0_1px_hsl(var(--accent)/0.4),0_0_18px_-2px_hsl(var(--accent)/0.5)]">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-3.5 w-3.5 text-white"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M6 8h8a3 3 0 0 1 0 6h-5a3 3 0 0 0 0 6h9"
|
||||
stroke="currentColor"
|
||||
@@ -48,59 +73,47 @@ export function Sidebar() {
|
||||
</svg>
|
||||
</div>
|
||||
<div className="leading-none">
|
||||
<div className="text-[16px] font-semibold tracking-tight text-foreground">
|
||||
<div className="display text-[19px] tracking-tight text-foreground -mt-px">
|
||||
Cyclone
|
||||
</div>
|
||||
<div className="text-[10px] uppercase tracking-[0.14em] text-muted-foreground mt-0.5">
|
||||
<div className="mono text-[9.5px] uppercase tracking-[0.18em] text-muted-foreground mt-1">
|
||||
CuNtx · Claims
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-3 py-6">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground px-3 mb-2">
|
||||
Workspace
|
||||
</div>
|
||||
<ul className="space-y-0.5">
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-5">
|
||||
<SectionLabel>Workspace</SectionLabel>
|
||||
<ul className="space-y-0.5 mb-5">
|
||||
{nav.map((item) => (
|
||||
<li key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "nav-active"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="h-4 w-4" strokeWidth={1.75} />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
<NavItem item={item} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<SectionLabel>Workflows</SectionLabel>
|
||||
<ul className="space-y-0.5 mb-5">
|
||||
<li>
|
||||
<NavLink
|
||||
to="/reconciliation"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||
"group relative flex items-center gap-3 rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors",
|
||||
isActive
|
||||
? "nav-active"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
)
|
||||
}
|
||||
>
|
||||
<GitMerge className="h-4 w-4" strokeWidth={1.5} />
|
||||
<GitMerge className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
<span>Reconciliation</span>
|
||||
{unmatchedCount > 0 && (
|
||||
<span className="ml-auto text-[10px] font-mono tabular-nums text-warning">
|
||||
{unmatchedCount > 0 ? (
|
||||
<span className="ml-auto mono text-[10px] text-signal">
|
||||
{unmatchedCount > 99 ? "99+" : unmatchedCount}
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
@@ -108,30 +121,34 @@ export function Sidebar() {
|
||||
to="/inbox"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||
"group relative flex items-center gap-3 rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors",
|
||||
isActive
|
||||
? "nav-active"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
)
|
||||
}
|
||||
>
|
||||
<InboxIcon className="h-4 w-4" strokeWidth={1.5} />
|
||||
<InboxIcon className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
<span>Inbox</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<SectionLabel>Reference</SectionLabel>
|
||||
<ul className="space-y-0.5">
|
||||
<li>
|
||||
<NavLink
|
||||
to="/acks"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||
"group relative flex items-center gap-3 rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors",
|
||||
isActive
|
||||
? "nav-active"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
)
|
||||
}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" strokeWidth={1.5} />
|
||||
<CheckCircle2 className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
<span>999 ACKs</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
@@ -140,14 +157,14 @@ export function Sidebar() {
|
||||
to="/batches"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||
"group relative flex items-center gap-3 rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors",
|
||||
isActive
|
||||
? "nav-active"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Layers className="h-4 w-4" strokeWidth={1.5} />
|
||||
<Layers className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
<span>Batches</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
@@ -156,31 +173,76 @@ export function Sidebar() {
|
||||
to="/batch-diff"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||
"group relative flex items-center gap-3 rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors",
|
||||
isActive
|
||||
? "nav-active"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
)
|
||||
}
|
||||
>
|
||||
<GitCompareArrows className="h-4 w-4" strokeWidth={1.5} />
|
||||
<GitCompareArrows className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
<span>Batch diff</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="px-3 py-4 border-t border-border/60">
|
||||
{/* Operator card — always at the bottom. Ringed, mono id, the
|
||||
account role under the name. */}
|
||||
<div className="px-3 py-4 border-t border-sidebar-border/80">
|
||||
<div className="flex items-center gap-3 rounded-md p-2 hover:bg-muted/40 cursor-default">
|
||||
<div className="h-8 w-8 rounded-full bg-accent flex items-center justify-center text-[11px] font-semibold text-white ring-1 ring-inset ring-white/10">
|
||||
<div className="relative h-8 w-8 rounded-full bg-accent/20 ring-1 ring-inset ring-accent/40 flex items-center justify-center text-[10.5px] font-semibold text-accent mono">
|
||||
JK
|
||||
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full bg-[hsl(var(--success))] ring-2 ring-sidebar" />
|
||||
</div>
|
||||
<div className="leading-tight">
|
||||
<div className="text-sm font-medium">Jordan K.</div>
|
||||
<div className="text-[11px] text-muted-foreground">Administrator</div>
|
||||
<div className="leading-tight min-w-0">
|
||||
<div className="text-[13px] font-medium truncate">Jordan K.</div>
|
||||
<div className="mono text-[10px] text-muted-foreground tracking-wider uppercase">
|
||||
Administrator
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="px-3 mb-1.5 eyebrow text-muted-foreground/60">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({
|
||||
item,
|
||||
}: {
|
||||
item: {
|
||||
to: string;
|
||||
label: string;
|
||||
// Lucide icons accept `strokeWidth` as a wider union (string | number | null | undefined);
|
||||
// we declare it as `number | string` so any Lucide icon can be assigned
|
||||
// here without the prop-types assignability complaint from TS.
|
||||
icon: React.ComponentType<{ className?: string; strokeWidth?: number | string }>;
|
||||
end?: boolean;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"group relative flex items-center gap-3 rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors",
|
||||
isActive
|
||||
? "nav-active"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
interface SparklineProps {
|
||||
values: number[];
|
||||
className?: string;
|
||||
stroke?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal SVG sparkline. Normalises to its own range, draws a stroked line
|
||||
* with a soft fill, and marks the last point. Stretch-friendly via
|
||||
* preserveAspectRatio="none" + non-scaling-stroke.
|
||||
* Minimal SVG sparkline. Normalises to its own range, draws a stroked
|
||||
* line with a soft fill, and marks the last point. Uses
|
||||
* preserveAspectRatio="none" + non-scaling-stroke so the chart stretches
|
||||
* with its container without distorting the stroke.
|
||||
*/
|
||||
export function Sparkline({ values, className }: SparklineProps) {
|
||||
export function Sparkline({
|
||||
values,
|
||||
className,
|
||||
stroke = "hsl(var(--accent))",
|
||||
}: SparklineProps) {
|
||||
if (!values || values.length < 2) return null;
|
||||
const w = 100;
|
||||
const h = 28;
|
||||
@@ -37,11 +43,17 @@ export function Sparkline({ values, className }: SparklineProps) {
|
||||
height="36"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={area} fill="hsl(var(--accent) / 0.13)" />
|
||||
<defs>
|
||||
<linearGradient id="sparkline-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity="0.18" />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={area} fill="url(#sparkline-fill)" />
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke="hsl(var(--accent))"
|
||||
stroke={stroke}
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -50,8 +62,8 @@ export function Sparkline({ values, className }: SparklineProps) {
|
||||
<circle
|
||||
cx={last[0]}
|
||||
cy={last[1]}
|
||||
r="2"
|
||||
fill="hsl(var(--accent))"
|
||||
r="2.2"
|
||||
fill={stroke}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
@@ -8,7 +8,7 @@ const claimStyles: Record<ClaimStatus, { dot: string; label: string }> = {
|
||||
draft: { dot: "bg-muted-foreground", label: "Draft" },
|
||||
submitted: { dot: "bg-[hsl(var(--accent))]", label: "Submitted" },
|
||||
accepted: { dot: "bg-[hsl(var(--success))]", label: "Accepted" },
|
||||
denied: { dot: "bg-destructive", label: "Denied" },
|
||||
denied: { dot: "bg-[hsl(var(--destructive))]", label: "Denied" },
|
||||
paid: { dot: "bg-[hsl(var(--success))]", label: "Paid" },
|
||||
pending: { dot: "bg-[hsl(var(--warning))]", label: "Pending" },
|
||||
};
|
||||
@@ -43,11 +43,15 @@ export function StatusBadge({
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"uppercase tracking-wider text-[10px]",
|
||||
"bg-muted text-muted-foreground border-border",
|
||||
"gap-1.5 uppercase tracking-[0.14em] text-[10.5px] font-medium",
|
||||
"bg-muted/40 text-muted-foreground border-border/60",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block h-1.5 w-1.5 rounded-full bg-current opacity-70"
|
||||
/>
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
@@ -62,9 +66,15 @@ export function ClaimStatusBadge({ status }: { status: ClaimStatus }) {
|
||||
}
|
||||
const s = claimStyles[status];
|
||||
return (
|
||||
<Badge variant="outline" className="bg-transparent border-border/60">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", s.dot)} />
|
||||
<span className="font-normal">{s.label}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1.5 bg-transparent border-border/60 uppercase tracking-[0.14em] text-[10.5px]"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("inline-block h-1.5 w-1.5 rounded-full", s.dot)}
|
||||
/>
|
||||
<span className="font-medium">{s.label}</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -75,9 +85,15 @@ export function RemitStatusBadge({ status }: { status: RemittanceStatus }) {
|
||||
}
|
||||
const s = remitStyles[status];
|
||||
return (
|
||||
<Badge variant="outline" className="bg-transparent border-border/60">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", s.dot)} />
|
||||
<span className="font-normal">{s.label}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1.5 bg-transparent border-border/60 uppercase tracking-[0.14em] text-[10.5px]"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("inline-block h-1.5 w-1.5 rounded-full", s.dot)}
|
||||
/>
|
||||
<span className="font-medium">{s.label}</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ConnectionStatus =
|
||||
| "live"
|
||||
| "connecting"
|
||||
| "reconnecting"
|
||||
| "closed"
|
||||
| "stalled"
|
||||
| "error";
|
||||
|
||||
const STATUS_TONE: Record<
|
||||
ConnectionStatus,
|
||||
{ dot: string; ring: string; label: string }
|
||||
> = {
|
||||
live: {
|
||||
dot: "bg-[hsl(var(--success))]",
|
||||
ring: "ring-[hsl(var(--success))]/40",
|
||||
label: "Live",
|
||||
},
|
||||
connecting: {
|
||||
dot: "bg-[hsl(var(--signal))]",
|
||||
ring: "ring-[hsl(var(--signal))]/40",
|
||||
label: "Connecting",
|
||||
},
|
||||
reconnecting: {
|
||||
dot: "bg-[hsl(var(--signal))]",
|
||||
ring: "ring-[hsl(var(--signal))]/40",
|
||||
label: "Reconnecting",
|
||||
},
|
||||
closed: {
|
||||
dot: "bg-muted-foreground",
|
||||
ring: "ring-muted-foreground/30",
|
||||
label: "Closed",
|
||||
},
|
||||
stalled: {
|
||||
dot: "bg-destructive",
|
||||
ring: "ring-destructive/40",
|
||||
label: "Stalled",
|
||||
},
|
||||
error: {
|
||||
dot: "bg-destructive",
|
||||
ring: "ring-destructive/40",
|
||||
label: "Error",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Refined status pill with an animated dot. The dot pulses for any
|
||||
* non-terminal status and stays solid for stable states. Sits inline
|
||||
* with other header chrome and reads as a one-word status, not a
|
||||
* multi-line status panel.
|
||||
*/
|
||||
export function StatusPill({
|
||||
status,
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
status: ConnectionStatus;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const tone = STATUS_TONE[status];
|
||||
const isPulsing =
|
||||
status === "live" ||
|
||||
status === "connecting" ||
|
||||
status === "reconnecting";
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border border-border/60 bg-card/60 px-2.5 py-1 mono text-[10.5px] uppercase tracking-[0.14em]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"relative inline-flex h-1.5 w-1.5 rounded-full",
|
||||
tone.dot,
|
||||
"ring-4",
|
||||
tone.ring
|
||||
)}
|
||||
>
|
||||
{isPulsing ? (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute inline-flex h-full w-full rounded-full opacity-60 animate-ping",
|
||||
tone.dot
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-foreground/80">{label ?? tone.label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-tick every second so a "Last event: 12s ago" subtitle stays
|
||||
* current without a parent re-render. Mount-once interval.
|
||||
*/
|
||||
export function useTickingNow(intervalMs = 1000): number {
|
||||
const [now, setNow] = useState<number>(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), intervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [intervalMs]);
|
||||
return now;
|
||||
}
|
||||
|
||||
export function formatAge(seconds: number): string {
|
||||
if (seconds < 0) return "0s";
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
if (m < 60) return s === 0 ? `${m}m` : `${m}m ${s}s`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `${h}h ${m % 60}m`;
|
||||
}
|
||||
@@ -3,23 +3,20 @@
|
||||
//
|
||||
// Tiny presentational component used in the toolbar of `Claims`,
|
||||
// `Remittances`, and `ActivityLog` to surface the connection status of
|
||||
// `useTailStream`. Renders the existing `<Badge>` (variants from
|
||||
// `src/components/ui/badge.tsx`) plus a "Last event: 12s ago" subtitle
|
||||
// that re-ticks every second, plus a "↻ Reconnect" button that only
|
||||
// appears when the connection has visibly failed (`stalled` or `error`).
|
||||
//
|
||||
// Variant mapping (per spec):
|
||||
// live → success
|
||||
// connecting → warning
|
||||
// reconnecting → warning
|
||||
// closed → destructive
|
||||
// stalled → destructive
|
||||
// error → destructive
|
||||
// `useTailStream`. Renders a status dot + label + "Last event: 12s ago"
|
||||
// subtitle that re-ticks every second, plus a "↻ Reconnect" button
|
||||
// that only appears when the connection has visibly failed (`stalled`
|
||||
// or `error`).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { RotateCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
StatusPill,
|
||||
formatAge,
|
||||
useTickingNow,
|
||||
type ConnectionStatus,
|
||||
} from "@/components/StatusPill";
|
||||
import type { TailStatus } from "@/hooks/useTailStream";
|
||||
|
||||
export interface TailStatusPillProps {
|
||||
@@ -29,55 +26,20 @@ export interface TailStatusPillProps {
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<TailStatus, string> = {
|
||||
connecting: "Connecting…",
|
||||
connecting: "Connecting",
|
||||
live: "Live",
|
||||
reconnecting: "Reconnecting…",
|
||||
reconnecting: "Reconnecting",
|
||||
closed: "Closed",
|
||||
stalled: "Stalled",
|
||||
error: "Error",
|
||||
};
|
||||
|
||||
type BadgeVariant = "success" | "warning" | "destructive";
|
||||
|
||||
const STATUS_VARIANT: Record<TailStatus, BadgeVariant> = {
|
||||
live: "success",
|
||||
connecting: "warning",
|
||||
reconnecting: "warning",
|
||||
closed: "destructive",
|
||||
stalled: "destructive",
|
||||
error: "destructive",
|
||||
};
|
||||
|
||||
/**
|
||||
* Render a duration in seconds as a compact human label. We deliberately
|
||||
* keep the units small — under a minute is the common case for a healthy
|
||||
* tail (the stall detector fires at 30s, so anything beyond is a real
|
||||
* outage the user will want to investigate).
|
||||
*/
|
||||
function formatAge(seconds: number): string {
|
||||
if (seconds < 0) return "0s";
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
if (m < 60) return s === 0 ? `${m}m` : `${m}m ${s}s`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `${h}h ${m % 60}m`;
|
||||
}
|
||||
|
||||
export function TailStatusPill({
|
||||
status,
|
||||
lastEventAt,
|
||||
onReconnect,
|
||||
}: TailStatusPillProps): React.JSX.Element {
|
||||
// Re-tick every second so the "Last event: 12s ago" subtitle stays
|
||||
// current without the parent having to re-render. We mount the
|
||||
// interval only once per pill instance.
|
||||
const [now, setNow] = useState<number>(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const now = useTickingNow(1000);
|
||||
const ageSeconds = lastEventAt
|
||||
? Math.max(0, Math.floor((now - lastEventAt.getTime()) / 1000))
|
||||
: null;
|
||||
@@ -87,26 +49,24 @@ export function TailStatusPill({
|
||||
? "Last event: never"
|
||||
: `Last event: ${formatAge(ageSeconds)} ago`;
|
||||
|
||||
// Only show the manual reconnect affordance when the connection has
|
||||
// visibly failed. `closed` is reserved for the post-unmount state and
|
||||
// is intentionally not actionable — the component is about to vanish.
|
||||
const showReconnect = status === "stalled" || status === "error";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="inline-flex items-center gap-2"
|
||||
className="inline-flex items-center gap-2.5"
|
||||
data-testid="tail-status-pill"
|
||||
>
|
||||
<Badge variant={STATUS_VARIANT[status]} aria-label={`tail status: ${status}`}>
|
||||
{STATUS_LABEL[status]}
|
||||
</Badge>
|
||||
<StatusPill
|
||||
status={status as ConnectionStatus}
|
||||
label={STATUS_LABEL[status]}
|
||||
/>
|
||||
<span
|
||||
className="text-xs text-muted-foreground"
|
||||
className="mono text-[10.5px] text-muted-foreground hidden sm:inline"
|
||||
data-testid="tail-status-pill-subtitle"
|
||||
>
|
||||
{subtitle}
|
||||
</span>
|
||||
{showReconnect && (
|
||||
{showReconnect ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -114,9 +74,10 @@ export function TailStatusPill({
|
||||
onClick={onReconnect}
|
||||
data-testid="tail-status-pill-reconnect"
|
||||
>
|
||||
↻ Reconnect
|
||||
<RotateCw className="h-3 w-3" strokeWidth={2} />
|
||||
Reconnect
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,23 +14,40 @@ export function BulkBar({
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<div
|
||||
className="fixed bottom-4 left-1/2 px-4 py-2 font-mono flex gap-3 z-20"
|
||||
className="fixed bottom-6 left-1/2 px-3 py-2.5 mono flex items-center gap-2 z-20 rounded-md"
|
||||
style={{
|
||||
transform: "translateX(-50%)",
|
||||
background: "var(--tt-bg-elev)",
|
||||
border: "1px solid var(--tt-amber)",
|
||||
color: "var(--tt-ink)",
|
||||
fontSize: 11,
|
||||
boxShadow:
|
||||
"0 0 0 1px hsl(36 88% 56% / 0.15), 0 12px 40px -8px hsl(0 0% 0% / 0.6), inset 0 1px 0 0 hsl(0 0% 100% / 0.04)",
|
||||
}}
|
||||
>
|
||||
<span className="tabular-nums" style={{ color: "var(--tt-amber)" }}>
|
||||
{count} selected
|
||||
<span
|
||||
className="tabular-nums flex items-center gap-1.5 px-2 py-0.5 rounded-sm"
|
||||
style={{
|
||||
color: "var(--tt-bg)",
|
||||
background: "var(--tt-amber)",
|
||||
fontWeight: 700,
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.04em",
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
<span style={{ color: "var(--tt-amber)", letterSpacing: "0.12em", textTransform: "uppercase" }}>
|
||||
selected
|
||||
</span>
|
||||
|
||||
<span className="mx-1 h-4 w-px" style={{ background: "var(--tt-muted)" }} aria-hidden />
|
||||
|
||||
{lane === "rejected" && (
|
||||
<button
|
||||
onClick={onResubmit}
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--tt-ink)" }}
|
||||
className="px-2.5 py-1 rounded-sm uppercase tracking-[0.12em] transition-colors hover:bg-[color:var(--tt-amber)]/10 focus-visible:outline-none focus-visible:bg-[color:var(--tt-amber)]/10"
|
||||
style={{ color: "var(--tt-ink)", fontWeight: 600 }}
|
||||
>
|
||||
Re-submit ({count})
|
||||
</button>
|
||||
@@ -38,16 +55,16 @@ export function BulkBar({
|
||||
{lane === "candidates" && (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--tt-ink)" }}
|
||||
className="px-2.5 py-1 rounded-sm uppercase tracking-[0.12em] transition-colors hover:bg-[color:var(--tt-amber)]/10 focus-visible:outline-none focus-visible:bg-[color:var(--tt-amber)]/10"
|
||||
style={{ color: "var(--tt-ink)", fontWeight: 600 }}
|
||||
>
|
||||
Dismiss ({count})
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onExport}
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--tt-ink)" }}
|
||||
className="px-2.5 py-1 rounded-sm uppercase tracking-[0.12em] transition-colors hover:bg-[color:var(--tt-amber)]/10 focus-visible:outline-none focus-visible:bg-[color:var(--tt-amber)]/10"
|
||||
style={{ color: "var(--tt-ink)", fontWeight: 600 }}
|
||||
>
|
||||
Export CSV ({count})
|
||||
</button>
|
||||
|
||||
@@ -17,38 +17,83 @@ export function CandidateBreakdown({
|
||||
tier: ScoreTier;
|
||||
}) {
|
||||
return (
|
||||
<div className="font-mono" style={{ fontSize: 11 }}>
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<div className="mono" style={{ fontSize: 11 }}>
|
||||
<div className="flex items-baseline justify-between mb-3">
|
||||
<span
|
||||
className="uppercase"
|
||||
style={{ color: "var(--tt-ink-dim)", letterSpacing: "0.12em" }}
|
||||
style={{
|
||||
color: "var(--tt-ink-dim)",
|
||||
letterSpacing: "0.18em",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Score
|
||||
Match score
|
||||
</span>
|
||||
<span
|
||||
className="tabular-nums"
|
||||
style={{ color: "var(--tt-amber)", fontSize: 24 }}
|
||||
className="display tabular-nums"
|
||||
style={{
|
||||
color: "var(--tt-amber)",
|
||||
fontSize: 28,
|
||||
lineHeight: 1,
|
||||
letterSpacing: "-0.02em",
|
||||
}}
|
||||
>
|
||||
{score}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{FIELDS.map((f) => (
|
||||
<li key={f.key} className="flex justify-between">
|
||||
<span style={{ color: "var(--tt-ink)" }}>{f.label}</span>
|
||||
<span
|
||||
className="tabular-nums"
|
||||
style={{ color: "var(--tt-ink-dim)" }}
|
||||
>
|
||||
{Math.round(breakdown[f.key] * f.weight)} / {f.weight}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
|
||||
<ul className="space-y-1.5">
|
||||
{FIELDS.map((f) => {
|
||||
const v = breakdown[f.key];
|
||||
const score = Math.round(v * f.weight);
|
||||
const pct = Math.max(0.04, v);
|
||||
return (
|
||||
<li key={f.key} className="flex flex-col gap-1">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span style={{ color: "var(--tt-ink)" }}>{f.label}</span>
|
||||
<span
|
||||
className="tabular-nums"
|
||||
style={{ color: "var(--tt-ink-dim)" }}
|
||||
>
|
||||
<span style={{ color: "var(--tt-amber)" }}>{score}</span>
|
||||
<span className="opacity-50"> / {f.weight}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden
|
||||
className="h-[3px] rounded-full overflow-hidden"
|
||||
style={{ background: "var(--tt-muted)" }}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${pct * 100}%`,
|
||||
background:
|
||||
"linear-gradient(90deg, var(--tt-amber) 0%, var(--tt-amber) 100%)",
|
||||
boxShadow: "0 0 4px -1px var(--tt-amber)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<p
|
||||
className="mt-3 uppercase"
|
||||
style={{ color: "var(--tt-ink-dim)", letterSpacing: "0.12em", fontSize: 10 }}
|
||||
className="mt-4 inline-flex items-center gap-1.5 uppercase"
|
||||
style={{
|
||||
color: "var(--tt-amber)",
|
||||
letterSpacing: "0.18em",
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block h-1 w-1 rounded-full"
|
||||
style={{ background: "var(--tt-amber)" }}
|
||||
/>
|
||||
{tier}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,10 @@ export function InboxHeader({
|
||||
}) {
|
||||
const now = new Date();
|
||||
const day = now.toLocaleDateString("en-US", { weekday: "short" }).toUpperCase();
|
||||
const date = now.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
});
|
||||
const time = now.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
@@ -15,35 +19,65 @@ export function InboxHeader({
|
||||
|
||||
return (
|
||||
<header
|
||||
className="sticky top-0 z-10 px-6 py-4"
|
||||
className="sticky top-0 z-10 px-6 pt-6 pb-4"
|
||||
style={{
|
||||
background: "var(--tt-bg)",
|
||||
borderBottom: "1px solid var(--tt-bg-elev)",
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
className="tracking-tight"
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div className="flex items-baseline gap-4">
|
||||
<h1
|
||||
className="display tracking-tight"
|
||||
style={{
|
||||
color: "var(--tt-ink)",
|
||||
fontSize: 30,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
Inbox
|
||||
</h1>
|
||||
<span
|
||||
className="mono uppercase"
|
||||
style={{
|
||||
color: "var(--tt-amber)",
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.18em",
|
||||
}}
|
||||
>
|
||||
· {day} {date}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="mono tabular-nums"
|
||||
style={{
|
||||
color: "var(--tt-ink-dim)",
|
||||
fontSize: 12,
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
{time}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className="mono mt-2 flex items-center gap-2"
|
||||
style={{
|
||||
color: "var(--tt-ink)",
|
||||
fontFamily: "'Inter Tight', system-ui, sans-serif",
|
||||
fontWeight: 700,
|
||||
fontSize: 22,
|
||||
color: "var(--tt-ink-dim)",
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
INBOX{" "}
|
||||
<span
|
||||
className="font-mono"
|
||||
style={{ color: "var(--tt-amber)", fontSize: 16 }}
|
||||
>
|
||||
· {day} {time}
|
||||
</span>
|
||||
</h1>
|
||||
<p
|
||||
className="font-mono mt-1"
|
||||
style={{ color: "var(--tt-ink-dim)", fontSize: 11, letterSpacing: "0.05em" }}
|
||||
>
|
||||
<span style={{ color: "var(--tt-amber)" }}>{needEyesCount}</span> items need eyes ·{" "}
|
||||
<span style={{ color: "var(--tt-ink)" }}>{doneTodayCount}</span> done today
|
||||
className="inline-block w-1.5 h-1.5 rounded-full animate-pulse-dot"
|
||||
style={{ background: "var(--tt-amber)" }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span style={{ color: "var(--tt-amber)", fontWeight: 600 }}>{needEyesCount}</span>
|
||||
items need eyes
|
||||
<span className="opacity-50">·</span>
|
||||
<span style={{ color: "var(--tt-ink)", fontWeight: 600 }}>{doneTodayCount}</span>
|
||||
done today
|
||||
</p>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { InboxClaimRow, InboxCandidateRow, ScoreBreakdown } from "@/lib/inbox-api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type Accent = "oxblood" | "amber" | "ink-blue" | "muted";
|
||||
|
||||
@@ -30,19 +31,23 @@ function Sparkline({ breakdown }: { breakdown: ScoreBreakdown | null }) {
|
||||
if (!breakdown) return null;
|
||||
const fields: Array<keyof ScoreBreakdown> = ["patient", "date", "amount", "provider"];
|
||||
return (
|
||||
<span className="inline-flex items-end gap-0.5 align-middle h-3">
|
||||
<span className="inline-flex items-end gap-[2px] align-middle h-3.5">
|
||||
{fields.map((k) => {
|
||||
const v = breakdown[k] as number;
|
||||
const h = Math.max(2, Math.round(v * 12));
|
||||
const h = Math.max(3, Math.round(v * 14));
|
||||
return (
|
||||
<span
|
||||
key={k}
|
||||
title={`${k}: ${v.toFixed(2)}`}
|
||||
className="inline-block w-1.5"
|
||||
style={{
|
||||
height: `${h}px`,
|
||||
background: v >= 0.5 ? "var(--tt-amber)" : "var(--tt-muted)",
|
||||
}}
|
||||
className={cn(
|
||||
"inline-block w-[3px] rounded-[1px] transition-all",
|
||||
v >= 0.7
|
||||
? "bg-[color:var(--tt-amber)] shadow-[0_0_4px_-1px_var(--tt-amber)]"
|
||||
: v >= 0.5
|
||||
? "bg-[color:var(--tt-amber)]/70"
|
||||
: "bg-[color:var(--tt-muted)]"
|
||||
)}
|
||||
style={{ height: `${h}px` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -67,41 +72,48 @@ export function InboxRow({ row, accent, onClick }: Props) {
|
||||
return (
|
||||
<tr
|
||||
onClick={onClick}
|
||||
className="cursor-pointer hover:bg-white/[0.03] transition-colors"
|
||||
style={{ borderBottom: "1px solid var(--tt-bg-elev)" }}
|
||||
className="cursor-pointer transition-colors group"
|
||||
>
|
||||
<td style={{ width: 4, background: ACCENT_VAR[accent], padding: 0 }} />
|
||||
<td
|
||||
className="py-2 pl-3 font-mono"
|
||||
style={{ color: "var(--tt-ink)", fontSize: 12 }}
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 4,
|
||||
background: ACCENT_VAR[accent],
|
||||
padding: 0,
|
||||
boxShadow: `inset 0 0 0 1px ${ACCENT_VAR[accent]}40`,
|
||||
}}
|
||||
/>
|
||||
<td
|
||||
className="py-2.5 pl-3 mono font-semibold"
|
||||
style={{ color: "var(--tt-ink)", fontSize: 12, letterSpacing: "-0.005em" }}
|
||||
>
|
||||
{id}
|
||||
</td>
|
||||
<td
|
||||
className="py-2 font-mono"
|
||||
style={{ color: "var(--tt-ink-dim)", fontSize: 11 }}
|
||||
className="py-2.5 mono"
|
||||
style={{ color: reason ? "var(--tt-oxblood)" : "var(--tt-amber)", fontSize: 11 }}
|
||||
>
|
||||
{reason ?? (topScore != null ? `score ${topScore}` : "")}
|
||||
</td>
|
||||
<td
|
||||
className="py-2 font-mono tabular-nums"
|
||||
className="py-2.5 mono tabular-nums"
|
||||
style={{ color: "var(--tt-ink-dim)", fontSize: 11 }}
|
||||
>
|
||||
{fmtAgo(rejectedAt)}
|
||||
</td>
|
||||
<td
|
||||
className="py-2 text-right font-mono tabular-nums"
|
||||
className="py-2.5 text-right mono tabular-nums font-semibold"
|
||||
style={{ color: "var(--tt-ink)", fontSize: 12 }}
|
||||
>
|
||||
{charge}
|
||||
</td>
|
||||
<td
|
||||
className="py-2 pl-4 uppercase tracking-wider"
|
||||
style={{ color: "var(--tt-ink-dim)", fontSize: 10 }}
|
||||
className="py-2.5 pl-4 mono uppercase"
|
||||
style={{ color: "var(--tt-ink-dim)", fontSize: 10, letterSpacing: "0.14em" }}
|
||||
>
|
||||
{payer}
|
||||
</td>
|
||||
<td className="py-2 pr-3">
|
||||
<td className="py-2.5 pr-3">
|
||||
<Sparkline breakdown={filled} />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
+126
-32
@@ -1,9 +1,74 @@
|
||||
import { useState } from "react";
|
||||
import { InboxRow, type Accent } from "./InboxRow";
|
||||
import type { InboxClaimRow, InboxCandidateRow } from "@/lib/inbox-api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type LaneRow = InboxClaimRow | InboxCandidateRow;
|
||||
|
||||
const ACCENT_COLOR: Record<Accent, string> = {
|
||||
oxblood: "var(--tt-oxblood)",
|
||||
amber: "var(--tt-amber)",
|
||||
"ink-blue": "var(--tt-ink-blue)",
|
||||
muted: "var(--tt-muted)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom checkbox cell — a small square that picks up the lane's accent
|
||||
* when selected. Built on a native `<input type="checkbox">` so the
|
||||
* accessibility role and label-by-id wiring are free (the test harness
|
||||
* looks the cell up with `screen.getByRole("checkbox")`).
|
||||
*/
|
||||
function RowCheckbox({
|
||||
id,
|
||||
checked,
|
||||
onToggle,
|
||||
}: {
|
||||
id: string;
|
||||
checked: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
className="flex shrink-0 items-center justify-center cursor-pointer select-none"
|
||||
style={{ width: 32 }}
|
||||
>
|
||||
<span className="relative inline-flex items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Select ${id}`}
|
||||
checked={checked}
|
||||
onChange={onToggle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"block h-3.5 w-3.5 rounded-[3px] border transition-all duration-100",
|
||||
checked
|
||||
? "bg-[color:var(--tt-amber)] border-[color:var(--tt-amber)] shadow-[0_0_8px_-1px_var(--tt-amber)]"
|
||||
: "border-[color:var(--tt-muted)] bg-transparent peer-hover:border-[color:var(--tt-ink-dim)] peer-focus-visible:ring-2 peer-focus-visible:ring-[color:var(--tt-amber)] peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-[color:var(--tt-bg-elev)]"
|
||||
)}
|
||||
/>
|
||||
{checked ? (
|
||||
<svg
|
||||
aria-hidden
|
||||
viewBox="0 0 12 12"
|
||||
className="pointer-events-none absolute h-2.5 w-2.5 text-[color:var(--tt-bg)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="2.5,6.5 5,9 9.5,3.5" />
|
||||
</svg>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Lane({
|
||||
name,
|
||||
accent,
|
||||
@@ -31,29 +96,56 @@ export function Lane({
|
||||
onSelectionChange?.(Array.from(next));
|
||||
}
|
||||
|
||||
const accentColor = ACCENT_COLOR[accent];
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex-1 min-w-[280px] flex flex-col"
|
||||
className="flex-1 min-w-[280px] flex flex-col rounded-md overflow-hidden"
|
||||
style={{
|
||||
background: "var(--tt-bg-elev)",
|
||||
border: "1px solid var(--tt-bg)",
|
||||
borderRadius: 4,
|
||||
border: "1px solid hsl(220 8% 18%)",
|
||||
boxShadow:
|
||||
"inset 0 1px 0 0 hsl(0 0% 100% / 0.03), 0 1px 2px 0 hsl(0 0% 0% / 0.4)",
|
||||
}}
|
||||
data-lane={name.toLowerCase()}
|
||||
>
|
||||
<header
|
||||
className="px-3 py-2 flex items-baseline justify-between"
|
||||
style={{ borderBottom: "1px solid var(--tt-bg)" }}
|
||||
className="px-3 py-2.5 flex items-baseline justify-between gap-2"
|
||||
style={{
|
||||
borderBottom: "1px solid hsl(220 8% 16%)",
|
||||
background:
|
||||
"linear-gradient(180deg, hsl(220 16% 11%) 0%, hsl(220 18% 9%) 100%)",
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
className="font-mono uppercase tracking-widest"
|
||||
style={{ color: "var(--tt-ink)", fontSize: 11, letterSpacing: "0.12em" }}
|
||||
>
|
||||
{name}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{
|
||||
background: accentColor,
|
||||
boxShadow: `0 0 8px ${accentColor}`,
|
||||
}}
|
||||
/>
|
||||
<h2
|
||||
className="mono uppercase"
|
||||
style={{
|
||||
color: "var(--tt-ink)",
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.18em",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</h2>
|
||||
</div>
|
||||
<span
|
||||
className="font-mono tabular-nums"
|
||||
style={{ color: "var(--tt-amber)", fontSize: 18 }}
|
||||
className="mono tabular-nums"
|
||||
style={{
|
||||
color: "var(--tt-amber)",
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{rows.length}
|
||||
</span>
|
||||
@@ -61,34 +153,36 @@ export function Lane({
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p
|
||||
className="p-4 font-mono"
|
||||
style={{ color: "var(--tt-muted)", fontSize: 11 }}
|
||||
className="px-4 py-6 mono text-center"
|
||||
style={{ color: "var(--tt-muted)", fontSize: 11, letterSpacing: "0.06em" }}
|
||||
>
|
||||
Nothing here.
|
||||
</p>
|
||||
) : (
|
||||
<div className="w-full" style={{ fontSize: 12 }}>
|
||||
{rows.map((row) => {
|
||||
<div className="w-full">
|
||||
{rows.map((row, idx) => {
|
||||
const id = rowId(row);
|
||||
const isSelected = selected.has(id);
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="flex items-stretch"
|
||||
style={{ borderBottom: "1px solid var(--tt-bg)" }}
|
||||
className={cn(
|
||||
"group flex items-stretch transition-colors",
|
||||
isSelected
|
||||
? "bg-[color:var(--tt-amber)]/8"
|
||||
: idx % 2 === 0
|
||||
? "bg-transparent"
|
||||
: "bg-white/[0.015]",
|
||||
"hover:bg-[color:var(--tt-amber)]/5"
|
||||
)}
|
||||
style={{ borderBottom: "1px solid hsl(220 8% 14%)" }}
|
||||
>
|
||||
<label
|
||||
className="flex items-center justify-center"
|
||||
style={{ width: 28, padding: "0 4px 0 8px" }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Select ${id}`}
|
||||
checked={selected.has(id)}
|
||||
onChange={() => toggle(id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</label>
|
||||
<div style={{ width: "100%" }}>
|
||||
<RowCheckbox
|
||||
id={id}
|
||||
checked={isSelected}
|
||||
onToggle={() => toggle(id)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<InboxRow row={row} accent={accent} onClick={() => onRowClick(row)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,20 +3,22 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[10.5px] font-medium uppercase tracking-[0.12em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary/15 text-primary",
|
||||
secondary: "border-transparent bg-muted text-muted-foreground",
|
||||
secondary: "border-transparent bg-muted/80 text-muted-foreground",
|
||||
success:
|
||||
"border-transparent bg-[hsl(var(--success)/0.15)] text-[hsl(var(--success))]",
|
||||
"border-transparent bg-[hsl(var(--success)/0.14)] text-[hsl(var(--success))]",
|
||||
warning:
|
||||
"border-transparent bg-[hsl(var(--warning)/0.15)] text-[hsl(var(--warning))]",
|
||||
"border-transparent bg-[hsl(var(--warning)/0.14)] text-[hsl(var(--warning))]",
|
||||
destructive:
|
||||
"border-transparent bg-destructive/15 text-destructive",
|
||||
outline: "border-border text-foreground",
|
||||
muted: "border-transparent bg-muted/60 text-muted-foreground",
|
||||
outline: "border-border/80 text-foreground",
|
||||
muted: "border-transparent bg-muted/40 text-muted-foreground",
|
||||
signal:
|
||||
"border-transparent bg-[hsl(var(--signal)/0.14)] text-[hsl(var(--signal))]",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
|
||||
@@ -4,28 +4,28 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-40 active:translate-y-px",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
|
||||
"bg-primary text-primary-foreground shadow-[0_1px_0_0_hsl(0_0%_100%/0.08)_inset,0_1px_2px_0_hsl(0_0%_0%/0.4)] hover:bg-primary/90",
|
||||
accent:
|
||||
"bg-accent text-accent-foreground shadow-sm hover:bg-accent/90",
|
||||
"bg-accent text-accent-foreground shadow-[0_1px_0_0_hsl(0_0%_100%/0.1)_inset,0_1px_2px_0_hsl(0_0%_0%/0.4)] hover:bg-accent/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
"bg-destructive text-destructive-foreground shadow-[0_1px_0_0_hsl(0_0%_100%/0.1)_inset,0_1px_2px_0_hsl(0_0%_0%/0.4)] hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-border bg-transparent text-foreground hover:bg-muted",
|
||||
"border border-border/80 bg-transparent text-foreground hover:bg-muted/60 hover:border-border",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-muted",
|
||||
ghost: "bg-transparent text-foreground hover:bg-muted",
|
||||
ghost: "bg-transparent text-foreground hover:bg-muted/60",
|
||||
link: "bg-transparent text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-lg px-6",
|
||||
icon: "h-9 w-9",
|
||||
default: "h-9 px-4",
|
||||
sm: "h-7 rounded px-2.5 text-xs",
|
||||
lg: "h-10 px-5",
|
||||
icon: "h-8 w-8",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -22,23 +22,6 @@ const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
|
||||
);
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
// Polymorphic `as` prop so consumers can pick the correct heading level
|
||||
// for semantic structure (e.g. `<CardTitle as="h2">` inside a Dashboard
|
||||
// section). Defaults to `"div"` so all existing call-sites that omit
|
||||
// `as` continue to render a <div> exactly as before — no regression.
|
||||
//
|
||||
// Implementation note: `React.forwardRef` infers its ref/props types
|
||||
// from the inner render function, but TypeScript can't propagate a
|
||||
// generic parameter through `forwardRef`'s own declaration. The
|
||||
// well-known workaround (see Ben Ilegbodu's polymorphic-component
|
||||
// write-up, mirrored by Mantine / tsteele.dev) is to give `forwardRef`
|
||||
// concrete types up front and then cast the resulting component to a
|
||||
// generic call signature so consumers get element-specific props
|
||||
// (e.g. h2 attrs) and the right ref type based on `as`. The inner
|
||||
// render uses `as` to swap the actual rendered element at runtime;
|
||||
// the type assertion on `ref` is safe because React only cares about
|
||||
// the `current` property at runtime regardless of the underlying
|
||||
// DOM node type.
|
||||
type CardTitleProps<E extends React.ElementType = "div"> = Omit<
|
||||
React.ComponentPropsWithoutRef<E>,
|
||||
"as"
|
||||
@@ -46,12 +29,6 @@ type CardTitleProps<E extends React.ElementType = "div"> = Omit<
|
||||
as?: E;
|
||||
};
|
||||
|
||||
// The exported component is a polymorphic callable: a single call
|
||||
// signature with the generic `E` parameter that consumers control
|
||||
// via the `as` prop. We assign forwardRef's result through an
|
||||
// `unknown` cast (necessary because the source type is
|
||||
// `ForwardRefExoticComponent` while the target is a plain generic
|
||||
// function).
|
||||
type CardTitleComponent = <E extends React.ElementType = "div">(
|
||||
props: CardTitleProps<E> & { ref?: React.ForwardedRef<React.ElementRef<E>> }
|
||||
) => React.ReactElement | null;
|
||||
@@ -71,8 +48,6 @@ const CardTitle = React.forwardRef<HTMLDivElement, CardTitleProps<"div">>(
|
||||
);
|
||||
}
|
||||
) as unknown as CardTitleComponent;
|
||||
// Displayname for React DevTools — assignable via cast since the
|
||||
// polymorphic call signature doesn't expose it.
|
||||
(CardTitle as unknown as { displayName: string }).displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
|
||||
@@ -18,42 +18,50 @@ const STATE_CONFIG: Record<
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
className: string;
|
||||
dot: string;
|
||||
}
|
||||
> = {
|
||||
submitted: {
|
||||
label: "Submitted",
|
||||
icon: Send,
|
||||
className: "bg-accent/15 text-accent border-accent/30",
|
||||
dot: "bg-accent",
|
||||
},
|
||||
received: {
|
||||
label: "Received",
|
||||
icon: Inbox,
|
||||
className: "bg-accent/10 text-accent/80 border-accent/20",
|
||||
className: "bg-accent/10 text-accent/85 border-accent/20",
|
||||
dot: "bg-accent/70",
|
||||
},
|
||||
paid: {
|
||||
label: "Paid",
|
||||
icon: CheckCircle2,
|
||||
className: "bg-success/15 text-success border-success/30",
|
||||
dot: "bg-success",
|
||||
},
|
||||
partial: {
|
||||
label: "Partial",
|
||||
icon: AlertTriangle,
|
||||
className: "bg-warning/15 text-warning border-warning/30",
|
||||
dot: "bg-warning",
|
||||
},
|
||||
denied: {
|
||||
label: "Denied",
|
||||
icon: Ban,
|
||||
className: "bg-destructive/15 text-destructive border-destructive/30",
|
||||
dot: "bg-destructive",
|
||||
},
|
||||
reconciled: {
|
||||
label: "Reconciled",
|
||||
icon: CheckCheck,
|
||||
className: "bg-success/15 text-success border-success/30",
|
||||
dot: "bg-success",
|
||||
},
|
||||
reversed: {
|
||||
label: "Reversed",
|
||||
icon: RotateCcw,
|
||||
className: "bg-warning/15 text-warning border-warning/30",
|
||||
dot: "bg-warning",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -70,12 +78,12 @@ export function ClaimStateBadge({
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"gap-1.5 font-mono uppercase tracking-wider text-[10px]",
|
||||
"gap-1.5 mono uppercase tracking-[0.14em] text-[10.5px] font-medium py-0.5",
|
||||
cfg.className,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" strokeWidth={1.75} aria-hidden />
|
||||
<Icon className="h-3 w-3" strokeWidth={2} aria-hidden />
|
||||
{cfg.label}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ const DialogOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"fixed inset-0 z-50 bg-black/65 backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -32,7 +32,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 surface rounded-2xl p-6 shadow-2xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 surface rounded-xl p-6 shadow-2xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -40,7 +40,7 @@ const DialogContent = React.forwardRef<
|
||||
{children}
|
||||
<DialogPrimitive.Close
|
||||
aria-label="Close dialog"
|
||||
className="absolute right-4 top-4 rounded-md p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
className="absolute right-4 top-4 rounded-md p-1 text-muted-foreground opacity-60 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden />
|
||||
<span className="sr-only">Close</span>
|
||||
@@ -72,7 +72,7 @@ const DialogTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
className={cn("text-base font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -30,20 +30,19 @@ export function EmptyState({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-2.5 py-14 text-center",
|
||||
"flex flex-col items-center justify-center gap-2.5 py-16 text-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="h-10 w-10 rounded-full ring-1 ring-inset ring-border/60 flex items-center justify-center text-muted-foreground"
|
||||
className="relative h-10 w-10 rounded-full ring-1 ring-inset ring-border/60 bg-muted/30 flex items-center justify-center text-muted-foreground"
|
||||
>
|
||||
<div className="absolute inset-0 rounded-full ring-1 ring-inset ring-accent/10" />
|
||||
{icon ?? <Inbox className="h-4 w-4" strokeWidth={1.5} />}
|
||||
</div>
|
||||
{eyebrow ? (
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{eyebrow}
|
||||
</div>
|
||||
<div className="eyebrow">{eyebrow}</div>
|
||||
) : null}
|
||||
<div className="text-sm text-muted-foreground/80 max-w-sm">
|
||||
{message}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function ErrorState({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"surface rounded-xl border border-destructive/30 p-4",
|
||||
"surface rounded-lg border border-destructive/30 p-4",
|
||||
"flex items-center gap-3",
|
||||
className,
|
||||
)}
|
||||
@@ -42,12 +42,10 @@ export function ErrorState({
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-destructive/80">
|
||||
{eyebrow}
|
||||
</div>
|
||||
<div className="eyebrow text-destructive/80">{eyebrow}</div>
|
||||
<div className="text-sm text-foreground/90 truncate">{message}</div>
|
||||
{detail ? (
|
||||
<div className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
<div className="text-xs text-muted-foreground truncate mt-0.5 mono">
|
||||
{detail}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -38,9 +38,7 @@ export function FilterChips({
|
||||
aria-label="Filter"
|
||||
>
|
||||
{eyebrow ? (
|
||||
<span className="mr-1 text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{eyebrow}
|
||||
</span>
|
||||
<span className="eyebrow mr-1">{eyebrow}</span>
|
||||
) : null}
|
||||
{options.map((opt) => {
|
||||
const isActive = opt.value === value;
|
||||
@@ -52,7 +50,7 @@ export function FilterChips({
|
||||
aria-checked={isActive}
|
||||
onClick={() => onChange(isActive ? null : opt.value)}
|
||||
className={cn(
|
||||
"h-7 rounded-full px-3 text-xs font-medium transition-colors",
|
||||
"h-7 rounded-full px-3 text-[11px] font-medium uppercase tracking-[0.1em] transition-colors",
|
||||
"ring-1 ring-inset focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
isActive
|
||||
? "bg-accent/15 text-accent ring-accent/40 hover:bg-accent/20"
|
||||
@@ -69,7 +67,7 @@ export function FilterChips({
|
||||
onClick={() => onChange(null)}
|
||||
aria-label="Clear filter"
|
||||
className={cn(
|
||||
"h-7 rounded-full px-2.5 text-xs font-medium transition-colors",
|
||||
"h-7 rounded-full px-2.5 text-[11px] font-medium uppercase tracking-[0.1em] transition-colors",
|
||||
"inline-flex items-center gap-1",
|
||||
"text-muted-foreground hover:text-foreground",
|
||||
"ring-1 ring-inset ring-border/40 hover:ring-border/60",
|
||||
|
||||
@@ -9,7 +9,11 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
type={type}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-9 w-full rounded-md border border-input bg-background/40 px-3 py-1 text-sm transition-colors",
|
||||
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
||||
"placeholder:text-muted-foreground/60",
|
||||
"focus-visible:outline-none focus-visible:bg-background/80 focus-visible:border-foreground/20",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -25,7 +29,10 @@ const Textarea = React.forwardRef<
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background/60 px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background/40 px-3 py-2 text-sm",
|
||||
"placeholder:text-muted-foreground/60",
|
||||
"focus-visible:outline-none focus-visible:bg-background/80 focus-visible:border-foreground/20",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -14,7 +14,11 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-background/60 px-3 py-1 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-background/40 px-3 py-1 text-sm",
|
||||
"placeholder:text-muted-foreground/60",
|
||||
"focus-visible:outline-none focus-visible:bg-background/80 focus-visible:border-foreground/20",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40",
|
||||
"[&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -35,7 +39,7 @@ const SelectContent = React.forwardRef<
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md surface text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md surface-2 text-popover-foreground shadow-2xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
@@ -64,14 +68,17 @@ const SelectItem = React.forwardRef<
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-muted focus:text-foreground data-[highlighted]:bg-muted data-[highlighted]:text-foreground data-[highlighted]:outline data-[highlighted]:outline-1 data-[highlighted]:outline-accent data-[state=checked]:font-medium data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none",
|
||||
"focus:bg-muted focus:text-foreground",
|
||||
"data-[highlighted]:bg-muted data-[highlighted]:text-foreground data-[highlighted]:outline data-[highlighted]:outline-1 data-[highlighted]:outline-accent",
|
||||
"data-[state=checked]:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
<Check className="h-3.5 w-3.5 text-accent" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
|
||||
@@ -37,8 +37,8 @@ export function Skeleton({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden bg-muted/30",
|
||||
"ring-1 ring-inset ring-border/40",
|
||||
"relative overflow-hidden rounded-md bg-muted/20",
|
||||
"ring-1 ring-inset ring-border/30",
|
||||
variantClass[variant],
|
||||
className,
|
||||
)}
|
||||
@@ -49,10 +49,10 @@ export function Skeleton({
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-y-0 left-0 w-1/2 animate-shimmer"
|
||||
className="absolute inset-y-0 left-0 w-2/3 animate-shimmer"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, transparent 0%, hsl(var(--muted-foreground) / 0.08) 50%, transparent 100%)",
|
||||
"linear-gradient(90deg, transparent 0%, hsl(220 14% 96% / 0.04) 50%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,10 @@ const TableHeader = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead
|
||||
ref={ref}
|
||||
className={cn("[&_tr]:border-b [&_tr]:border-border/60", className)}
|
||||
className={cn(
|
||||
"[&_tr]:border-b [&_tr]:border-border/60 [&_tr]:bg-muted/20",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -45,7 +48,7 @@ const TableRow = React.forwardRef<
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b border-border/40 transition-colors hover:bg-muted/30 focus-within:bg-muted/30 data-[state=selected]:bg-muted",
|
||||
"border-b border-border/40 transition-colors hover:bg-muted/30 focus-within:bg-muted/40 data-[state=selected]:bg-muted/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -61,7 +64,7 @@ const TableHead = React.forwardRef<
|
||||
ref={ref}
|
||||
scope={scope}
|
||||
className={cn(
|
||||
"h-10 px-4 text-left align-middle text-[11px] font-medium uppercase tracking-wider text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
"h-9 px-4 text-left align-middle text-[10.5px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/80 [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
+177
-141
@@ -1,7 +1,16 @@
|
||||
/* Inter Tight (UI sans) + JetBrains Mono (data/mono) for the claim-drawer
|
||||
Modern palette. Loaded via Google Fonts CDN. Project default face (Geist
|
||||
Mono) is preserved by fallback chains in --m-font-mono. */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap");
|
||||
/* =====================================================================
|
||||
Cyclone — design system
|
||||
---------------------------------------------------------------------
|
||||
A precision instrument for one operator on one machine. The visual
|
||||
language borrows from Bloomberg terminals and Linear-grade
|
||||
engineering tools: dark, hairline-strict, mono-heavy, with one
|
||||
editorial serif accent for the moments that deserve weight.
|
||||
---------------------------------------------------------------------
|
||||
Type stack:
|
||||
- Geist Sans · UI, body, controls
|
||||
- Geist Mono · every number, every id, every code-like datum
|
||||
- Instrument Serif · editorial display (hero numbers, headlines)
|
||||
===================================================================== */
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@@ -9,96 +18,98 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Refined Apple-HIG dark — true black with a whisper of blue */
|
||||
--background: 240 6% 4%; /* #0a0a0c */
|
||||
--foreground: 240 6% 96%; /* #f5f5f7 */
|
||||
/* ---- Core surfaces ----
|
||||
A graphite canvas with a whisper of blue. Slightly warm at the
|
||||
midtones (--card) to keep the chrome from feeling sterile. */
|
||||
--background: 222 18% 5%; /* #0b0c10 — near-black, cool */
|
||||
--foreground: 220 14% 96%; /* #f4f5f7 — paper white */
|
||||
|
||||
--card: 240 5% 8%; /* #131316 */
|
||||
--card-foreground: 240 6% 96%;
|
||||
--card: 222 16% 8%; /* #11131a */
|
||||
--card-foreground: 220 14% 96%;
|
||||
|
||||
--popover: 240 5% 9%;
|
||||
--popover-foreground: 240 6% 96%;
|
||||
--popover: 222 18% 9%; /* #13151c */
|
||||
--popover-foreground: 220 14% 96%;
|
||||
|
||||
--muted: 240 4% 12%; /* #1e1e21 */
|
||||
--muted-foreground: 240 4% 61%; /* #9a9a9f */
|
||||
--muted: 222 12% 13%; /* #1f2229 */
|
||||
--muted-foreground: 220 8% 64%; /* #9b9ea6 */
|
||||
|
||||
--secondary: 240 4% 12%;
|
||||
--secondary-foreground: 240 6% 96%;
|
||||
--secondary: 222 12% 13%;
|
||||
--secondary-foreground: 220 14% 96%;
|
||||
|
||||
--accent: 217 100% 56%; /* #1a73ff — electric, more committed than stock */
|
||||
/* ---- Accents ----
|
||||
Electric blue (primary) for actions and emphasis. Amber (signal)
|
||||
is reserved for "needs attention" states and the Inbox terminal
|
||||
accents — never decorative. */
|
||||
--accent: 212 100% 60%; /* #2e8aff — electric, modern */
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--signal: 36 92% 56%; /* #f3a531 — warm amber */
|
||||
|
||||
--primary: 217 100% 56%;
|
||||
--primary: 212 100% 60%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
--destructive: 1 100% 60%; /* #ff453a */
|
||||
--destructive: 358 82% 60%; /* #ec4458 */
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
--success: 142 71% 50%; /* #30d158 */
|
||||
--success-foreground: 144 70% 10%;
|
||||
--success: 152 64% 46%; /* #2cbd76 */
|
||||
--success-foreground: 152 70% 8%;
|
||||
|
||||
--warning: 38 100% 56%; /* #ff9f0a */
|
||||
--warning-foreground: 26 80% 14%;
|
||||
--warning: 36 92% 56%; /* #f3a531 */
|
||||
--warning-foreground: 26 80% 12%;
|
||||
|
||||
--border: 240 4% 14%; /* #232327 */
|
||||
--input: 240 4% 14%;
|
||||
--ring: 217 100% 56%;
|
||||
--border: 222 10% 16%; /* #262932 */
|
||||
--input: 222 10% 16%;
|
||||
--ring: 212 100% 60%;
|
||||
|
||||
--sidebar: 240 6% 5%;
|
||||
--sidebar-foreground: 240 6% 96%;
|
||||
--sidebar-accent: 217 100% 56%;
|
||||
--sidebar-border: 240 4% 12%;
|
||||
/* ---- Sidebar ---- */
|
||||
--sidebar: 222 20% 4%; /* darker than the page */
|
||||
--sidebar-foreground: 220 14% 96%;
|
||||
--sidebar-accent: 212 100% 60%;
|
||||
--sidebar-border: 222 12% 12%;
|
||||
|
||||
--radius: 0.75rem; /* 12px */
|
||||
/* ---- Document surface (drawer detail panels) ----
|
||||
A faint warm paper. Keeps detail views distinct from the chrome
|
||||
without going full light. Pairs with editorial serif headlines. */
|
||||
--surface: 36 18% 96%; /* #f6f3ec */
|
||||
--surface-ink: 30 12% 9%; /* #181614 */
|
||||
--surface-ink-2: 30 6% 32%; /* #4f4b46 */
|
||||
--surface-ink-3: 30 4% 52%; /* #85807a */
|
||||
--surface-line: 30 14% 14%; /* #24221f */
|
||||
--surface-line-soft: 30 14% 14% / 0.12;
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Modern palette (SP4 claim-drawer)
|
||||
------------------------------------------------------------------
|
||||
Light, document-feeling surface tokens for the drawer. Prefixed
|
||||
`--m-` to avoid colliding with the project's existing tokens
|
||||
(e.g. the existing `--accent: 217 100% 56%` electric blue, used
|
||||
via Tailwind utilities everywhere). Same prefix family as the
|
||||
`--sidebar-*` tokens above. The drawer's component CSS will
|
||||
reference these via `var(--m-surface)` etc.
|
||||
Coexists with the dark app shell — the drawer is rendered on a
|
||||
light surface regardless of global theme.
|
||||
------------------------------------------------------------------ */
|
||||
--m-surface: #FAFAF7;
|
||||
--m-ink-primary: #0A0A0A;
|
||||
--m-ink-secondary: #6B6B6B;
|
||||
--m-ink-tertiary: #A0A0A0;
|
||||
--m-border-heavy: #1A1A1A;
|
||||
--m-accent: #1D4ED8;
|
||||
--m-accent-hover: #1E40AF;
|
||||
--m-error: #DC2626;
|
||||
--m-error-bg: #FEF2F2;
|
||||
--m-success: #15803D;
|
||||
--m-success-bg: #F0FDF4;
|
||||
--m-warning: #B45309;
|
||||
--m-warning-bg: #FFFBEB;
|
||||
/* ---- Ticker-tape terminal (Inbox) ----
|
||||
Bloomberg-coded: three accent rails on near-black, with the
|
||||
paper-tone ink for legibility. */
|
||||
--tt-bg: 220 22% 5%; /* #0a0d12 */
|
||||
--tt-bg-elev: 220 18% 9%; /* #13171f */
|
||||
--tt-ink: 36 18% 88%; /* #e2ddd0 */
|
||||
--tt-ink-dim: 220 6% 58%; /* #8e9098 */
|
||||
--tt-amber: 36 88% 56%; /* #efa836 */
|
||||
--tt-oxblood: 354 50% 40%; /* #8a2d35 */
|
||||
--tt-ink-blue: 212 38% 36%; /* #2e4d77 */
|
||||
--tt-muted: 220 4% 38%; /* #5b5d62 */
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
SP6 Inbox — Ticker Tape palette
|
||||
------------------------------------------------------------------
|
||||
Bloomberg-terminal vibe. Three accent rails (oxblood / amber /
|
||||
ink-blue) on near-black surfaces. Prefixed `--tt-` to coexist
|
||||
with the dark app shell tokens. The Inbox page is full-bleed
|
||||
dark and reads as a separate working surface from the rest of
|
||||
the app.
|
||||
------------------------------------------------------------------ */
|
||||
--tt-bg: #0a0e14;
|
||||
--tt-bg-elev: #11161f;
|
||||
--tt-ink: #e6e1d3;
|
||||
--tt-ink-dim: #8a8b8e;
|
||||
--tt-amber: #f4b942;
|
||||
--tt-oxblood: #8b2c2c;
|
||||
--tt-ink-blue: #2c4a6e;
|
||||
--tt-muted: #4a4d54;
|
||||
/* ---- Legacy aliases (--m-*)
|
||||
Kept as thin shims so any older component still using the
|
||||
"warm paper" surface family resolves to the same hue family.
|
||||
New components should prefer --surface-* / --destructive / etc. */
|
||||
--m-surface: hsl(var(--surface));
|
||||
--m-ink-primary: hsl(var(--surface-ink));
|
||||
--m-ink-secondary: hsl(var(--surface-ink-2));
|
||||
--m-ink-tertiary: hsl(var(--surface-ink-3));
|
||||
--m-border-heavy: hsl(var(--surface-line));
|
||||
--m-font-mono: var(--font-mono);
|
||||
--m-error: hsl(var(--destructive));
|
||||
--m-error-bg: hsl(var(--destructive) / 0.08);
|
||||
--m-success: hsl(var(--success));
|
||||
--m-warning: hsl(var(--warning));
|
||||
--m-accent: hsl(var(--accent));
|
||||
|
||||
/* Modern type stack — UI sans + data mono. Fallbacks preserve the
|
||||
existing Geist Mono face for any element that hasn't opted in. */
|
||||
--m-font-sans: "Inter Tight", ui-sans-serif, system-ui, sans-serif;
|
||||
--m-font-mono: "JetBrains Mono", ui-monospace, "Geist Mono", monospace;
|
||||
--radius: 0.625rem; /* 10px — slightly tighter */
|
||||
|
||||
/* ---- Type stack ---- */
|
||||
--font-sans: "Geist", ui-sans-serif, -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
--font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
|
||||
--font-display: "Instrument Serif", "Iowan Old Style", "Apple Garamond", "Baskerville", "Times New Roman", serif;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -111,6 +122,7 @@
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: var(--font-sans);
|
||||
font-feature-settings: "ss01", "cv11", "calt";
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
@@ -127,20 +139,27 @@
|
||||
z-index: 0;
|
||||
background: radial-gradient(
|
||||
55rem 35rem at 100% -15%,
|
||||
hsla(218, 100%, 58%, 0.085),
|
||||
hsla(212, 100%, 60%, 0.07),
|
||||
transparent 62%
|
||||
);
|
||||
}
|
||||
|
||||
/* Fine grain — the substrate of a precision instrument. */
|
||||
/* Hairline grid — the substrate of a precision instrument.
|
||||
Sits behind everything, gives the dark surfaces a sense of
|
||||
scale and the work a sense of "field". */
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
opacity: 0.045;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
|
||||
opacity: 0.5;
|
||||
background-image:
|
||||
linear-gradient(to right, hsl(222 10% 16% / 0.18) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, hsl(222 10% 16% / 0.18) 1px, transparent 1px);
|
||||
background-size: 64px 64px;
|
||||
mask-image: radial-gradient(ellipse 80% 60% at 50% 30%, black 30%, transparent 80%);
|
||||
-webkit-mask-image: radial-gradient(ellipse 80% 60% at 50% 30%, black 30%, transparent 80%);
|
||||
}
|
||||
|
||||
/* Custom scrollbar — minimal, hairline, Apple-style */
|
||||
@@ -164,20 +183,15 @@
|
||||
|
||||
/* Focus ring — accent, 2px, with offset.
|
||||
Components override this with Tailwind focus-visible:ring-* utilities
|
||||
for a ring+offset combo that matches the rest of the design system.
|
||||
This base rule covers anything that doesn't opt in (links in
|
||||
prose, native checkboxes, etc.). */
|
||||
for a ring+offset combo that matches the rest of the design system. */
|
||||
:focus-visible {
|
||||
outline: 2px solid hsl(var(--ring));
|
||||
outline-offset: 2px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Respect reduced-motion users globally. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
@@ -193,11 +207,42 @@
|
||||
font-feature-settings: "tnum", "ss01";
|
||||
}
|
||||
|
||||
/* Display face — used for KPI numbers and data emphasis */
|
||||
/* Display face — Instrument Serif. Used for the moments that need
|
||||
editorial weight: hero numbers, page titles in document surfaces,
|
||||
and the brand mark. */
|
||||
.display {
|
||||
font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.015em;
|
||||
font-feature-settings: "ss01";
|
||||
}
|
||||
|
||||
/* Data mono — Geist Mono with tabular-nums. The voice of every
|
||||
id, code, dollar amount, and timestamp in the app. */
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.04em;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Soft card surface — a 1px hairline + a 1px inner highlight to
|
||||
give the surface a sense of depth without a heavy shadow. */
|
||||
.surface {
|
||||
background-color: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 hsl(0 0% 100% / 0.04),
|
||||
0 1px 2px 0 hsl(0 0% 0% / 0.4);
|
||||
}
|
||||
|
||||
/* Subtle elevated surface — used for cards that should lift a
|
||||
little more than the default. */
|
||||
.surface-2 {
|
||||
background: linear-gradient(180deg, hsl(222 16% 9%) 0%, hsl(222 16% 7.5%) 100%);
|
||||
border: 1px solid hsl(var(--border));
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 hsl(0 0% 100% / 0.05),
|
||||
0 1px 3px 0 hsl(0 0% 0% / 0.5);
|
||||
}
|
||||
|
||||
/* Hairline — a 1px separator that reads as a divider, not a border */
|
||||
@@ -205,16 +250,8 @@
|
||||
border-color: hsl(var(--border) / 0.6);
|
||||
}
|
||||
|
||||
/* Soft card surface with a 1px hairline + subtle inner highlight */
|
||||
.surface {
|
||||
background-color: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 hsl(0 0% 100% / 0.035),
|
||||
0 1px 2px 0 hsl(0 0% 0% / 0.5);
|
||||
}
|
||||
|
||||
/* Active nav indicator — a thin left-aligned accent line */
|
||||
/* Active nav indicator — a thin left-aligned accent line with a
|
||||
subtle glow that matches the instrument aesthetic. */
|
||||
.nav-active {
|
||||
background-color: hsl(var(--muted) / 0.6);
|
||||
color: hsl(var(--foreground));
|
||||
@@ -231,8 +268,7 @@
|
||||
box-shadow: 0 0 12px hsl(var(--accent) / 0.5);
|
||||
}
|
||||
|
||||
/* Skip link — invisible until focused. Sits above the scan element on
|
||||
first paint so it is the first Tab stop in the app. */
|
||||
/* Skip link — invisible until focused. */
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
top: 0.75rem;
|
||||
@@ -264,7 +300,6 @@
|
||||
0 0 0 4px hsl(var(--ring)),
|
||||
0 8px 24px hsl(0 0% 0% / 0.45);
|
||||
}
|
||||
/* Reduced motion: skip the slide. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skip-link {
|
||||
transition: none;
|
||||
@@ -277,44 +312,66 @@
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
height: 1.375rem;
|
||||
min-width: 1.375rem;
|
||||
padding: 0 0.4rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-family: "Geist Mono", ui-monospace, monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Section label — the all-caps eyebrow used everywhere */
|
||||
.eyebrow {
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* Editorial serif headline — for hero numbers and document
|
||||
surfaces. A single line height that breathes. */
|
||||
.editorial {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
/* Row hover state — slightly more refined than the default
|
||||
table hover. A subtle tint + a left accent strip. */
|
||||
.row-hover {
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
.row-hover:hover {
|
||||
background-color: hsl(var(--muted) / 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
.text-pretty {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
.ring-inset-hairline {
|
||||
box-shadow: inset 0 0 0 1px hsl(var(--border) / 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------
|
||||
Print styles — a clean paper-record rendering of detail drawers.
|
||||
When an operator hits Cmd-P / Ctrl-P with the ClaimDrawer (or the
|
||||
future RemitDrawer) open, we strip the app chrome (sidebar, header,
|
||||
close button, skeletons, animations, dark theme), collapse the
|
||||
dialog to a static full-width block, and let its section headers +
|
||||
tables flow onto the page without internal scrolling or backdrop
|
||||
bleed. Result: a readable, ink-friendly paper trail of the claim.
|
||||
---------------------------------------------------------------------- */
|
||||
@media print {
|
||||
/* Hide the app chrome — only the drawer content matters on paper */
|
||||
aside, /* sidebar */
|
||||
header, /* app header within Layout */
|
||||
[data-print="hide"],
|
||||
button[aria-label^="Close"] {
|
||||
aside, header, [data-print="hide"], button[aria-label^="Close"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Reset the dark theme for ink savings and readability */
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #000000;
|
||||
@@ -325,14 +382,10 @@
|
||||
--border: #cccccc;
|
||||
--accent: #1a1a1a;
|
||||
}
|
||||
|
||||
/* Reset the page surface */
|
||||
body {
|
||||
background: #ffffff !important;
|
||||
color: #000000 !important;
|
||||
}
|
||||
|
||||
/* Drawer becomes a static block at full width */
|
||||
[role="dialog"] {
|
||||
position: static !important;
|
||||
inset: auto !important;
|
||||
@@ -344,8 +397,6 @@
|
||||
box-shadow: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* Strip backdrop + animations */
|
||||
[data-state="open"][data-radix-dialog-overlay],
|
||||
.animate-fade-in,
|
||||
.animate-shimmer,
|
||||
@@ -353,27 +404,12 @@
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Show the panel contents without internal scrolling */
|
||||
[role="dialog"] * {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* Tables break gracefully */
|
||||
table {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
/* Avoid splitting a section header from its content */
|
||||
section,
|
||||
h1, h2, h3 {
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
/* Honor reduced motion (also applies to print preview) */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
table { page-break-inside: avoid; }
|
||||
section, h1, h2, h3 { page-break-after: avoid; }
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0s !important;
|
||||
}
|
||||
}
|
||||
|
||||
+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