diff --git a/src/App.tsx b/src/App.tsx index cb06e9c..67b38a8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,7 @@ import { AccountingDashboardPage, AccountingChartOfAccountsPage, AccountingJournalEntriesPage, + AccountingGeneralLedgerPage, AccountingInvoicesPage, AccountingBillsPage, AccountingCustomersPage, @@ -380,6 +381,7 @@ const App = () => ( } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/accounting/AccountingGeneralLedgerPage.tsx b/src/pages/accounting/AccountingGeneralLedgerPage.tsx new file mode 100644 index 0000000..f55e338 --- /dev/null +++ b/src/pages/accounting/AccountingGeneralLedgerPage.tsx @@ -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 = { + 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("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(() => { + 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(); + 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

Select an association.

; + if (companyLoading) return
; + if (companyError || !companyId) return

{companyError || "Accounting setup is not ready."}

; + + return ( +
+
+
+

General Ledger

+

Every account's activity with running balances

+
+ +
+ + {/* Filters */} + + +
+ + setFrom(e.target.value)} className="h-9 w-[150px]" /> +
+
+ + setTo(e.target.value)} className="h-9 w-[150px]" /> +
+
+ + +
+ +
+
+ + {ledger.groups.length === 0 ? ( + + No general ledger activity for this period. + + ) : ( +
+ {ledger.groups.map((g) => ( +
+

{g.label}

+ {g.accounts.map((a) => ( + + +
+
{a.code ? `${a.code} · ` : ""}{a.name}
+
+ Beginning balance {money(a.beginning, cur)} +
+
+ + + + Date + Description + Ref # + Debit + Credit + Balance + + + + {a.rows.length === 0 ? ( + + + No activity in this period. + + + ) : ( + a.rows.map((r, i) => ( + + {fmtDate(r.date)} + {r.description || "—"} + {r.reference ?? "—"} + {r.debit > 0 ? money(r.debit, cur) : ""} + {r.credit > 0 ? money(r.credit, cur) : ""} + {money(r.running, cur)} + + )) + )} + + Ending balance + {money(a.periodDebit, cur)} + {money(a.periodCredit, cur)} + {money(a.ending, cur)} + + +
+
+
+ ))} +
+ ))} + + {/* Grand totals */} + + + Total — {ledger.accountCount} account{ledger.accountCount === 1 ? "" : "s"} + + Debits {money(ledger.totalDebit, cur)} + · + Credits {money(ledger.totalCredit, cur)} + + + +
+ )} +
+ ); +} diff --git a/src/pages/accounting/AccountingIndex.tsx b/src/pages/accounting/AccountingIndex.tsx index 4e9387f..2ef8769 100644 --- a/src/pages/accounting/AccountingIndex.tsx +++ b/src/pages/accounting/AccountingIndex.tsx @@ -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"; diff --git a/src/pages/accounting/AccountingLayout.tsx b/src/pages/accounting/AccountingLayout.tsx index e631138..9598299 100644 --- a/src/pages/accounting/AccountingLayout.tsx +++ b/src/pages/accounting/AccountingLayout.tsx @@ -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" },