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