mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
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:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user