feat(frontend): /reconciliation page with two-column matching UI
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
import { useState } from "react";
|
||||
import { GitMerge, CircleDashed, 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 { EmptyState } from "@/components/ui/empty-state";
|
||||
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.
|
||||
*
|
||||
* Loading / error / empty states each early-return so the two-column
|
||||
* selection JSX stays readable and doesn't have to defensively check
|
||||
* `unmatched.data` everywhere.
|
||||
*/
|
||||
export function ReconciliationPage() {
|
||||
const { unmatched, match } = useReconciliation();
|
||||
const [selectedClaim, setSelectedClaim] = useState<string | null>(null);
|
||||
const [selectedRemit, setSelectedRemit] = useState<string | null>(null);
|
||||
|
||||
if (unmatched.isLoading) {
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
<header>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
||||
<span className="inline-block h-px w-6 bg-border" />
|
||||
Reconciliation
|
||||
</div>
|
||||
<h1 className="text-[22px] font-semibold tracking-tight">
|
||||
Manual pairing
|
||||
</h1>
|
||||
</header>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Skeleton variant="default" height={320} />
|
||||
<Skeleton variant="default" height={320} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (unmatched.isError) {
|
||||
const detail =
|
||||
unmatched.error instanceof Error
|
||||
? unmatched.error.message
|
||||
: String(unmatched.error);
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
<header>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
||||
<span className="inline-block h-px w-6 bg-border" />
|
||||
Reconciliation
|
||||
</div>
|
||||
<h1 className="text-[22px] font-semibold tracking-tight">
|
||||
Manual pairing
|
||||
</h1>
|
||||
</header>
|
||||
<ErrorState
|
||||
message="Couldn't load unmatched claims and remittances."
|
||||
detail={detail}
|
||||
onRetry={() => unmatched.refetch()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { claims, remittances } = unmatched.data ?? {
|
||||
claims: [],
|
||||
remittances: [],
|
||||
};
|
||||
const empty = claims.length === 0 && remittances.length === 0;
|
||||
|
||||
if (empty) {
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
<header>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
||||
<span className="inline-block h-px w-6 bg-border" />
|
||||
Reconciliation
|
||||
</div>
|
||||
<h1 className="text-[22px] font-semibold tracking-tight">
|
||||
Manual pairing
|
||||
</h1>
|
||||
</header>
|
||||
<div className="surface rounded-xl">
|
||||
<EmptyState
|
||||
icon={<CircleDashed className="h-4 w-4" strokeWidth={1.5} />}
|
||||
eyebrow="Reconciliation · nothing pending"
|
||||
message="Every claim and remittance is paired."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
// ApiError carries the HTTP status; 409 is the backend's "already
|
||||
// matched" signal, so surface a friendlier message than the raw body.
|
||||
const msg =
|
||||
e instanceof ApiError && e.status === 409
|
||||
? "Already matched — view the existing match."
|
||||
: e instanceof Error
|
||||
? e.message
|
||||
: String(e);
|
||||
toast.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setSelectedClaim(null);
|
||||
setSelectedRemit(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
<header>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
||||
<span className="inline-block h-px w-6 bg-border" />
|
||||
Reconciliation
|
||||
</div>
|
||||
<h1 className="text-[22px] font-semibold tracking-tight">
|
||||
Manual pairing
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1.5 text-[14px]">
|
||||
Pick one claim and one remittance, then match them.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Claims column */}
|
||||
<div className="surface rounded-xl border border-border/60 p-4 space-y-2">
|
||||
<h2 className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Unmatched claims ({claims.length})
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{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 rounded-md border transition-colors",
|
||||
active
|
||||
? "border-accent bg-accent/5"
|
||||
: "border-border/60 hover:border-border"
|
||||
)}
|
||||
>
|
||||
<div className="display num text-sm">{c.id}</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">
|
||||
{c.patientControlNumber}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{c.serviceDate ?? "—"} · ${c.chargeAmount.toFixed(2)} · NPI{" "}
|
||||
{c.providerNpi ?? "—"}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remits column */}
|
||||
<div className="surface rounded-xl border border-border/60 p-4 space-y-2">
|
||||
<h2 className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Unmatched remits ({remittances.length})
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{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 rounded-md border transition-colors",
|
||||
active
|
||||
? "border-accent bg-accent/5"
|
||||
: "border-border/60 hover:border-border"
|
||||
)}
|
||||
>
|
||||
<div className="font-mono text-sm flex items-center gap-2">
|
||||
{r.payerClaimControlNumber}
|
||||
{r.isReversal ? (
|
||||
<span className="text-[10px] text-warning uppercase tracking-wider">
|
||||
Reversal
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
Status {r.statusCode} · ${r.totalPaid.toFixed(2)} paid · $
|
||||
{r.adjustmentAmount.toFixed(2)} adj
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleMatch}
|
||||
disabled={!selectedClaim || !selectedRemit || match.isPending}
|
||||
>
|
||||
<GitMerge className="h-3.5 w-3.5 mr-1.5" />
|
||||
Match selected
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClear}
|
||||
disabled={(!selectedClaim && !selectedRemit) || match.isPending}
|
||||
>
|
||||
<X className="h-3.5 w-3.5 mr-1.5" />
|
||||
Clear selection
|
||||
</Button>
|
||||
{selectedClaim || selectedRemit ? (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{selectedClaim ? `Claim ${selectedClaim}` : "No claim"}
|
||||
{" · "}
|
||||
{selectedRemit ? `Remit ${selectedRemit}` : "No remit"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user