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(null); const [selectedRemit, setSelectedRemit] = useState(null); if (unmatched.isLoading) { return (
Manual pairing} />
); } if (unmatched.isError) { const detail = unmatched.error instanceof Error ? unmatched.error.message : String(unmatched.error); return (
Manual pairing} /> unmatched.refetch()} />
); } const { claims, remittances } = unmatched.data ?? { claims: [], remittances: [], }; const empty = claims.length === 0 && remittances.length === 0; if (empty) { return (
Manual pairing} />
} eyebrow="Reconciliation · nothing pending" message="Every claim and remittance is paired." />
); } 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 (
Manual pairing} subtitle="Pick one claim and one remittance, then match them." />
{/* Claims column */}

Unmatched claims {claims.length}

{claims.map((c) => { const active = selectedClaim === c.id; return ( ); })}
{/* Remits column */}

Unmatched remits {remittances.length}

{remittances.map((r) => { const active = selectedRemit === r.id; return ( ); })}
{/* 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. */} {selectedClaim || selectedRemit ? ( {selectedClaim ? `Claim ${selectedClaim}` : "No claim"} {" · "} {selectedRemit ? `Remit ${selectedRemit}` : "No remit"} ) : null}
); }