Reconciliation: only show the open period + sortable column headers

- Transactions on/before the last completed reconciliation's statement date no
  longer reappear in a new reconciliation (date floor). Imported GL registers
  were inserted cleared-but-unreconciled, flooding the list with prior-period
  rows; reconciling now works period-by-period
- Sortable headers (Date / Description / Ref # / Deposit / Withdrawal) with
  asc/desc toggle indicators

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 23:08:12 -04:00
parent 8a57f53317
commit 2d6f7ea17b
@@ -18,11 +18,31 @@ import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
import { ArrowLeft, CheckCircle2, AlertTriangle, FileDown, Search, Loader2 } from "lucide-react"; import { ArrowLeft, CheckCircle2, AlertTriangle, FileDown, Search, Loader2, ArrowUp, ArrowDown, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner"; import { toast } from "sonner";
import { money, fmtDate } from "./lib/format"; import { money, fmtDate } from "./lib/format";
import { renderReconciliationPdf, type ReconReportData } from "./lib/reconciliationPdf"; import { renderReconciliationPdf, type ReconReportData } from "./lib/reconciliationPdf";
/** Clickable, sortable column header for the reconciliation table. */
function SortHead({ col, label, sort, onSort, align }: {
col: string; label: string;
sort: { col: string; dir: "asc" | "desc" };
onSort: (col: any) => void;
align?: "right";
}) {
const activeCol = sort.col === col;
return (
<TableHead className={cn("cursor-pointer select-none", align === "right" && "text-right")} onClick={() => onSort(col)}>
<span className={cn("inline-flex items-center gap-1", align === "right" && "flex-row-reverse")}>
{label}
{activeCol ? (sort.dir === "asc" ? <ArrowUp className="h-3.5 w-3.5" /> : <ArrowDown className="h-3.5 w-3.5" />)
: <ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground/50" />}
</span>
</TableHead>
);
}
type Tx = { type Tx = {
id: string; id: string;
date: string; date: string;
@@ -56,6 +76,10 @@ export default function AccountingReconcileDetailPage() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [filter, setFilter] = useState<"all" | "deposits" | "withdrawals">("all"); const [filter, setFilter] = useState<"all" | "deposits" | "withdrawals">("all");
const [checked, setChecked] = useState<Set<string>>(new Set()); const [checked, setChecked] = useState<Set<string>>(new Set());
type SortCol = "date" | "description" | "reference" | "deposit" | "withdrawal";
const [sort, setSort] = useState<{ col: SortCol; dir: "asc" | "desc" }>({ col: "date", dir: "asc" });
const toggleSort = (col: SortCol) =>
setSort((s) => (s.col === col ? { col, dir: s.dir === "asc" ? "desc" : "asc" } : { col, dir: "asc" }));
const [successOpen, setSuccessOpen] = useState(false); const [successOpen, setSuccessOpen] = useState(false);
const [successData, setSuccessData] = useState<ReconReportData | null>(null); const [successData, setSuccessData] = useState<ReconReportData | null>(null);
@@ -85,6 +109,10 @@ export default function AccountingReconcileDetailPage() {
const lastCompleted = (history as any[]).find((h: any) => h.status === "completed"); const lastCompleted = (history as any[]).find((h: any) => h.status === "completed");
const openingBalance = active?.opening_balance ?? Number(lastCompleted?.statement_balance ?? 0); const openingBalance = active?.opening_balance ?? Number(lastCompleted?.statement_balance ?? 0);
// The period being reconciled opens the day AFTER the last completed
// reconciliation. Transactions on/before that date belong to a closed period
// and must not reappear in a new reconciliation.
const priorReconDate: string | null = lastCompleted?.statement_end_date ?? null;
const { data: allAccounts = [] } = useQuery({ const { data: allAccounts = [] } = useQuery({
queryKey: ["accounts", cid], queryKey: ["accounts", cid],
@@ -94,16 +122,18 @@ export default function AccountingReconcileDetailPage() {
}); });
const { data: txs = [] } = useQuery({ const { data: txs = [] } = useQuery({
queryKey: ["recon-txs", accountId, active?.statement_end_date], queryKey: ["recon-txs", accountId, active?.statement_end_date, priorReconDate],
enabled: !!accountId && !!active, enabled: !!accountId && !!active,
queryFn: async () => { queryFn: async () => {
const { data } = await accounting let q = accounting
.from("transactions") .from("transactions")
.select("id,date,description,reference,amount,type,cleared,reconciliation_id") .select("id,date,description,reference,amount,type,cleared,reconciliation_id")
.eq("account_id", accountId) .eq("account_id", accountId)
.is("reconciliation_id", null) .is("reconciliation_id", null)
.lte("date", active!.statement_end_date) .lte("date", active!.statement_end_date);
.order("date"); // Only this period: skip anything already covered by the last reconciliation.
if (priorReconDate) q = q.gt("date", priorReconDate);
const { data } = await q.order("date");
return (data ?? []) as Tx[]; return (data ?? []) as Tx[];
}, },
}); });
@@ -117,13 +147,29 @@ export default function AccountingReconcileDetailPage() {
const filtered = useMemo(() => { const filtered = useMemo(() => {
const s = search.toLowerCase().trim(); const s = search.toLowerCase().trim();
return (txs as Tx[]).filter((t) => { const rows = (txs as Tx[]).filter((t) => {
if (filter === "deposits" && t.type !== "credit") return false; if (filter === "deposits" && t.type !== "credit") return false;
if (filter === "withdrawals" && t.type !== "debit") return false; if (filter === "withdrawals" && t.type !== "debit") return false;
if (s && !`${t.description} ${t.reference ?? ""}`.toLowerCase().includes(s)) return false; if (s && !`${t.description} ${t.reference ?? ""}`.toLowerCase().includes(s)) return false;
return true; return true;
}); });
}, [txs, search, filter]); const dir = sort.dir === "asc" ? 1 : -1;
const key = (t: Tx): string | number => {
switch (sort.col) {
case "date": return t.date ?? "";
case "description": return (t.description ?? "").toLowerCase();
case "reference": return (t.reference ?? "").toLowerCase();
case "deposit": return t.type === "credit" ? Number(t.amount) : -Infinity;
case "withdrawal": return t.type === "debit" ? Number(t.amount) : -Infinity;
}
};
return [...rows].sort((a, b) => {
const ka = key(a), kb = key(b);
if (ka < kb) return -1 * dir;
if (ka > kb) return 1 * dir;
return 0;
});
}, [txs, search, filter, sort]);
const clearedDeposits = useMemo( const clearedDeposits = useMemo(
() => (txs as Tx[]).filter((t) => checked.has(t.id) && t.type === "credit") () => (txs as Tx[]).filter((t) => checked.has(t.id) && t.type === "credit")
@@ -336,11 +382,11 @@ export default function AccountingReconcileDetailPage() {
onCheckedChange={(v) => toggleAll(!!v)} onCheckedChange={(v) => toggleAll(!!v)}
/> />
</TableHead> </TableHead>
<TableHead>Date</TableHead> <SortHead col="date" label="Date" sort={sort} onSort={toggleSort} />
<TableHead>Description</TableHead> <SortHead col="description" label="Description" sort={sort} onSort={toggleSort} />
<TableHead>Ref #</TableHead> <SortHead col="reference" label="Ref #" sort={sort} onSort={toggleSort} />
<TableHead className="text-right">Deposit</TableHead> <SortHead col="deposit" label="Deposit" sort={sort} onSort={toggleSort} align="right" />
<TableHead className="text-right">Withdrawal</TableHead> <SortHead col="withdrawal" label="Withdrawal" sort={sort} onSort={toggleSort} align="right" />
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>