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,
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user