Add ACMCC app source, Supabase backend, and project config

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 20:19:26 -04:00
parent 313b51b412
commit 183fe0a93c
1422 changed files with 259271 additions and 0 deletions
@@ -0,0 +1,597 @@
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 deposits = (txs as Tx[]).filter((t) => checked.has(t.id) && t.type === "credit")
.map((t) => ({ date: t.date, description: t.description, amount: Number(t.amount) }));
const withdrawals = (txs as Tx[]).filter((t) => checked.has(t.id) && t.type === "debit")
.map((t) => ({ date: t.date, description: t.description, amount: Number(t.amount) }));
setSuccessData({
companyName: associationName ?? "Company",
accountName: (account as any)?.name ?? "Account",
statementEndDate: active.statement_end_date,
openingBalance: active.opening_balance,
statementBalance: active.statement_balance,
difference: 0,
deposits,
withdrawals,
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,amount,type")
.eq("reconciliation_id", h.id);
const deposits = (rows ?? []).filter((r: any) => r.type === "credit")
.map((r: any) => ({ date: r.date, description: r.description, amount: Number(r.amount) }));
const withdrawals = (rows ?? []).filter((r: any) => r.type === "debit")
.map((r: any) => ({ date: r.date, description: r.description, amount: Number(r.amount) }));
renderReconciliationPdf({
companyName: associationName ?? "Company",
accountName: (account as any)?.name ?? "Account",
statementEndDate: h.statement_end_date,
openingBalance: Number(h.opening_balance),
statementBalance: Number(h.statement_balance),
difference: 0,
deposits, withdrawals,
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">Statement balance</span><span>{money(successData.statementBalance, successData.currency)}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Cleared transactions</span><span>{successData.deposits.length + successData.withdrawals.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>
);
}