94990932f9
- Dark editorial hero: 'Manual pairing.' (80px serif, italic pairing)
- 'AUTO-MATCH PAUSED' amber status pill on the right
- Ghost 'PAIRED' watermark behind the hero
- Dramatic torn-page fold with shadow + perforation + 'PAIR THEM BY HAND' label
- Paper plane with title block ('RECONCILIATION · SHEET 01' / 'Pair them.' 96px)
- Folio system (§ 01 Vital signs, § 02 The match, § 03 The action)
- KPI strip with 3 paper-toned tiles (Unmatched claims / remits / in queue)
- Two-column pairing surface with paper cards, blue/amber accent rails
and a center vertical bridge that flips to green-merge when both selected
- Active row uses paper-tinted blue (claim) or amber (remit) backgrounds
- Action footer with italic 'Ready to pair' / 'Now pick a remit' states
- Empty state: 'Nothing pending.' with circle-dashed icon on cream
- Footer: 'END OF SHEET 01 / CYCLONE · RECONCILIATION / N ROWS PENDING'
1036 lines
37 KiB
TypeScript
1036 lines
37 KiB
TypeScript
import { useState } from "react";
|
|
import {
|
|
ArrowRight,
|
|
CircleDashed,
|
|
GitMerge,
|
|
X,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { useReconciliation } from "@/hooks/useReconciliation";
|
|
import { ApiError } from "@/lib/api";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { ErrorState } from "@/components/ui/error-state";
|
|
import { Button } from "@/components/ui/button";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
/**
|
|
* Two-column manual reconciliation surface. The operator picks one row from
|
|
* the claims column, one from the remits column, then clicks "Match
|
|
* selected" to POST /api/reconciliation/match. The hook's invalidation
|
|
* cascade refreshes the unmatched bucket + claims + remits + activity feeds
|
|
* on success.
|
|
*
|
|
* Hybrid layout: dark editorial hero → torn-page fold → cream paper plane
|
|
* with the two pairing columns, a footer with the match action, and a
|
|
* "currently selected" annotation between them.
|
|
*/
|
|
export function ReconciliationPage() {
|
|
const { unmatched, match } = useReconciliation();
|
|
const [selectedClaim, setSelectedClaim] = useState<string | null>(null);
|
|
const [selectedRemit, setSelectedRemit] = useState<string | null>(null);
|
|
|
|
// -----------------------------------------------------------------
|
|
// Stagger choreography
|
|
// -----------------------------------------------------------------
|
|
const heroDelay = 0;
|
|
const foldDelay = 220;
|
|
const titleDelay = 320;
|
|
const kpiDelay = 460;
|
|
const columnsDelay = 600;
|
|
const actionDelay = 760;
|
|
|
|
// ---------- Loading state ----------
|
|
if (unmatched.isLoading) {
|
|
return (
|
|
<div className="space-y-0">
|
|
<section
|
|
className="relative animate-fade-in pt-6 pb-8 lg:pt-9 lg:pb-10"
|
|
style={{ animationDelay: `${heroDelay}ms` }}
|
|
>
|
|
<div className="relative z-10 max-w-3xl">
|
|
<div className="flex items-center gap-3 mb-5">
|
|
<div className="h-px w-14 bg-foreground/25" />
|
|
<span className="mono text-[12px] uppercase tracking-[0.22em] text-muted-foreground">
|
|
Reconciliation
|
|
</span>
|
|
</div>
|
|
<h1 className="display text-[48px] sm:text-[64px] lg:text-[80px] leading-[0.92] text-foreground tracking-[-0.04em]">
|
|
Manual{" "}
|
|
<span className="italic text-muted-foreground/85">pairing.</span>
|
|
</h1>
|
|
</div>
|
|
</section>
|
|
<div
|
|
className="relative max-w-[1280px] mx-auto animate-fade-in-up px-8 lg:px-14 py-8"
|
|
style={{
|
|
animationDelay: `${titleDelay}ms`,
|
|
backgroundColor: "hsl(var(--surface))",
|
|
}}
|
|
>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<Skeleton variant="default" height={320} />
|
|
<Skeleton variant="default" height={320} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------- Error state ----------
|
|
if (unmatched.isError) {
|
|
const detail =
|
|
unmatched.error instanceof Error
|
|
? unmatched.error.message
|
|
: String(unmatched.error);
|
|
return (
|
|
<div className="space-y-0">
|
|
<section
|
|
className="relative animate-fade-in pt-6 pb-8 lg:pt-9 lg:pb-10"
|
|
style={{ animationDelay: `${heroDelay}ms` }}
|
|
>
|
|
<div className="relative z-10 max-w-3xl">
|
|
<div className="flex items-center gap-3 mb-5">
|
|
<div className="h-px w-14 bg-foreground/25" />
|
|
<span className="mono text-[12px] uppercase tracking-[0.22em] text-muted-foreground">
|
|
Reconciliation
|
|
</span>
|
|
</div>
|
|
<h1 className="display text-[48px] sm:text-[64px] lg:text-[80px] leading-[0.92] text-foreground tracking-[-0.04em]">
|
|
Manual{" "}
|
|
<span className="italic text-muted-foreground/85">pairing.</span>
|
|
</h1>
|
|
</div>
|
|
</section>
|
|
<div
|
|
className="relative max-w-[1280px] mx-auto animate-fade-in-up px-8 lg:px-14 py-8"
|
|
style={{
|
|
animationDelay: `${titleDelay}ms`,
|
|
backgroundColor: "hsl(var(--surface))",
|
|
boxShadow:
|
|
"inset 0 1px 0 0 hsl(0 0% 100% / 0.5), 0 1px 0 0 hsl(30 14% 22% / 0.06), 0 30px 80px -24px hsl(0 0% 0% / 0.45)",
|
|
}}
|
|
>
|
|
<ErrorState
|
|
message="Couldn't load unmatched claims and remittances."
|
|
detail={detail}
|
|
onRetry={() => unmatched.refetch()}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const { claims, remittances } = unmatched.data ?? {
|
|
claims: [],
|
|
remittances: [],
|
|
};
|
|
const empty = claims.length === 0 && remittances.length === 0;
|
|
|
|
const handleMatch = async () => {
|
|
if (!selectedClaim || !selectedRemit) return;
|
|
try {
|
|
await match.mutateAsync({ claimId: selectedClaim, remitId: selectedRemit });
|
|
toast.success(`Matched ${selectedClaim} ↔ ${selectedRemit}`);
|
|
setSelectedClaim(null);
|
|
setSelectedRemit(null);
|
|
} catch (e) {
|
|
const msg =
|
|
e instanceof ApiError && e.status === 409
|
|
? "Already matched — view the existing match."
|
|
: e instanceof Error
|
|
? e.message
|
|
: String(e);
|
|
toast.error(msg);
|
|
}
|
|
};
|
|
|
|
const handleClear = () => {
|
|
setSelectedClaim(null);
|
|
setSelectedRemit(null);
|
|
};
|
|
|
|
// ---------- Empty state ----------
|
|
if (empty) {
|
|
return (
|
|
<div className="space-y-0">
|
|
<section
|
|
className="relative animate-fade-in pt-6 pb-8 lg:pt-9 lg:pb-10"
|
|
style={{ animationDelay: `${heroDelay}ms` }}
|
|
>
|
|
<div
|
|
aria-hidden
|
|
className="pointer-events-none select-none absolute inset-x-0 top-[58%] -translate-y-1/2 whitespace-nowrap display text-center"
|
|
style={{
|
|
fontSize: "clamp(160px, 20vw, 300px)",
|
|
letterSpacing: "-0.05em",
|
|
opacity: 0.05,
|
|
lineHeight: 1,
|
|
color: "hsl(var(--surface-ink))",
|
|
}}
|
|
>
|
|
PAIRED
|
|
</div>
|
|
<div className="relative z-10 max-w-3xl">
|
|
<div className="flex items-center gap-3 mb-5">
|
|
<div className="h-px w-14 bg-foreground/25" />
|
|
<span className="mono text-[12px] uppercase tracking-[0.22em] text-muted-foreground">
|
|
Reconciliation
|
|
</span>
|
|
</div>
|
|
<h1 className="display text-[48px] sm:text-[64px] lg:text-[80px] leading-[0.92] text-foreground tracking-[-0.04em]">
|
|
Manual{" "}
|
|
<span className="italic text-muted-foreground/85">pairing.</span>
|
|
</h1>
|
|
<p className="text-[14px] text-muted-foreground mt-5 max-w-xl leading-relaxed">
|
|
Pick one claim and one remittance, then match them. The system
|
|
reconciles automatically when amounts and providers line up;
|
|
the ones that don't are dropped here for you to triage.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Fold */}
|
|
<div
|
|
aria-hidden
|
|
className="relative h-14 animate-fade-in"
|
|
style={{ animationDelay: `${foldDelay}ms` }}
|
|
>
|
|
<div
|
|
className="absolute inset-x-0 top-0 h-1/2"
|
|
style={{
|
|
background:
|
|
"linear-gradient(to bottom, hsl(0 0% 0% / 0.55), transparent)",
|
|
}}
|
|
/>
|
|
<div
|
|
className="absolute inset-x-0 bottom-0 h-1/2"
|
|
style={{
|
|
background:
|
|
"linear-gradient(to top, hsl(30 14% 14% / 0.18), transparent)",
|
|
}}
|
|
/>
|
|
<div
|
|
className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px"
|
|
style={{
|
|
background:
|
|
"linear-gradient(to right, transparent, hsl(30 14% 14% / 0.5) 12%, hsl(30 14% 14% / 0.7) 50%, hsl(30 14% 14% / 0.5) 88%, transparent)",
|
|
}}
|
|
/>
|
|
<div
|
|
className="absolute inset-x-0 top-1/2 -translate-y-1/2 flex justify-between px-2"
|
|
style={{ pointerEvents: "none" }}
|
|
>
|
|
{Array.from({ length: 48 }, (_, i) => (
|
|
<span
|
|
key={i}
|
|
className="block h-[3px] w-[3px] rounded-full"
|
|
style={{ backgroundColor: "hsl(30 14% 14% / 0.22)" }}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="relative z-10 mx-auto h-full flex items-center justify-center gap-3 bg-background px-4 w-fit">
|
|
<span
|
|
className="display italic"
|
|
style={{ color: "hsl(var(--muted-foreground))", fontSize: 15 }}
|
|
>
|
|
↘
|
|
</span>
|
|
<span
|
|
className="mono uppercase tracking-[0.24em]"
|
|
style={{
|
|
color: "hsl(var(--muted-foreground))",
|
|
fontSize: 11,
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
Pair them by hand
|
|
</span>
|
|
<span
|
|
className="display italic"
|
|
style={{ color: "hsl(var(--muted-foreground))", fontSize: 15 }}
|
|
>
|
|
↙
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className="relative max-w-[1280px] mx-auto animate-fade-in-up"
|
|
style={{
|
|
animationDelay: `${titleDelay}ms`,
|
|
backgroundColor: "hsl(var(--surface))",
|
|
boxShadow:
|
|
"inset 0 1px 0 0 hsl(0 0% 100% / 0.5), 0 1px 0 0 hsl(30 14% 22% / 0.06), 0 30px 80px -24px hsl(0 0% 0% / 0.45)",
|
|
}}
|
|
>
|
|
<div
|
|
aria-hidden
|
|
className="pointer-events-none absolute inset-0 opacity-[0.04]"
|
|
style={{
|
|
backgroundImage:
|
|
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.1 0 0 0 0 0.08 0 0 0 0 0.05 0 0 0 0.9 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>\")",
|
|
backgroundSize: "160px 160px",
|
|
mixBlendMode: "multiply",
|
|
}}
|
|
/>
|
|
<div className="relative px-8 lg:px-14 py-16 text-center">
|
|
<CircleDashed
|
|
className="h-10 w-10 mx-auto mb-4"
|
|
strokeWidth={1.25}
|
|
style={{ color: "hsl(var(--surface-ink-3))" }}
|
|
/>
|
|
<div
|
|
className="display italic mb-2"
|
|
style={{ color: "hsl(var(--surface-ink-2))", fontSize: 28 }}
|
|
>
|
|
Nothing pending.
|
|
</div>
|
|
<div
|
|
className="mono text-[11px] uppercase tracking-[0.18em]"
|
|
style={{ color: "hsl(var(--surface-ink-3))" }}
|
|
>
|
|
Every claim and remittance is paired.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------- Main rendering ----------
|
|
return (
|
|
<div className="space-y-0">
|
|
{/* HERO */}
|
|
<section
|
|
className="relative animate-fade-in pt-6 pb-8 lg:pt-9 lg:pb-10"
|
|
style={{ animationDelay: `${heroDelay}ms` }}
|
|
>
|
|
{/* Ghost watermark */}
|
|
<div
|
|
aria-hidden
|
|
className="pointer-events-none select-none absolute inset-x-0 top-[58%] -translate-y-1/2 whitespace-nowrap display text-center"
|
|
style={{
|
|
fontSize: "clamp(160px, 20vw, 300px)",
|
|
letterSpacing: "-0.05em",
|
|
opacity: 0.05,
|
|
lineHeight: 1,
|
|
color: "hsl(var(--surface-ink))",
|
|
}}
|
|
>
|
|
PAIRED
|
|
</div>
|
|
|
|
<div className="relative z-10 grid grid-cols-1 lg:grid-cols-[1fr,auto] gap-8 items-end mb-7">
|
|
<div className="min-w-0 max-w-3xl">
|
|
<div className="flex items-center gap-3 mb-5">
|
|
<div className="h-px w-14 bg-foreground/25" />
|
|
<span className="mono text-[12px] uppercase tracking-[0.22em] text-muted-foreground">
|
|
Reconciliation
|
|
</span>
|
|
<span
|
|
className="mono text-[12px] uppercase tracking-[0.22em] text-muted-foreground/60 hidden sm:inline"
|
|
aria-hidden
|
|
>
|
|
·{" "}
|
|
{claims.length + remittances.length} need eyes
|
|
</span>
|
|
</div>
|
|
<h1 className="display text-[48px] sm:text-[64px] lg:text-[80px] leading-[0.92] text-foreground tracking-[-0.04em]">
|
|
Manual{" "}
|
|
<span className="italic text-muted-foreground/85">pairing.</span>
|
|
</h1>
|
|
</div>
|
|
<div className="flex flex-col items-start lg:items-end gap-3">
|
|
<div
|
|
className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/60 px-3.5 py-2 text-[11.5px] mono uppercase tracking-[0.14em] text-muted-foreground backdrop-blur"
|
|
>
|
|
<span className="relative inline-flex h-1.5 w-1.5">
|
|
<span className="absolute inline-flex h-full w-full rounded-full bg-[hsl(var(--warning))] opacity-60 animate-ping" />
|
|
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-[hsl(var(--warning))]" />
|
|
</span>
|
|
Auto-match paused
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative z-10 max-w-2xl">
|
|
<p className="text-[14px] text-muted-foreground leading-relaxed">
|
|
Pick one claim and one remittance, then match them. The system
|
|
reconciles automatically when amounts and providers line up; the
|
|
ones that don't are dropped here for you to triage.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
{/* FOLD */}
|
|
<div
|
|
aria-hidden
|
|
className="relative h-14 animate-fade-in"
|
|
style={{ animationDelay: `${foldDelay}ms` }}
|
|
>
|
|
<div
|
|
className="absolute inset-x-0 top-0 h-1/2"
|
|
style={{
|
|
background:
|
|
"linear-gradient(to bottom, hsl(0 0% 0% / 0.55), transparent)",
|
|
}}
|
|
/>
|
|
<div
|
|
className="absolute inset-x-0 bottom-0 h-1/2"
|
|
style={{
|
|
background:
|
|
"linear-gradient(to top, hsl(30 14% 14% / 0.18), transparent)",
|
|
}}
|
|
/>
|
|
<div
|
|
className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px"
|
|
style={{
|
|
background:
|
|
"linear-gradient(to right, transparent, hsl(30 14% 14% / 0.5) 12%, hsl(30 14% 14% / 0.7) 50%, hsl(30 14% 14% / 0.5) 88%, transparent)",
|
|
}}
|
|
/>
|
|
<div
|
|
className="absolute inset-x-0 top-1/2 -translate-y-1/2 flex justify-between px-2"
|
|
style={{ pointerEvents: "none" }}
|
|
>
|
|
{Array.from({ length: 48 }, (_, i) => (
|
|
<span
|
|
key={i}
|
|
className="block h-[3px] w-[3px] rounded-full"
|
|
style={{ backgroundColor: "hsl(30 14% 14% / 0.22)" }}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="relative z-10 mx-auto h-full flex items-center justify-center gap-3 bg-background px-4 w-fit">
|
|
<span
|
|
className="display italic"
|
|
style={{ color: "hsl(var(--muted-foreground))", fontSize: 15 }}
|
|
>
|
|
↘
|
|
</span>
|
|
<span
|
|
className="mono uppercase tracking-[0.24em]"
|
|
style={{
|
|
color: "hsl(var(--muted-foreground))",
|
|
fontSize: 11,
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
Pair them by hand
|
|
</span>
|
|
<span
|
|
className="display italic"
|
|
style={{ color: "hsl(var(--muted-foreground))", fontSize: 15 }}
|
|
>
|
|
↙
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* PAPER PLANE */}
|
|
<div
|
|
className="relative max-w-[1280px] mx-auto animate-fade-in-up"
|
|
style={{
|
|
animationDelay: `${titleDelay}ms`,
|
|
backgroundColor: "hsl(var(--surface))",
|
|
boxShadow:
|
|
"inset 0 1px 0 0 hsl(0 0% 100% / 0.5), 0 1px 0 0 hsl(30 14% 22% / 0.06), 0 30px 80px -24px hsl(0 0% 0% / 0.45)",
|
|
}}
|
|
>
|
|
{/* Paper grain */}
|
|
<div
|
|
aria-hidden
|
|
className="pointer-events-none absolute inset-0 opacity-[0.04]"
|
|
style={{
|
|
backgroundImage:
|
|
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.1 0 0 0 0 0.08 0 0 0 0 0.05 0 0 0 0.9 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>\")",
|
|
backgroundSize: "160px 160px",
|
|
mixBlendMode: "multiply",
|
|
}}
|
|
/>
|
|
|
|
{/* Title block */}
|
|
<div className="relative px-8 lg:px-14 pt-12 pb-9 border-b border-[hsl(30_14%_14%/_0.12)]">
|
|
<div
|
|
aria-hidden
|
|
className="absolute left-7 lg:left-12 top-0 bottom-0 w-px"
|
|
style={{ backgroundColor: "hsl(30 14% 14% / 0.14)" }}
|
|
/>
|
|
<div className="flex items-end justify-between gap-8 flex-wrap">
|
|
<div>
|
|
<div
|
|
className="mono text-[12px] uppercase tracking-[0.24em] mb-4 font-medium"
|
|
style={{ color: "hsl(var(--surface-ink-3))" }}
|
|
>
|
|
Reconciliation · Sheet 01
|
|
</div>
|
|
<h2
|
|
className="display leading-[0.92] tracking-[-0.04em]"
|
|
style={{
|
|
color: "hsl(var(--surface-ink))",
|
|
fontSize: "clamp(48px, 7vw, 96px)",
|
|
fontWeight: 400,
|
|
}}
|
|
>
|
|
Pair them.
|
|
</h2>
|
|
<div
|
|
className="mt-4 display italic"
|
|
style={{
|
|
color: "hsl(var(--surface-ink-2))",
|
|
fontSize: "clamp(15px, 1.3vw, 18px)",
|
|
lineHeight: 1.4,
|
|
maxWidth: "32ch",
|
|
}}
|
|
>
|
|
One claim, one remit. The system learns from every pair you
|
|
confirm — make them count.
|
|
</div>
|
|
</div>
|
|
<div className="text-right shrink-0">
|
|
<div
|
|
className="mono text-[11px] uppercase tracking-[0.24em] mb-1.5 font-medium"
|
|
style={{ color: "hsl(var(--surface-ink-3))" }}
|
|
>
|
|
In the queue
|
|
</div>
|
|
<div
|
|
className="display tabular-nums"
|
|
style={{
|
|
color: "hsl(var(--surface-ink))",
|
|
fontSize: 26,
|
|
lineHeight: 1.1,
|
|
}}
|
|
>
|
|
{claims.length + remittances.length}
|
|
</div>
|
|
<div
|
|
className="mono text-[11px] mt-1"
|
|
style={{ color: "hsl(var(--surface-ink-3))" }}
|
|
>
|
|
{claims.length} claim{claims.length === 1 ? "" : "s"} ·{" "}
|
|
{remittances.length} remit
|
|
{remittances.length === 1 ? "" : "s"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* KPI strip — § 01 Vital signs */}
|
|
<section
|
|
aria-label="Reconciliation metrics"
|
|
className="relative px-8 lg:px-14 py-7 animate-fade-in-up"
|
|
style={{ animationDelay: `${kpiDelay}ms` }}
|
|
>
|
|
<div
|
|
aria-hidden
|
|
className="absolute left-7 lg:left-12 top-7 flex flex-col items-center gap-1"
|
|
>
|
|
<span
|
|
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
|
|
style={{ color: "hsl(var(--surface-ink-3))" }}
|
|
>
|
|
§ 01
|
|
</span>
|
|
<div
|
|
className="w-px h-10"
|
|
style={{ backgroundColor: "hsl(30 14% 14% / 0.18)" }}
|
|
/>
|
|
<span
|
|
className="display italic"
|
|
style={{
|
|
color: "hsl(var(--surface-ink-2))",
|
|
fontSize: 11,
|
|
writingMode: "vertical-rl",
|
|
transform: "rotate(180deg)",
|
|
letterSpacing: "0.16em",
|
|
}}
|
|
>
|
|
Vital signs
|
|
</span>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<KpiTile
|
|
label="Unmatched claims"
|
|
value={claims.length}
|
|
tone="blue"
|
|
/>
|
|
<KpiTile
|
|
label="Unmatched remits"
|
|
value={remittances.length}
|
|
tone="amber"
|
|
/>
|
|
<KpiTile
|
|
label="In the queue"
|
|
value={claims.length + remittances.length}
|
|
tone="ink"
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Two-column pairing surface — § 02 The match */}
|
|
<section
|
|
aria-label="Pick a claim and a remit"
|
|
className="relative px-8 lg:px-14 pb-8 lg:pb-10 animate-fade-in-up"
|
|
style={{ animationDelay: `${columnsDelay}ms` }}
|
|
>
|
|
<div
|
|
aria-hidden
|
|
className="absolute left-7 lg:left-12 top-9 flex flex-col items-center gap-1"
|
|
>
|
|
<span
|
|
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
|
|
style={{ color: "hsl(var(--surface-ink-3))" }}
|
|
>
|
|
§ 02
|
|
</span>
|
|
<div
|
|
className="w-px h-12"
|
|
style={{ backgroundColor: "hsl(30 14% 14% / 0.18)" }}
|
|
/>
|
|
<span
|
|
className="display italic"
|
|
style={{
|
|
color: "hsl(var(--surface-ink-2))",
|
|
fontSize: 11,
|
|
writingMode: "vertical-rl",
|
|
transform: "rotate(180deg)",
|
|
letterSpacing: "0.16em",
|
|
}}
|
|
>
|
|
The match
|
|
</span>
|
|
</div>
|
|
|
|
<div
|
|
className="pt-6 border-t"
|
|
style={{
|
|
borderTopStyle: "double",
|
|
borderTopWidth: 3,
|
|
borderColor: "hsl(30 14% 14% / 0.12)",
|
|
}}
|
|
>
|
|
<div className="flex items-end justify-between gap-6 flex-wrap mb-5">
|
|
<div>
|
|
<div
|
|
className="mono text-[11.5px] uppercase tracking-[0.24em] font-semibold mb-2"
|
|
style={{ color: "hsl(var(--surface-ink-3))" }}
|
|
>
|
|
The match
|
|
</div>
|
|
<h3
|
|
className="display leading-[0.98] tracking-[-0.03em]"
|
|
style={{
|
|
color: "hsl(var(--surface-ink))",
|
|
fontSize: "clamp(24px, 2.6vw, 32px)",
|
|
fontWeight: 400,
|
|
}}
|
|
>
|
|
Pick one of <span className="italic">each.</span>
|
|
</h3>
|
|
</div>
|
|
<div
|
|
className="text-[12.5px] display italic"
|
|
style={{ color: "hsl(var(--surface-ink-2))", maxWidth: 380 }}
|
|
>
|
|
Tap a row on the left, tap a row on the right, then send the
|
|
pair.
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-[1fr,auto,1fr] gap-4 items-stretch">
|
|
{/* Claims column */}
|
|
<PairColumn
|
|
eyebrow="Claims"
|
|
count={claims.length}
|
|
tone="blue"
|
|
empty={claims.length === 0}
|
|
emptyMessage="No unmatched claims."
|
|
>
|
|
{claims.map((c) => {
|
|
const active = selectedClaim === c.id;
|
|
return (
|
|
<button
|
|
key={c.id}
|
|
type="button"
|
|
onClick={() => setSelectedClaim(c.id)}
|
|
aria-pressed={active}
|
|
className={cn(
|
|
"w-full text-left p-3.5 min-h-[44px] rounded-md border transition-colors",
|
|
active
|
|
? "border-[hsl(212_100%_45%)] bg-[hsl(212_85%_95%)] ring-1 ring-inset ring-[hsl(212_100%_45%_/_0.30)]"
|
|
: "border-[hsl(30_14%_14%/_0.14)] bg-[hsl(36_22%_98%)] hover:bg-[hsl(36_22%_94%)] hover:border-[hsl(30_14%_14%/_0.25)]"
|
|
)}
|
|
>
|
|
<div
|
|
className="display mono text-[13.5px]"
|
|
style={{ color: "hsl(var(--surface-ink))" }}
|
|
>
|
|
{c.id}
|
|
</div>
|
|
<div
|
|
className="text-[12.5px] mt-0.5"
|
|
style={{ color: "hsl(var(--surface-ink-2))" }}
|
|
>
|
|
{c.patientName}
|
|
</div>
|
|
<div
|
|
className="mono text-[11px] mt-1"
|
|
style={{ color: "hsl(var(--surface-ink-3))" }}
|
|
>
|
|
{c.serviceDate ?? "—"} · $
|
|
{c.billedAmount.toFixed(2)} · NPI{" "}
|
|
{c.providerNpi ?? "—"}
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</PairColumn>
|
|
|
|
{/* Center bridge — a vertical "→" between the two columns */}
|
|
<div className="hidden md:flex flex-col items-center justify-center px-2">
|
|
<div
|
|
aria-hidden
|
|
className="w-px flex-1"
|
|
style={{ backgroundColor: "hsl(30 14% 14% / 0.14)" }}
|
|
/>
|
|
<div
|
|
className="my-3 h-10 w-10 rounded-full flex items-center justify-center border"
|
|
style={{
|
|
backgroundColor: selectedClaim && selectedRemit
|
|
? "hsl(152 50% 88%)"
|
|
: "hsl(36 22% 92%)",
|
|
borderColor: selectedClaim && selectedRemit
|
|
? "hsl(152 64% 38% / 0.4)"
|
|
: "hsl(30 14% 14% / 0.18)",
|
|
color: selectedClaim && selectedRemit
|
|
? "hsl(152 64% 30%)"
|
|
: "hsl(var(--surface-ink-3))",
|
|
}}
|
|
>
|
|
{selectedClaim && selectedRemit ? (
|
|
<GitMerge className="h-4 w-4" strokeWidth={1.75} />
|
|
) : (
|
|
<ArrowRight
|
|
className="h-4 w-4 rotate-90"
|
|
strokeWidth={1.75}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div
|
|
aria-hidden
|
|
className="w-px flex-1"
|
|
style={{ backgroundColor: "hsl(30 14% 14% / 0.14)" }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Remits column */}
|
|
<PairColumn
|
|
eyebrow="Remits"
|
|
count={remittances.length}
|
|
tone="amber"
|
|
empty={remittances.length === 0}
|
|
emptyMessage="No unmatched remits."
|
|
>
|
|
{remittances.map((r) => {
|
|
const active = selectedRemit === r.id;
|
|
return (
|
|
<button
|
|
key={r.id}
|
|
type="button"
|
|
onClick={() => setSelectedRemit(r.id)}
|
|
aria-pressed={active}
|
|
className={cn(
|
|
"w-full text-left p-3.5 min-h-[44px] rounded-md border transition-colors",
|
|
active
|
|
? "border-[hsl(36_92%_50%)] bg-[hsl(36_82%_92%)] ring-1 ring-inset ring-[hsl(36_92%_50%_/_0.30)]"
|
|
: "border-[hsl(30_14%_14%/_0.14)] bg-[hsl(36_22%_98%)] hover:bg-[hsl(36_22%_94%)] hover:border-[hsl(30_14%_14%/_0.25)]"
|
|
)}
|
|
>
|
|
<div
|
|
className="mono text-[13.5px] flex items-center gap-2"
|
|
style={{ color: "hsl(var(--surface-ink))" }}
|
|
>
|
|
<span className="display">{r.payerClaimControlNumber}</span>
|
|
{r.isReversal ? (
|
|
<span
|
|
className="text-[10px] uppercase tracking-[0.18em] mono font-semibold"
|
|
style={{ color: "hsl(36 92% 30%)" }}
|
|
>
|
|
Reversal
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<div
|
|
className="mono text-[11px] mt-1"
|
|
style={{ color: "hsl(var(--surface-ink-3))" }}
|
|
>
|
|
Status {r.status} · ${r.paidAmount.toFixed(2)} paid · $
|
|
{r.adjustmentAmount.toFixed(2)} adj
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</PairColumn>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Action footer — § 03 The action */}
|
|
<section
|
|
aria-label="Match action"
|
|
className="relative px-8 lg:px-14 pb-8 animate-fade-in-up"
|
|
style={{ animationDelay: `${actionDelay}ms` }}
|
|
>
|
|
<div
|
|
aria-hidden
|
|
className="absolute left-7 lg:left-12 top-7 flex flex-col items-center gap-1"
|
|
>
|
|
<span
|
|
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
|
|
style={{ color: "hsl(var(--surface-ink-3))" }}
|
|
>
|
|
§ 03
|
|
</span>
|
|
<div
|
|
className="w-px h-10"
|
|
style={{ backgroundColor: "hsl(30 14% 14% / 0.18)" }}
|
|
/>
|
|
<span
|
|
className="display italic"
|
|
style={{
|
|
color: "hsl(var(--surface-ink-2))",
|
|
fontSize: 11,
|
|
writingMode: "vertical-rl",
|
|
transform: "rotate(180deg)",
|
|
letterSpacing: "0.16em",
|
|
}}
|
|
>
|
|
The action
|
|
</span>
|
|
</div>
|
|
<div
|
|
className="pt-5 border-t flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
|
style={{ borderColor: "hsl(30 14% 14% / 0.10)" }}
|
|
>
|
|
<div
|
|
className="text-[12.5px] display italic"
|
|
style={{ color: "hsl(var(--surface-ink-2))" }}
|
|
>
|
|
{selectedClaim && selectedRemit ? (
|
|
<>
|
|
Ready to pair{" "}
|
|
<span
|
|
className="display mono not-italic"
|
|
style={{ color: "hsl(var(--surface-ink))" }}
|
|
>
|
|
{selectedClaim}
|
|
</span>{" "}
|
|
with{" "}
|
|
<span
|
|
className="display mono not-italic"
|
|
style={{ color: "hsl(var(--surface-ink))" }}
|
|
>
|
|
{selectedRemit}
|
|
</span>
|
|
.
|
|
</>
|
|
) : selectedClaim ? (
|
|
<>Now pick a remit on the right.</>
|
|
) : selectedRemit ? (
|
|
<>Now pick a claim on the left.</>
|
|
) : (
|
|
<>Pick one from each side to begin.</>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleClear}
|
|
disabled={(!selectedClaim && !selectedRemit) || match.isPending}
|
|
className="min-h-[44px] sm:min-h-0"
|
|
>
|
|
<X className="h-3.5 w-3.5 mr-1.5" />
|
|
Clear
|
|
</Button>
|
|
<Button
|
|
onClick={handleMatch}
|
|
disabled={!selectedClaim || !selectedRemit || match.isPending}
|
|
className="min-h-[44px] sm:min-h-0"
|
|
>
|
|
<GitMerge className="h-3.5 w-3.5 mr-1.5" />
|
|
Match selected
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Footer */}
|
|
<div
|
|
className="relative px-8 lg:px-14 py-5 border-t flex items-center justify-between mono text-[10.5px] uppercase tracking-[0.18em]"
|
|
style={{
|
|
borderColor: "hsl(30 14% 14% / 0.12)",
|
|
color: "hsl(var(--surface-ink-3))",
|
|
}}
|
|
>
|
|
<span>End of sheet 01</span>
|
|
<span>Cyclone · Reconciliation</span>
|
|
<span>
|
|
{claims.length + remittances.length}{" "}
|
|
{claims.length + remittances.length === 1 ? "row" : "rows"} pending
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper components
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function KpiTile({
|
|
label,
|
|
value,
|
|
tone,
|
|
}: {
|
|
label: string;
|
|
value: number;
|
|
tone: "blue" | "amber" | "ink";
|
|
}) {
|
|
const colorMap = {
|
|
blue: "hsl(212 100% 45%)",
|
|
amber: "hsl(36 92% 50%)",
|
|
ink: "hsl(var(--surface-ink))",
|
|
} as const;
|
|
const tintMap = {
|
|
blue: "hsl(212 85% 95%)",
|
|
amber: "hsl(36 82% 92%)",
|
|
ink: "hsl(36 22% 92%)",
|
|
} as const;
|
|
return (
|
|
<div
|
|
className="relative rounded-xl p-5 overflow-hidden border"
|
|
style={{
|
|
backgroundColor: "hsl(var(--surface))",
|
|
boxShadow:
|
|
"inset 0 1px 0 0 hsl(0 0% 100% / 0.45), 0 1px 0 0 hsl(30 14% 22% / 0.06), inset 3px 0 0 0 hsl(0 0% 100% / 0.4)",
|
|
borderColor: "hsl(30 14% 14% / 0.10)",
|
|
}}
|
|
>
|
|
<div
|
|
aria-hidden
|
|
className="absolute left-0 top-5 bottom-5 w-[3px] rounded-r-sm"
|
|
style={{ backgroundColor: colorMap[tone], opacity: 0.85 }}
|
|
/>
|
|
<div className="flex items-center justify-between mb-2.5">
|
|
<div
|
|
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold"
|
|
style={{ color: "hsl(var(--surface-ink-3))" }}
|
|
>
|
|
{label}
|
|
</div>
|
|
<div
|
|
className="h-5 w-5 rounded-md flex items-center justify-center"
|
|
style={{ backgroundColor: tintMap[tone] }}
|
|
>
|
|
<span
|
|
className="block h-1.5 w-1.5 rounded-full"
|
|
style={{ backgroundColor: colorMap[tone] }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div
|
|
className="display tabular-nums tracking-[-0.04em]"
|
|
style={{
|
|
color: "hsl(var(--surface-ink))",
|
|
fontSize: "clamp(32px, 3.6vw, 44px)",
|
|
lineHeight: 1,
|
|
fontWeight: 400,
|
|
}}
|
|
>
|
|
{value}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PairColumn({
|
|
eyebrow,
|
|
count,
|
|
tone,
|
|
empty,
|
|
emptyMessage,
|
|
children,
|
|
}: {
|
|
eyebrow: string;
|
|
count: number;
|
|
tone: "blue" | "amber";
|
|
empty: boolean;
|
|
emptyMessage: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
const accent =
|
|
tone === "blue" ? "hsl(212 100% 45%)" : "hsl(36 92% 50%)";
|
|
const tint =
|
|
tone === "blue" ? "hsl(212 85% 95%)" : "hsl(36 82% 92%)";
|
|
return (
|
|
<div
|
|
className="rounded-xl border overflow-hidden"
|
|
style={{
|
|
backgroundColor: "hsl(36 22% 96%)",
|
|
borderColor: "hsl(30 14% 14% / 0.10)",
|
|
boxShadow:
|
|
"inset 0 1px 0 0 hsl(0 0% 100% / 0.45), 0 1px 0 0 hsl(30 14% 22% / 0.06)",
|
|
}}
|
|
>
|
|
<div
|
|
className="px-4 py-3 border-b flex items-center justify-between"
|
|
style={{
|
|
borderColor: "hsl(30 14% 14% / 0.08)",
|
|
backgroundColor: "hsl(36 22% 92%)",
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-2.5">
|
|
<div
|
|
aria-hidden
|
|
className="h-2.5 w-2.5 rounded-sm"
|
|
style={{ backgroundColor: accent }}
|
|
/>
|
|
<span
|
|
className="mono text-[10.5px] uppercase tracking-[0.20em] font-semibold"
|
|
style={{ color: "hsl(var(--surface-ink-2))" }}
|
|
>
|
|
{eyebrow}
|
|
</span>
|
|
</div>
|
|
<span
|
|
className="display mono tabular-nums"
|
|
style={{
|
|
color: "hsl(var(--surface-ink))",
|
|
fontSize: 16,
|
|
backgroundColor: tint,
|
|
padding: "2px 8px",
|
|
borderRadius: 3,
|
|
border: "1px solid hsl(30 14% 14% / 0.10)",
|
|
}}
|
|
>
|
|
{count}
|
|
</span>
|
|
</div>
|
|
<div className="p-3 space-y-2 min-h-[200px] max-h-[420px] overflow-y-auto">
|
|
{empty ? (
|
|
<div
|
|
className="text-center py-8 text-[12.5px] display italic"
|
|
style={{ color: "hsl(var(--surface-ink-3))" }}
|
|
>
|
|
{emptyMessage}
|
|
</div>
|
|
) : (
|
|
children
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|