231 lines
8.5 KiB
TypeScript
231 lines
8.5 KiB
TypeScript
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 { PageHeader } from "@/components/PageHeader";
|
|
import { Button } from "@/components/ui/button";
|
|
import { cn } from "@/lib/utils";
|
|
import { RoleGate } from "@/auth/RoleGate";
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
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-6 lg:space-y-8 animate-fade-in">
|
|
<PageHeader
|
|
eyebrow="Reconciliation"
|
|
title={<>Manual <span className="display italic text-muted-foreground">pairing</span></>}
|
|
/>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<Skeleton variant="default" height={320} />
|
|
<Skeleton variant="default" height={320} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (unmatched.isError) {
|
|
const detail =
|
|
unmatched.error instanceof Error
|
|
? unmatched.error.message
|
|
: String(unmatched.error);
|
|
return (
|
|
<div className="space-y-6 lg:space-y-8 animate-fade-in">
|
|
<PageHeader
|
|
eyebrow="Reconciliation"
|
|
title={<>Manual <span className="display italic text-muted-foreground">pairing</span></>}
|
|
/>
|
|
<ErrorState
|
|
message="Couldn't load unmatched claims and remittances."
|
|
detail={detail}
|
|
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-6 lg:space-y-8 animate-fade-in">
|
|
<PageHeader
|
|
eyebrow="Reconciliation"
|
|
title={<>Manual <span className="display italic text-muted-foreground">pairing</span></>}
|
|
/>
|
|
<div className="surface rounded-xl">
|
|
<EmptyState
|
|
icon={<CircleDashed className="h-4 w-4" strokeWidth={1.5} />}
|
|
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) {
|
|
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-6 lg:space-y-8 animate-fade-in">
|
|
<PageHeader
|
|
eyebrow="Reconciliation"
|
|
title={<>Manual <span className="display italic text-muted-foreground">pairing</span></>}
|
|
subtitle="Pick one claim and one remittance, then match them."
|
|
/>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{/* Claims column */}
|
|
<div className="surface-2 rounded-xl p-4 space-y-2">
|
|
<h2 className="eyebrow flex items-center justify-between">
|
|
Unmatched claims
|
|
<span className="mono text-[10.5px] text-foreground/80">{claims.length}</span>
|
|
</h2>
|
|
<div className="space-y-2">
|
|
{claims.map((c) => {
|
|
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 min-h-[44px] rounded-md border transition-colors text-left",
|
|
active
|
|
? "border-accent bg-accent/10 ring-1 ring-inset ring-accent/30"
|
|
: "border-border/60 hover:border-border hover:bg-muted/30"
|
|
)}
|
|
>
|
|
<div className="display mono text-[13px]">{c.id}</div>
|
|
<div className="text-[12px] text-muted-foreground">
|
|
{c.patientName}
|
|
</div>
|
|
<div className="mono text-[11px] text-muted-foreground mt-0.5">
|
|
{c.serviceDate ?? "—"} · ${c.billedAmount.toFixed(2)} · NPI{" "}
|
|
{c.providerNpi ?? "—"}
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Remits column */}
|
|
<div className="surface-2 rounded-xl p-4 space-y-2">
|
|
<h2 className="eyebrow flex items-center justify-between">
|
|
Unmatched remits
|
|
<span className="mono text-[10.5px] text-foreground/80">{remittances.length}</span>
|
|
</h2>
|
|
<div className="space-y-2">
|
|
{remittances.map((r) => {
|
|
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 min-h-[44px] rounded-md border transition-colors text-left",
|
|
active
|
|
? "border-accent bg-accent/10 ring-1 ring-inset ring-accent/30"
|
|
: "border-border/60 hover:border-border hover:bg-muted/30"
|
|
)}
|
|
>
|
|
<div className="mono text-[13px] flex items-center gap-2">
|
|
{r.payerClaimControlNumber}
|
|
{r.isReversal ? (
|
|
<span className="text-[10px] text-warning uppercase tracking-wider">
|
|
Reversal
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<div className="mono text-[11px] text-muted-foreground mt-0.5">
|
|
Status {r.status} · ${r.paidAmount.toFixed(2)} paid · $
|
|
{r.adjustmentAmount.toFixed(2)} adj
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{/* Match selected is the only true write affordance on this
|
|
page — it transitions a claim from "submitted" /
|
|
"rejected" to "paid" / "partial" / "denied" / "received"
|
|
via `record_manual_match`. Viewers can still pick rows
|
|
and see the selection chrome, but the button itself is
|
|
disabled + tooltip-explained for them. */}
|
|
<RoleGate allow={["admin", "user"]}>
|
|
<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>
|
|
</RoleGate>
|
|
<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 selection
|
|
</Button>
|
|
{selectedClaim || selectedRemit ? (
|
|
<span className="text-[11.5px] text-muted-foreground sm:ml-2 basis-full sm:basis-auto mono">
|
|
{selectedClaim ? `Claim ${selectedClaim}` : "No claim"}
|
|
{" · "}
|
|
{selectedRemit ? `Remit ${selectedRemit}` : "No remit"}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|