mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Accounting: add General Ledger tab
Buildium-style GL grouped by account type with date-range filter, account filter, beginning/ending balances, per-line running balance, and grand totals. Reads posted journal entries. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
|||||||
AccountingDashboardPage,
|
AccountingDashboardPage,
|
||||||
AccountingChartOfAccountsPage,
|
AccountingChartOfAccountsPage,
|
||||||
AccountingJournalEntriesPage,
|
AccountingJournalEntriesPage,
|
||||||
|
AccountingGeneralLedgerPage,
|
||||||
AccountingInvoicesPage,
|
AccountingInvoicesPage,
|
||||||
AccountingBillsPage,
|
AccountingBillsPage,
|
||||||
AccountingCustomersPage,
|
AccountingCustomersPage,
|
||||||
@@ -380,6 +381,7 @@ const App = () => (
|
|||||||
<Route index element={<AccountingDashboardPage />} />
|
<Route index element={<AccountingDashboardPage />} />
|
||||||
<Route path="chart-of-accounts" element={<AccountingChartOfAccountsPage />} />
|
<Route path="chart-of-accounts" element={<AccountingChartOfAccountsPage />} />
|
||||||
<Route path="journal-entries" element={<AccountingJournalEntriesPage />} />
|
<Route path="journal-entries" element={<AccountingJournalEntriesPage />} />
|
||||||
|
<Route path="general-ledger" element={<AccountingGeneralLedgerPage />} />
|
||||||
<Route path="invoices" element={<AccountingInvoicesPage />} />
|
<Route path="invoices" element={<AccountingInvoicesPage />} />
|
||||||
<Route path="bills" element={<AccountingBillsPage />} />
|
<Route path="bills" element={<AccountingBillsPage />} />
|
||||||
<Route path="customers" element={<AccountingCustomersPage />} />
|
<Route path="customers" element={<AccountingCustomersPage />} />
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { accounting } from "@/lib/accountingClient";
|
||||||
|
import { useCompanyId } from "./lib/useCompanyId";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
||||||
|
import { Loader2, RefreshCw } from "lucide-react";
|
||||||
|
import { money, fmtDate, startOfYearET, todayET } from "./lib/format";
|
||||||
|
|
||||||
|
// Account type → display label, ordering, and normal balance side.
|
||||||
|
const TYPE_META: Record<string, { label: string; order: number; side: "debit" | "credit" }> = {
|
||||||
|
asset: { label: "Assets", order: 1, side: "debit" },
|
||||||
|
liability: { label: "Liabilities", order: 2, side: "credit" },
|
||||||
|
equity: { label: "Equity", order: 3, side: "credit" },
|
||||||
|
income: { label: "Income", order: 4, side: "credit" },
|
||||||
|
expense: { label: "Expenses", order: 5, side: "debit" },
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlatLine = {
|
||||||
|
account_id: string;
|
||||||
|
code: string | null;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
date: string;
|
||||||
|
description: string;
|
||||||
|
reference: string | null;
|
||||||
|
debit: number;
|
||||||
|
credit: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AccountingGeneralLedgerPage() {
|
||||||
|
const { companyId, loading: companyLoading, error: companyError, associationId } = useCompanyId();
|
||||||
|
const cid = companyId ?? "";
|
||||||
|
const cur = "USD";
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const [from, setFrom] = useState(startOfYearET());
|
||||||
|
const [to, setTo] = useState(todayET());
|
||||||
|
const [accountFilter, setAccountFilter] = useState<string>("all");
|
||||||
|
const [activityOnly, setActivityOnly] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const { data: entries = [], isFetching } = useQuery({
|
||||||
|
queryKey: ["gl-journal-entries", cid],
|
||||||
|
enabled: !!cid,
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await accounting
|
||||||
|
.from("journal_entries")
|
||||||
|
.select("id, date, description, reference, journal_entry_lines(debit, credit, account_id, accounts(code, name, type))")
|
||||||
|
.eq("company_id", cid)
|
||||||
|
.order("date", { ascending: true })
|
||||||
|
.order("created_at", { ascending: true })
|
||||||
|
).data ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: accounts = [] } = useQuery({
|
||||||
|
queryKey: ["accounts", cid],
|
||||||
|
enabled: !!cid,
|
||||||
|
queryFn: async () =>
|
||||||
|
(await accounting.from("accounts").select("id, code, name, type").eq("company_id", cid).order("code")).data ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flatten every JE line into a single list, carrying its entry's date/desc/ref.
|
||||||
|
const lines = useMemo<FlatLine[]>(() => {
|
||||||
|
const out: FlatLine[] = [];
|
||||||
|
for (const e of entries as any[]) {
|
||||||
|
for (const l of e.journal_entry_lines ?? []) {
|
||||||
|
if (!l.account_id) continue;
|
||||||
|
out.push({
|
||||||
|
account_id: l.account_id,
|
||||||
|
code: l.accounts?.code ?? null,
|
||||||
|
name: l.accounts?.name ?? "(unknown account)",
|
||||||
|
type: l.accounts?.type ?? "asset",
|
||||||
|
date: e.date,
|
||||||
|
description: e.description ?? "",
|
||||||
|
reference: e.reference ?? null,
|
||||||
|
debit: Number(l.debit) || 0,
|
||||||
|
credit: Number(l.credit) || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [entries]);
|
||||||
|
|
||||||
|
// Build per-account ledgers: beginning balance (everything before `from`),
|
||||||
|
// the in-range rows with a running balance, and the ending balance.
|
||||||
|
const ledger = useMemo(() => {
|
||||||
|
const byAccount = new Map<string, FlatLine[]>();
|
||||||
|
for (const l of lines) {
|
||||||
|
if (accountFilter !== "all" && l.account_id !== accountFilter) continue;
|
||||||
|
(byAccount.get(l.account_id) ?? byAccount.set(l.account_id, []).get(l.account_id)!).push(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sideOf = (type: string) => TYPE_META[type]?.side ?? "debit";
|
||||||
|
// Render running balance on the account's natural side (credit accounts
|
||||||
|
// show a credit balance as positive, mirroring Buildium).
|
||||||
|
const natural = (signed: number, type: string) => (sideOf(type) === "debit" ? signed : -signed);
|
||||||
|
|
||||||
|
const accountsOut = Array.from(byAccount.entries()).map(([accId, rows]) => {
|
||||||
|
const meta = rows[0];
|
||||||
|
let beginSigned = 0; // debit − credit
|
||||||
|
const inRange: (FlatLine & { running: number })[] = [];
|
||||||
|
let runSigned = 0;
|
||||||
|
let periodDebit = 0;
|
||||||
|
let periodCredit = 0;
|
||||||
|
|
||||||
|
// rows already arrive date-ascending from the query order
|
||||||
|
for (const r of rows) {
|
||||||
|
const signed = r.debit - r.credit;
|
||||||
|
if (r.date < from) {
|
||||||
|
beginSigned += signed;
|
||||||
|
} else if (r.date <= to) {
|
||||||
|
runSigned += signed;
|
||||||
|
periodDebit += r.debit;
|
||||||
|
periodCredit += r.credit;
|
||||||
|
inRange.push({ ...r, running: natural(beginSigned + runSigned, meta.type) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const endSigned = beginSigned + runSigned;
|
||||||
|
return {
|
||||||
|
account_id: accId,
|
||||||
|
code: meta.code,
|
||||||
|
name: meta.name,
|
||||||
|
type: meta.type,
|
||||||
|
beginning: natural(beginSigned, meta.type),
|
||||||
|
ending: natural(endSigned, meta.type),
|
||||||
|
periodDebit,
|
||||||
|
periodCredit,
|
||||||
|
rows: inRange,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drop untouched accounts when "activity only" is on (no in-range rows AND no opening balance).
|
||||||
|
const filtered = activityOnly
|
||||||
|
? accountsOut.filter((a) => a.rows.length > 0 || Math.abs(a.beginning) > 0.005)
|
||||||
|
: accountsOut;
|
||||||
|
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const oa = TYPE_META[a.type]?.order ?? 99;
|
||||||
|
const ob = TYPE_META[b.type]?.order ?? 99;
|
||||||
|
if (oa !== ob) return oa - ob;
|
||||||
|
return (a.code ?? "").localeCompare(b.code ?? "") || a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group by type for section headers
|
||||||
|
const groups: { type: string; label: string; accounts: typeof filtered }[] = [];
|
||||||
|
for (const a of filtered) {
|
||||||
|
const label = TYPE_META[a.type]?.label ?? a.type;
|
||||||
|
let g = groups.find((x) => x.type === a.type);
|
||||||
|
if (!g) { g = { type: a.type, label, accounts: [] }; groups.push(g); }
|
||||||
|
g.accounts.push(a);
|
||||||
|
}
|
||||||
|
const totalDebit = filtered.reduce((s, a) => s + a.periodDebit, 0);
|
||||||
|
const totalCredit = filtered.reduce((s, a) => s + a.periodCredit, 0);
|
||||||
|
return { groups, totalDebit, totalCredit, accountCount: filtered.length };
|
||||||
|
}, [lines, accountFilter, activityOnly, from, to]);
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await qc.invalidateQueries({ queryKey: ["gl-journal-entries", cid] });
|
||||||
|
setTimeout(() => setRefreshing(false), 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">General Ledger</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">Every account's activity with running balances</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={refresh} disabled={refreshing || isFetching}>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-1 ${refreshing || isFetching ? "animate-spin" : ""}`} /> Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-wrap items-end gap-4 py-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">From</Label>
|
||||||
|
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="h-9 w-[150px]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">To</Label>
|
||||||
|
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-9 w-[150px]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Account</Label>
|
||||||
|
<Select value={accountFilter} onValueChange={setAccountFilter}>
|
||||||
|
<SelectTrigger className="h-9 w-[260px]"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All accounts</SelectItem>
|
||||||
|
{(accounts as any[]).map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>{a.code ? `${a.code} · ` : ""}{a.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-muted-foreground pb-1.5 cursor-pointer select-none">
|
||||||
|
<input type="checkbox" checked={activityOnly} onChange={(e) => setActivityOnly(e.target.checked)} className="h-4 w-4" />
|
||||||
|
Accounts with activity only
|
||||||
|
</label>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{ledger.groups.length === 0 ? (
|
||||||
|
<Card><CardContent className="py-16 text-center text-muted-foreground text-sm">
|
||||||
|
No general ledger activity for this period.
|
||||||
|
</CardContent></Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{ledger.groups.map((g) => (
|
||||||
|
<div key={g.type} className="space-y-4">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{g.label}</h2>
|
||||||
|
{g.accounts.map((a) => (
|
||||||
|
<Card key={a.account_id}>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/40">
|
||||||
|
<div className="font-medium text-sm">{a.code ? `${a.code} · ` : ""}{a.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Beginning balance <span className="font-medium text-foreground tabular-nums ml-1">{money(a.beginning, cur)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[110px]">Date</TableHead>
|
||||||
|
<TableHead>Description</TableHead>
|
||||||
|
<TableHead className="w-[110px]">Ref #</TableHead>
|
||||||
|
<TableHead className="text-right w-[120px]">Debit</TableHead>
|
||||||
|
<TableHead className="text-right w-[120px]">Credit</TableHead>
|
||||||
|
<TableHead className="text-right w-[130px]">Balance</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{a.rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center text-muted-foreground text-sm py-4">
|
||||||
|
No activity in this period.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
a.rows.map((r, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
<TableCell className="text-sm">{fmtDate(r.date)}</TableCell>
|
||||||
|
<TableCell className="text-sm">{r.description || "—"}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{r.reference ?? "—"}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm tabular-nums">{r.debit > 0 ? money(r.debit, cur) : ""}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm tabular-nums">{r.credit > 0 ? money(r.credit, cur) : ""}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm tabular-nums">{money(r.running, cur)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<TableRow className="bg-muted/60 font-semibold">
|
||||||
|
<TableCell colSpan={3} className="text-sm">Ending balance</TableCell>
|
||||||
|
<TableCell className="text-right text-sm tabular-nums">{money(a.periodDebit, cur)}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm tabular-nums">{money(a.periodCredit, cur)}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm tabular-nums">{money(a.ending, cur)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Grand totals */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between py-3 text-sm">
|
||||||
|
<span className="font-semibold">Total — {ledger.accountCount} account{ledger.accountCount === 1 ? "" : "s"}</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Debits <span className="font-semibold text-foreground tabular-nums ml-1">{money(ledger.totalDebit, cur)}</span>
|
||||||
|
<span className="mx-3">·</span>
|
||||||
|
Credits <span className="font-semibold text-foreground tabular-nums ml-1">{money(ledger.totalCredit, cur)}</span>
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ export { default as AccountingLayout } from "./AccountingLayout";
|
|||||||
export { default as AccountingDashboardPage } from "./AccountingDashboardPage";
|
export { default as AccountingDashboardPage } from "./AccountingDashboardPage";
|
||||||
export { default as AccountingChartOfAccountsPage } from "./AccountingChartOfAccountsPage";
|
export { default as AccountingChartOfAccountsPage } from "./AccountingChartOfAccountsPage";
|
||||||
export { default as AccountingJournalEntriesPage } from "./AccountingJournalEntriesPage";
|
export { default as AccountingJournalEntriesPage } from "./AccountingJournalEntriesPage";
|
||||||
|
export { default as AccountingGeneralLedgerPage } from "./AccountingGeneralLedgerPage";
|
||||||
export { default as AccountingInvoicesPage } from "./AccountingInvoicesPage";
|
export { default as AccountingInvoicesPage } from "./AccountingInvoicesPage";
|
||||||
export { default as AccountingBillsPage } from "./AccountingBillsPage";
|
export { default as AccountingBillsPage } from "./AccountingBillsPage";
|
||||||
export { default as AccountingCustomersPage } from "./AccountingCustomersPage";
|
export { default as AccountingCustomersPage } from "./AccountingCustomersPage";
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const NAV: NavSection[] = [
|
|||||||
{ to: "chart-of-accounts", label: "Chart of Accounts" },
|
{ to: "chart-of-accounts", label: "Chart of Accounts" },
|
||||||
{ to: "budgets", label: "Budgeting" },
|
{ to: "budgets", label: "Budgeting" },
|
||||||
{ to: "journal-entries", label: "Journal Entries" },
|
{ to: "journal-entries", label: "Journal Entries" },
|
||||||
|
{ to: "general-ledger", label: "General Ledger" },
|
||||||
{ to: "banking", label: "Banking" },
|
{ to: "banking", label: "Banking" },
|
||||||
{ to: "reconciliation", label: "Reconciliation" },
|
{ to: "reconciliation", label: "Reconciliation" },
|
||||||
{ to: "deposits", label: "Make Deposit" },
|
{ to: "deposits", label: "Make Deposit" },
|
||||||
|
|||||||
Reference in New Issue
Block a user