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:
2026-06-08 22:17:23 -04:00
parent f315d86e03
commit 4b5c2ea2ea
4 changed files with 296 additions and 0 deletions
+2
View File
@@ -24,6 +24,7 @@ import {
AccountingDashboardPage,
AccountingChartOfAccountsPage,
AccountingJournalEntriesPage,
AccountingGeneralLedgerPage,
AccountingInvoicesPage,
AccountingBillsPage,
AccountingCustomersPage,
@@ -380,6 +381,7 @@ const App = () => (
<Route index element={<AccountingDashboardPage />} />
<Route path="chart-of-accounts" element={<AccountingChartOfAccountsPage />} />
<Route path="journal-entries" element={<AccountingJournalEntriesPage />} />
<Route path="general-ledger" element={<AccountingGeneralLedgerPage />} />
<Route path="invoices" element={<AccountingInvoicesPage />} />
<Route path="bills" element={<AccountingBillsPage />} />
<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>
);
}
+1
View File
@@ -2,6 +2,7 @@ export { default as AccountingLayout } from "./AccountingLayout";
export { default as AccountingDashboardPage } from "./AccountingDashboardPage";
export { default as AccountingChartOfAccountsPage } from "./AccountingChartOfAccountsPage";
export { default as AccountingJournalEntriesPage } from "./AccountingJournalEntriesPage";
export { default as AccountingGeneralLedgerPage } from "./AccountingGeneralLedgerPage";
export { default as AccountingInvoicesPage } from "./AccountingInvoicesPage";
export { default as AccountingBillsPage } from "./AccountingBillsPage";
export { default as AccountingCustomersPage } from "./AccountingCustomersPage";
@@ -45,6 +45,7 @@ const NAV: NavSection[] = [
{ to: "chart-of-accounts", label: "Chart of Accounts" },
{ to: "budgets", label: "Budgeting" },
{ to: "journal-entries", label: "Journal Entries" },
{ to: "general-ledger", label: "General Ledger" },
{ to: "banking", label: "Banking" },
{ to: "reconciliation", label: "Reconciliation" },
{ to: "deposits", label: "Make Deposit" },