mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
e302fb91f0
- Remove the Zoho Books integration (edge functions, sync libs, settings, reports/overview, banking links, fees tab, import dialog); preserve fee rules as a standalone FeesTab and the COA accounting_system classification. - Financial Overview/Reports (staff + board) render the Accounting dashboard and reports; board reports mirror the rich Accounting Reports. - New Reserve Fund Schedule report + an is_reserve flag on accounts. - Unify all report exports to a branded format (logo + centered header + footer): shared ReportSheet (on-screen) and reportHeader (PDF). Budget vs Actuals and Bank Reconciliation PDFs now match the reference layout. - Render financial reports inline (no preview pop-up). - Budget Management mirrors Accounting Budgeting (staff-accessible) with SPA navigation; editable bills in the Accounting Bills page. - Negative opening balances flow through to the GL and reports (allow negative input; keep non-zero on save; signed CSV import). - Upload a per-account trial balance via CSV on Opening Balances. - Board members: read-only RLS access to their association's accounting ledger; editable board-members panel on the association page; share vendor contacts with the board (toggle + directory section). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
609 lines
28 KiB
TypeScript
609 lines
28 KiB
TypeScript
import { Link, useParams } from "react-router-dom";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { useMemo, useState, useEffect } from "react";
|
||
import { accounting } from "@/lib/accountingClient";
|
||
import { useCompanyId } from "./lib/useCompanyId";
|
||
import { useAuth } from "@/contexts/AuthContext";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||
import {
|
||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||
} from "@/components/ui/dialog";
|
||
import {
|
||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
||
import { ArrowLeft, CheckCircle2, AlertTriangle, FileDown, Search, Loader2 } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { money, fmtDate } from "./lib/format";
|
||
import { renderReconciliationPdf, type ReconReportData } from "./lib/reconciliationPdf";
|
||
|
||
type Tx = {
|
||
id: string;
|
||
date: string;
|
||
description: string;
|
||
reference: string | null;
|
||
amount: number;
|
||
type: "debit" | "credit";
|
||
cleared: boolean;
|
||
reconciliation_id: string | null;
|
||
};
|
||
|
||
export default function AccountingReconcileDetailPage() {
|
||
const { accountId = "" } = useParams();
|
||
const { companyId, loading: companyLoading, error: companyError, associationId, associationName } = useCompanyId();
|
||
const { user } = useAuth();
|
||
const cid = companyId ?? "";
|
||
const cur = "USD";
|
||
const qc = useQueryClient();
|
||
|
||
const [setupOpen, setSetupOpen] = useState(false);
|
||
const [setup, setSetup] = useState({
|
||
statement_end_date: new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }),
|
||
statement_balance: 0,
|
||
});
|
||
const [active, setActive] = useState<{
|
||
statement_end_date: string;
|
||
statement_balance: number;
|
||
opening_balance: number;
|
||
} | null>(null);
|
||
|
||
const [search, setSearch] = useState("");
|
||
const [filter, setFilter] = useState<"all" | "deposits" | "withdrawals">("all");
|
||
const [checked, setChecked] = useState<Set<string>>(new Set());
|
||
|
||
const [successOpen, setSuccessOpen] = useState(false);
|
||
const [successData, setSuccessData] = useState<ReconReportData | null>(null);
|
||
const [adjOpen, setAdjOpen] = useState(false);
|
||
const [adjAccount, setAdjAccount] = useState("");
|
||
const [adjNote, setAdjNote] = useState("Bank reconciliation adjustment");
|
||
|
||
const { data: account } = useQuery({
|
||
queryKey: ["account", accountId],
|
||
enabled: !!accountId,
|
||
queryFn: async () =>
|
||
(await accounting.from("accounts").select("*").eq("id", accountId).single()).data,
|
||
});
|
||
|
||
const { data: history = [] } = useQuery({
|
||
queryKey: ["recon-history", accountId],
|
||
enabled: !!accountId,
|
||
queryFn: async () =>
|
||
(
|
||
await accounting
|
||
.from("reconciliations")
|
||
.select("*")
|
||
.eq("account_id", accountId)
|
||
.order("statement_end_date", { ascending: false })
|
||
).data ?? [],
|
||
});
|
||
|
||
const lastCompleted = (history as any[]).find((h: any) => h.status === "completed");
|
||
const openingBalance = active?.opening_balance ?? Number(lastCompleted?.statement_balance ?? 0);
|
||
|
||
const { data: allAccounts = [] } = useQuery({
|
||
queryKey: ["accounts", cid],
|
||
enabled: !!cid,
|
||
queryFn: async () =>
|
||
(await accounting.from("accounts").select("id,name,type").eq("company_id", cid).order("name")).data ?? [],
|
||
});
|
||
|
||
const { data: txs = [] } = useQuery({
|
||
queryKey: ["recon-txs", accountId, active?.statement_end_date],
|
||
enabled: !!accountId && !!active,
|
||
queryFn: async () => {
|
||
const { data } = await accounting
|
||
.from("transactions")
|
||
.select("id,date,description,reference,amount,type,cleared,reconciliation_id")
|
||
.eq("account_id", accountId)
|
||
.is("reconciliation_id", null)
|
||
.lte("date", active!.statement_end_date)
|
||
.order("date");
|
||
return (data ?? []) as Tx[];
|
||
},
|
||
});
|
||
|
||
// Preload checked from already-cleared rows
|
||
useEffect(() => {
|
||
if (active) {
|
||
setChecked(new Set((txs as Tx[]).filter((t) => t.cleared).map((t) => t.id)));
|
||
}
|
||
}, [active, txs]);
|
||
|
||
const filtered = useMemo(() => {
|
||
const s = search.toLowerCase().trim();
|
||
return (txs as Tx[]).filter((t) => {
|
||
if (filter === "deposits" && t.type !== "credit") return false;
|
||
if (filter === "withdrawals" && t.type !== "debit") return false;
|
||
if (s && !`${t.description} ${t.reference ?? ""}`.toLowerCase().includes(s)) return false;
|
||
return true;
|
||
});
|
||
}, [txs, search, filter]);
|
||
|
||
const clearedDeposits = useMemo(
|
||
() => (txs as Tx[]).filter((t) => checked.has(t.id) && t.type === "credit")
|
||
.reduce((s, t) => s + Number(t.amount), 0),
|
||
[txs, checked],
|
||
);
|
||
const clearedWithdrawals = useMemo(
|
||
() => (txs as Tx[]).filter((t) => checked.has(t.id) && t.type === "debit")
|
||
.reduce((s, t) => s + Number(t.amount), 0),
|
||
[txs, checked],
|
||
);
|
||
const clearedBalance = openingBalance + clearedDeposits - clearedWithdrawals;
|
||
const difference = Number(active?.statement_balance ?? 0) - clearedBalance;
|
||
const balanced = Math.abs(difference) < 0.005;
|
||
|
||
const startReconciling = () => {
|
||
if (!setup.statement_end_date) return toast.error("Statement date required");
|
||
setActive({
|
||
statement_end_date: setup.statement_end_date,
|
||
statement_balance: Number(setup.statement_balance) || 0,
|
||
opening_balance: Number(lastCompleted?.statement_balance ?? 0),
|
||
});
|
||
setSetupOpen(false);
|
||
};
|
||
|
||
const toggleAll = (on: boolean) => {
|
||
if (on) setChecked(new Set(filtered.map((t) => t.id)));
|
||
else setChecked(new Set());
|
||
};
|
||
|
||
const saveForLater = async () => {
|
||
if (!active) return;
|
||
const ids = Array.from(checked);
|
||
if (ids.length === 0) return toast.error("No transactions selected");
|
||
const { error } = await accounting.from("transactions").update({ cleared: true }).in("id", ids);
|
||
if (error) return toast.error(error.message);
|
||
toast.success("Progress saved");
|
||
qc.invalidateQueries({ queryKey: ["recon-txs", accountId] });
|
||
};
|
||
|
||
const finish = async () => {
|
||
if (!active || !balanced) return;
|
||
if (!cid) return toast.error("No company selected");
|
||
const ids = Array.from(checked);
|
||
|
||
const { data: rec, error: recErr } = await accounting
|
||
.from("reconciliations")
|
||
.insert({
|
||
company_id: cid,
|
||
account_id: accountId,
|
||
statement_end_date: active.statement_end_date,
|
||
statement_balance: active.statement_balance,
|
||
opening_balance: active.opening_balance,
|
||
cleared_deposits: clearedDeposits,
|
||
cleared_withdrawals: clearedWithdrawals,
|
||
status: "completed",
|
||
completed_at: new Date().toISOString(),
|
||
completed_by: user?.id ?? null,
|
||
})
|
||
.select()
|
||
.single();
|
||
if (recErr || !rec) return toast.error(recErr?.message ?? "Failed to save reconciliation");
|
||
|
||
if (ids.length > 0) {
|
||
const { error: txErr } = await accounting
|
||
.from("transactions")
|
||
.update({ cleared: true, reconciliation_id: rec.id })
|
||
.in("id", ids);
|
||
if (txErr) {
|
||
toast.error(`Reconciliation saved but transaction update failed: ${txErr.message}`);
|
||
}
|
||
}
|
||
|
||
const mapRow = (t: Tx) => ({ name: t.description ?? "", date: t.date, number: t.reference ?? "", memo: "", amount: Number(t.amount) });
|
||
const clearedDepRows = (txs as Tx[]).filter((t) => checked.has(t.id) && t.type === "credit").map(mapRow);
|
||
const clearedWdRows = (txs as Tx[]).filter((t) => checked.has(t.id) && t.type === "debit").map(mapRow);
|
||
const unclearedDepRows = (txs as Tx[]).filter((t) => !checked.has(t.id) && t.type === "credit").map(mapRow);
|
||
const unclearedWdRows = (txs as Tx[]).filter((t) => !checked.has(t.id) && t.type === "debit").map(mapRow);
|
||
const sumAmt = (rs: { amount: number }[]) => rs.reduce((s, r) => s + r.amount, 0);
|
||
const begin = Number(active.opening_balance);
|
||
const ending = begin + sumAmt(clearedDepRows) - sumAmt(clearedWdRows);
|
||
const book = ending + sumAmt(unclearedDepRows) - sumAmt(unclearedWdRows);
|
||
const acctLabel = (account as any)?.code ? `${(account as any).code} ${(account as any).name}` : ((account as any)?.name ?? "Account");
|
||
|
||
setSuccessData({
|
||
companyName: associationName ?? "Company",
|
||
accountName: acctLabel,
|
||
statementEndDate: active.statement_end_date,
|
||
beginningBalance: begin,
|
||
endingBalance: ending,
|
||
bookBalance: book,
|
||
clearedDeposits: clearedDepRows,
|
||
clearedWithdrawals: clearedWdRows,
|
||
unclearedDeposits: unclearedDepRows,
|
||
unclearedWithdrawals: unclearedWdRows,
|
||
preparedBy: user?.email ?? "—",
|
||
currency: cur,
|
||
completedAt: new Date().toISOString(),
|
||
});
|
||
setSuccessOpen(true);
|
||
setActive(null);
|
||
setChecked(new Set());
|
||
qc.invalidateQueries({ queryKey: ["recon-history", accountId] });
|
||
qc.invalidateQueries({ queryKey: ["recon-last", cid] });
|
||
qc.invalidateQueries({ queryKey: ["recon-txs", accountId] });
|
||
};
|
||
|
||
const postAdjustment = async () => {
|
||
if (!active || !adjAccount) return toast.error("Pick an account");
|
||
const adjAmount = difference; // sign: positive if statement > cleared
|
||
const type: "credit" | "debit" = adjAmount >= 0 ? "credit" : "debit";
|
||
const { data, error } = await accounting
|
||
.from("transactions")
|
||
.insert({
|
||
company_id: cid,
|
||
account_id: accountId,
|
||
date: active.statement_end_date,
|
||
description: adjNote || "Reconciliation adjustment",
|
||
amount: Math.abs(adjAmount),
|
||
type,
|
||
cleared: true,
|
||
reference: "ADJ",
|
||
category: "Adjustment",
|
||
})
|
||
.select()
|
||
.single();
|
||
if (error || !data) return toast.error(error?.message ?? "Failed");
|
||
|
||
// Offset entry to chosen account
|
||
await accounting.from("transactions").insert({
|
||
company_id: cid,
|
||
account_id: adjAccount,
|
||
date: active.statement_end_date,
|
||
description: adjNote || "Reconciliation adjustment",
|
||
amount: Math.abs(adjAmount),
|
||
type: type === "credit" ? "debit" : "credit",
|
||
reference: "ADJ",
|
||
category: "Adjustment",
|
||
});
|
||
|
||
setAdjOpen(false);
|
||
toast.success("Adjustment posted");
|
||
qc.invalidateQueries({ queryKey: ["recon-txs", accountId] });
|
||
// auto-check the new adjustment
|
||
setChecked((prev) => new Set(prev).add(data.id));
|
||
};
|
||
|
||
if (!associationId) return <p className="text-sm text-muted-foreground">Select an association.</p>;
|
||
if (companyLoading) return <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
|
||
if (companyError || !companyId) return <p className="text-sm text-muted-foreground text-center py-12">{companyError || "Accounting setup is not ready."}</p>;
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||
<div className="flex items-center gap-3">
|
||
<Button asChild variant="ghost" size="sm">
|
||
<Link to="/dashboard/accounting/reconciliation"><ArrowLeft className="h-4 w-4 mr-1" />Bank Reconciliation</Link>
|
||
</Button>
|
||
<div>
|
||
<h1 className="text-xl font-semibold">{(account as any)?.name ?? "Account"}</h1>
|
||
<p className="text-xs text-muted-foreground">
|
||
Current balance {money((account as any)?.balance, cur)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
{!active && (
|
||
<Button onClick={() => {
|
||
setSetup({
|
||
statement_end_date: new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }),
|
||
statement_balance: 0,
|
||
});
|
||
setSetupOpen(true);
|
||
}}>Reconcile</Button>
|
||
)}
|
||
</div>
|
||
|
||
{active ? (
|
||
<div className="grid gap-4 lg:grid-cols-[1fr_360px]">
|
||
{/* Left panel */}
|
||
<Card>
|
||
<CardHeader className="pb-3">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<CardTitle className="text-base mr-auto">Statement Transactions</CardTitle>
|
||
<div className="relative">
|
||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
className="pl-8 h-9 w-56"
|
||
placeholder="Search…"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
/>
|
||
</div>
|
||
<Select value={filter} onValueChange={(v) => setFilter(v as any)}>
|
||
<SelectTrigger className="h-9 w-40"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">All</SelectItem>
|
||
<SelectItem value="deposits">Deposits only</SelectItem>
|
||
<SelectItem value="withdrawals">Withdrawals only</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="w-8">
|
||
<Checkbox
|
||
checked={filtered.length > 0 && filtered.every((t) => checked.has(t.id))}
|
||
onCheckedChange={(v) => toggleAll(!!v)}
|
||
/>
|
||
</TableHead>
|
||
<TableHead>Date</TableHead>
|
||
<TableHead>Description</TableHead>
|
||
<TableHead>Ref #</TableHead>
|
||
<TableHead className="text-right">Deposit</TableHead>
|
||
<TableHead className="text-right">Withdrawal</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{filtered.map((t) => {
|
||
const on = checked.has(t.id);
|
||
return (
|
||
<TableRow
|
||
key={t.id}
|
||
className={on ? "bg-emerald-50/60 border-l-4 border-l-emerald-500" : ""}
|
||
>
|
||
<TableCell>
|
||
<Checkbox
|
||
checked={on}
|
||
onCheckedChange={(v) => {
|
||
setChecked((prev) => {
|
||
const next = new Set(prev);
|
||
if (v) next.add(t.id); else next.delete(t.id);
|
||
return next;
|
||
});
|
||
}}
|
||
/>
|
||
</TableCell>
|
||
<TableCell>{fmtDate(t.date)}</TableCell>
|
||
<TableCell className="max-w-[280px] truncate">{t.description}</TableCell>
|
||
<TableCell className="text-muted-foreground">{t.reference ?? "—"}</TableCell>
|
||
<TableCell className="text-right text-emerald-700">
|
||
{t.type === "credit" ? money(t.amount, cur) : ""}
|
||
</TableCell>
|
||
<TableCell className="text-right text-red-700">
|
||
{t.type === "debit" ? money(t.amount, cur) : ""}
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
})}
|
||
{filtered.length === 0 && (
|
||
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
||
No unreconciled transactions in this period.
|
||
</TableCell></TableRow>
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Right panel */}
|
||
<div className="space-y-4 lg:sticky lg:top-4 lg:self-start">
|
||
<Card>
|
||
<CardHeader className="pb-3"><CardTitle className="text-base">Summary</CardTitle></CardHeader>
|
||
<CardContent className="space-y-3 text-sm">
|
||
<div>
|
||
<Label className="text-xs">Statement ending balance</Label>
|
||
<Input
|
||
type="number" step="0.01"
|
||
value={active.statement_balance}
|
||
onChange={(e) => setActive({ ...active, statement_balance: Number(e.target.value) })}
|
||
/>
|
||
</div>
|
||
<div className="flex justify-between text-xs"><span className="text-muted-foreground">Opening balance</span><span>{money(openingBalance, cur)}</span></div>
|
||
<div className="flex justify-between text-xs"><span className="text-muted-foreground">+ Cleared deposits ({(txs as Tx[]).filter(t => checked.has(t.id) && t.type === "credit").length})</span><span className="text-emerald-700">+{money(clearedDeposits, cur)}</span></div>
|
||
<div className="flex justify-between text-xs"><span className="text-muted-foreground">− Cleared withdrawals ({(txs as Tx[]).filter(t => checked.has(t.id) && t.type === "debit").length})</span><span className="text-red-700">−{money(clearedWithdrawals, cur)}</span></div>
|
||
<div className="flex justify-between border-t pt-2 font-medium text-sm"><span>Cleared balance</span><span>{money(clearedBalance, cur)}</span></div>
|
||
<div className="flex justify-between text-xs text-muted-foreground"><span>Statement balance</span><span>{money(active.statement_balance, cur)}</span></div>
|
||
|
||
<div className="rounded-md border p-3 text-center">
|
||
<div className="text-xs text-muted-foreground mb-1">Difference</div>
|
||
<div className={`text-2xl font-bold flex items-center justify-center gap-2 ${balanced ? "text-emerald-600" : "text-red-600"}`}>
|
||
{balanced ? <CheckCircle2 className="h-6 w-6" /> : <AlertTriangle className="h-6 w-6" />}
|
||
{money(difference, cur)}
|
||
</div>
|
||
{!balanced && (
|
||
<button
|
||
onClick={() => setAdjOpen(true)}
|
||
className="mt-2 text-xs text-primary hover:underline"
|
||
>
|
||
Create adjustment entry
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-2 pt-1">
|
||
<Button disabled={!balanced} onClick={finish}>Finish Reconciling</Button>
|
||
<Button variant="outline" onClick={saveForLater}>Save for Later</Button>
|
||
<Button variant="ghost" onClick={() => { setActive(null); setChecked(new Set()); }}>Cancel</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<Tabs defaultValue="overview">
|
||
<TabsList>
|
||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||
<TabsTrigger value="history">History</TabsTrigger>
|
||
</TabsList>
|
||
<TabsContent value="overview">
|
||
<Card>
|
||
<CardContent className="py-10 text-center space-y-2">
|
||
<p className="text-muted-foreground">
|
||
{lastCompleted
|
||
? <>Last reconciled on <strong>{fmtDate(lastCompleted.statement_end_date)}</strong> at <strong>{money(lastCompleted.statement_balance, cur)}</strong>.</>
|
||
: "This account has never been reconciled."}
|
||
</p>
|
||
<Button onClick={() => setSetupOpen(true)}>Start Reconciling</Button>
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
<TabsContent value="history">
|
||
<Card>
|
||
<CardContent className="pt-6">
|
||
<Table>
|
||
<TableHeader><TableRow>
|
||
<TableHead>Period (Statement End)</TableHead>
|
||
<TableHead>Statement Balance</TableHead>
|
||
<TableHead>Reconciled Date</TableHead>
|
||
<TableHead>Status</TableHead>
|
||
<TableHead></TableHead>
|
||
</TableRow></TableHeader>
|
||
<TableBody>
|
||
{(history as any[]).map((h: any) => (
|
||
<TableRow key={h.id}>
|
||
<TableCell>{fmtDate(h.statement_end_date)}</TableCell>
|
||
<TableCell>{money(h.statement_balance, cur)}</TableCell>
|
||
<TableCell>{h.completed_at ? fmtDate(h.completed_at) : "—"}</TableCell>
|
||
<TableCell>
|
||
{h.status === "completed"
|
||
? <Badge className="bg-emerald-600">Completed</Badge>
|
||
: <Badge variant="secondary">In progress</Badge>}
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
<Button size="sm" variant="ghost" onClick={async () => {
|
||
const { data: rows } = await accounting
|
||
.from("transactions")
|
||
.select("date,description,reference,amount,type")
|
||
.eq("reconciliation_id", h.id);
|
||
const mapRow = (r: any) => ({ name: r.description ?? "", date: r.date, number: r.reference ?? "", memo: "", amount: Number(r.amount) });
|
||
const clearedDeposits = (rows ?? []).filter((r: any) => r.type === "credit").map(mapRow);
|
||
const clearedWithdrawals = (rows ?? []).filter((r: any) => r.type === "debit").map(mapRow);
|
||
const sumAmt = (rs: { amount: number }[]) => rs.reduce((s, r) => s + r.amount, 0);
|
||
const begin = Number(h.opening_balance);
|
||
const ending = begin + sumAmt(clearedDeposits) - sumAmt(clearedWithdrawals);
|
||
const acctLabel = (account as any)?.code ? `${(account as any).code} ${(account as any).name}` : ((account as any)?.name ?? "Account");
|
||
renderReconciliationPdf({
|
||
companyName: associationName ?? "Company",
|
||
accountName: acctLabel,
|
||
statementEndDate: h.statement_end_date,
|
||
beginningBalance: begin,
|
||
endingBalance: ending,
|
||
bookBalance: ending,
|
||
clearedDeposits, clearedWithdrawals, unclearedDeposits: [], unclearedWithdrawals: [],
|
||
preparedBy: user?.email ?? "—",
|
||
currency: cur,
|
||
completedAt: h.completed_at ?? new Date().toISOString(),
|
||
});
|
||
}}>
|
||
<FileDown className="h-4 w-4 mr-1" />View Report
|
||
</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
{(history as any[]).length === 0 && (
|
||
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">No reconciliations yet.</TableCell></TableRow>
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
</Tabs>
|
||
)}
|
||
|
||
{/* Setup modal */}
|
||
<Dialog open={setupOpen} onOpenChange={setSetupOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Start Reconciliation</DialogTitle>
|
||
<DialogDescription>Enter the details from your bank statement.</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-3">
|
||
<div>
|
||
<Label>Statement end date</Label>
|
||
<Input type="date" value={setup.statement_end_date}
|
||
onChange={(e) => setSetup({ ...setup, statement_end_date: e.target.value })} />
|
||
</div>
|
||
<div>
|
||
<Label>Ending statement balance</Label>
|
||
<Input type="number" step="0.01" value={setup.statement_balance}
|
||
onChange={(e) => setSetup({ ...setup, statement_balance: Number(e.target.value) })} />
|
||
</div>
|
||
<div>
|
||
<Label>Opening balance</Label>
|
||
<Input readOnly value={Number(lastCompleted?.statement_balance ?? 0).toFixed(2)} />
|
||
<p className="text-xs text-muted-foreground mt-1">
|
||
Pulled from last reconciliation.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="ghost" onClick={() => setSetupOpen(false)}>Cancel</Button>
|
||
<Button onClick={startReconciling}>Start Reconciling</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Adjustment modal */}
|
||
<Dialog open={adjOpen} onOpenChange={setAdjOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Create Adjustment Entry</DialogTitle>
|
||
<DialogDescription>
|
||
Post a journal entry of {money(difference, cur)} to balance the reconciliation.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-3">
|
||
<div>
|
||
<Label>Offset account (suspense / bank charges)</Label>
|
||
<Select value={adjAccount} onValueChange={setAdjAccount}>
|
||
<SelectTrigger><SelectValue placeholder="Select account" /></SelectTrigger>
|
||
<SelectContent>
|
||
{(allAccounts as any[])
|
||
.filter((a: any) => a.id !== accountId)
|
||
.map((a: any) => (
|
||
<SelectItem key={a.id} value={a.id}>{a.name} ({a.type})</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div>
|
||
<Label>Note</Label>
|
||
<Input value={adjNote} onChange={(e) => setAdjNote(e.target.value)} />
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="ghost" onClick={() => setAdjOpen(false)}>Cancel</Button>
|
||
<Button onClick={postAdjustment}>Post Adjustment</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Success modal */}
|
||
<Dialog open={successOpen} onOpenChange={setSuccessOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-2">
|
||
<CheckCircle2 className="h-5 w-5 text-emerald-600" /> Reconciliation Complete
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
{successData && (
|
||
<div className="space-y-2 text-sm">
|
||
<div className="flex justify-between"><span className="text-muted-foreground">Period reconciled</span><span>{fmtDate(successData.statementEndDate)}</span></div>
|
||
<div className="flex justify-between"><span className="text-muted-foreground">Ending balance</span><span>{money(successData.endingBalance, successData.currency)}</span></div>
|
||
<div className="flex justify-between"><span className="text-muted-foreground">Cleared transactions</span><span>{successData.clearedDeposits.length + successData.clearedWithdrawals.length}</span></div>
|
||
<div className="flex justify-between"><span className="text-muted-foreground">Date completed</span><span>{fmtDate(successData.completedAt)}</span></div>
|
||
</div>
|
||
)}
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setSuccessOpen(false)}>Close</Button>
|
||
<Button onClick={() => successData && renderReconciliationPdf(successData)}>
|
||
<FileDown className="h-4 w-4 mr-1" />Export PDF
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|