feat(ui): cohesive frontend polish — design system + per-screen refinement

Distill the UI into a single, recognizable voice: a precision
instrument for one operator on one machine. Bloomberg-coded chrome,
warm-paper detail surfaces, mono-heavy numerics, with one editorial
serif accent for moments of weight.

Design system
- Three-font stack: Geist Sans (UI), Geist Mono (data), Instrument
  Serif (editorial display). No Inter, no system fonts.
- Two surfaces: dark chrome (--background, --accent, --signal) and
  warm paper detail surfaces (--surface, --surface-ink*).
- Inbox has its own Ticker Tape terminal palette (--tt-*).
- Shared component classes: .eyebrow, .mono, .display, .surface,
  .surface-2, .row-hover, .nav-active, .kbd, .editorial, .hairline.
- --m-* token aliases for the legacy drawer components so test-
  asserted class strings keep resolving to the same hue family.

Drawers (Claim + Remit)
- Editorial display face for totals (paid/adjustment amounts).
- Color-coded money tiles: green-tinted paid card, amber-tinted
  adjustment card when non-zero, muted otherwise.
- Tabs get accent-blue underline + CSS-driven active state.
- Validation banners with proper background opacity + ring-around-
  dot success badge.
- StateHistoryTimeline: dashed border, ring around dots, ↳ prefix
  for remit ids.
- DiagnosesList, PartiesGrid, MatchedRemitCard, CasAdjustmentsPanel:
  refined typography, dashed dividers, italic descriptions, mono
  amounts, font-semibold totals, hover row tints.

Inbox Ticker Tape
- Custom RowCheckbox with sr-only input + amber accent.
- Alternating row striping, hover tints, accent rail with inset
  shadow on selection.
- Refined sparkline with glow at high values.
- BulkBar: bottom-floating bar with amber count chip, larger shadow.
- CandidateBreakdown: animated progress bars with amber gradient.
- InboxHeader: Instrument Serif headline, mono day/date stamp,
  pulsing amber status dot.

Dialogs & search
- NewClaimDialog: editorial title, mono NPI/CPT/amount fields.
- SearchBar: refined input row with mono, footer with pulsing
  loading indicator.
- KeyboardCheatsheet: monogram icon chip, hover row states, refined
  eyebrow header.

Primitives
- StatusBadge / ClaimStateBadge: per-state dot indicators.
- SelectItem: data-[highlighted]:outline tokens for keyboard a11y.
- Table primitives: refined header treatment, hover/focus states.

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