Fintech & Payments

Reconciliation Table

A bank-vs-ledger reconciliation table with match-confidence chips, a segmented filter that animates rows in and out, and one-click confirmation of suggested matches.

Install

npx shadcn@latest add @paragon/reconciliation-table

reconciliation-table.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowRight, Check, HelpCircle, Link2 } from "lucide-react";
import { cn } from "@/lib/utils";

export interface ReconRow {
  id: string;
  /** Bank-side description. */
  bank: string;
  /** Ledger-side description, if a candidate match exists. */
  ledger?: string;
  amount: number;
  date: string;
  /** Match confidence 0–1. Undefined = unmatched. */
  confidence?: number;
}

export interface ReconciliationTableProps extends React.ComponentProps<"div"> {
  rows: ReconRow[];
  currency?: string;
  onMatch?: (id: string) => void;
}

type Filter = "all" | "matched" | "unmatched";

function confidenceMeta(c?: number) {
  if (c === undefined)
    return { label: "Unmatched", cls: "bg-muted text-muted-foreground" };
  if (c >= 0.9) return { label: "High", cls: "bg-success/10 text-success" };
  if (c >= 0.6) return { label: "Review", cls: "bg-warning/15 text-warning" };
  return { label: "Low", cls: "bg-destructive/10 text-destructive" };
}

/**
 * A bank-vs-ledger reconciliation table. Each row shows the bank line, its
 * candidate ledger match, and a confidence chip (high / review / low /
 * unmatched). A segmented filter narrows the view; rows animate in and out
 * with the house translate-and-fade as the filter changes, and confirming a
 * suggested match flips the row to matched in place. A summary bar tallies
 * the matched count and total. Reduced motion cross-fades.
 */
export function ReconciliationTable({
  rows,
  currency = "USD",
  onMatch,
  className,
  ...props
}: ReconciliationTableProps) {
  const reduced = useReducedMotion();
  const [filter, setFilter] = React.useState<Filter>("all");
  const [confirmed, setConfirmed] = React.useState<Record<string, boolean>>({});

  const money = React.useMemo(
    () => new Intl.NumberFormat("en-US", { style: "currency", currency }),
    [currency],
  );

  const resolved = rows.map((r) => ({
    ...r,
    matched: confirmed[r.id] || (r.confidence ?? 0) >= 0.9,
  }));

  const visible = resolved.filter((r) =>
    filter === "all"
      ? true
      : filter === "matched"
        ? r.matched
        : !r.matched,
  );

  const matchedCount = resolved.filter((r) => r.matched).length;

  return (
    <div
      className={cn(
        "w-full max-w-xl overflow-hidden rounded-xl bg-card shadow-border",
        className,
      )}
      {...props}
    >
      <div className="flex items-center justify-between gap-3 border-b p-3">
        <h3 className="text-sm font-medium">Reconciliation</h3>
        <div
          role="tablist"
          aria-label="Filter rows"
          className="flex rounded-lg bg-muted p-0.5"
        >
          {(["all", "matched", "unmatched"] as Filter[]).map((f) => (
            <button
              key={f}
              role="tab"
              aria-selected={filter === f}
              onClick={() => setFilter(f)}
              className={cn(
                "rounded-md px-2.5 py-1 text-[13px] font-medium capitalize transition-colors duration-150 ease-out",
                filter === f
                  ? "bg-card text-foreground shadow-border"
                  : "text-muted-foreground hover:text-foreground",
              )}
            >
              {f}
            </button>
          ))}
        </div>
      </div>

      <ul className="divide-y">
        <AnimatePresence initial={false} mode="popLayout">
          {visible.map((r) => {
            const meta = confidenceMeta(r.matched ? 0.95 : r.confidence);
            return (
              <motion.li
                key={r.id}
                layout={!reduced}
                initial={reduced ? { opacity: 0 } : { opacity: 0, y: 8 }}
                animate={{ opacity: 1, y: 0 }}
                exit={reduced ? { opacity: 0 } : { opacity: 0, y: -8 }}
                transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
                className="flex items-center gap-3 px-3 py-2.5"
              >
                <div className="grid min-w-0 flex-1 grid-cols-[1fr_auto_1fr] items-center gap-2">
                  <div className="min-w-0">
                    <p className="truncate text-[13px] font-medium">{r.bank}</p>
                    <p className="text-[11px] text-muted-foreground tabular-nums">
                      {r.date}
                    </p>
                  </div>
                  <ArrowRight
                    className="size-3.5 shrink-0 text-muted-foreground/60"
                    aria-hidden
                  />
                  <div className="min-w-0">
                    {r.ledger ? (
                      <p className="truncate text-[13px] text-muted-foreground">
                        {r.ledger}
                      </p>
                    ) : (
                      <p className="text-[13px] text-muted-foreground/50 italic">
                        No ledger entry
                      </p>
                    )}
                  </div>
                </div>

                <span className="w-20 shrink-0 text-right text-[13px] font-medium tabular-nums">
                  {money.format(r.amount)}
                </span>

                <span
                  className={cn(
                    "inline-flex w-20 shrink-0 items-center justify-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium",
                    meta.cls,
                  )}
                >
                  {r.matched ? (
                    <Check className="size-3" aria-hidden />
                  ) : r.confidence !== undefined ? null : (
                    <HelpCircle className="size-3" aria-hidden />
                  )}
                  {meta.label}
                </span>

                {!r.matched && r.ledger && r.confidence !== undefined ? (
                  <button
                    type="button"
                    aria-label={`Confirm match for ${r.bank}`}
                    onClick={() => {
                      setConfirmed((c) => ({ ...c, [r.id]: true }));
                      onMatch?.(r.id);
                    }}
                    className="pressable flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground shadow-border transition-colors duration-150 ease-out hover:text-foreground"
                  >
                    <Link2 className="size-3.5" aria-hidden />
                  </button>
                ) : (
                  <span className="size-7 shrink-0" aria-hidden />
                )}
              </motion.li>
            );
          })}
        </AnimatePresence>
      </ul>

      <div className="flex items-center justify-between border-t px-3 py-2.5 text-[13px]">
        <span className="text-muted-foreground tabular-nums">
          {matchedCount} of {rows.length} matched
        </span>
        <span className="font-medium tabular-nums">
          {money.format(rows.reduce((s, r) => s + r.amount, 0))}
        </span>
      </div>
    </div>
  );
}