From 1296b9449d236ca09614c43eb7fad6708aad2f38 Mon Sep 17 00:00:00 2001 From: renee-png Date: Fri, 19 Jun 2026 13:42:11 -0400 Subject: [PATCH] Accounting: add Import G/L feature on Journal Entries page New GLImportDialog imports a Buildium General Ledger / journal export (CSV or XLSX) into journal entries. Lines are grouped into transactions by Buildium Id (Amount is signed: + debit / - credit), parent-rollup and cash-book rows are skipped, and accounts are matched to the association's chart of accounts by code then name. Shows a preview (counts, date range, trial balance by type) and blocks import on unmatched accounts or unbalanced transactions. Keyed on the Buildium Id, so re-importing the same file skips anything already imported. Co-Authored-By: Claude Opus 4.8 --- .../AccountingJournalEntriesPage.tsx | 15 +- .../accounting/components/GLImportDialog.tsx | 402 ++++++++++++++++++ supabase/.temp/cli-latest | 2 +- 3 files changed, 417 insertions(+), 2 deletions(-) create mode 100644 src/pages/accounting/components/GLImportDialog.tsx diff --git a/src/pages/accounting/AccountingJournalEntriesPage.tsx b/src/pages/accounting/AccountingJournalEntriesPage.tsx index fe23737..d80f234 100644 --- a/src/pages/accounting/AccountingJournalEntriesPage.tsx +++ b/src/pages/accounting/AccountingJournalEntriesPage.tsx @@ -12,10 +12,11 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from " import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; -import { Plus, Trash2, ChevronRight, AlertCircle, Loader2, Pencil, Repeat } from "lucide-react"; +import { Plus, Trash2, ChevronRight, AlertCircle, Loader2, Pencil, Repeat, Upload } from "lucide-react"; import { Link } from "react-router-dom"; import { toast } from "sonner"; import { money, fmtDate } from "./lib/format"; +import GLImportDialog from "./components/GLImportDialog"; // Lines use a single signed amount: + = debit, − = credit type JELine = { id: string; account_id: string; amount: string; description: string }; @@ -34,6 +35,7 @@ export default function AccountingJournalEntriesPage() { const qc = useQueryClient(); const [open, setOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); const [detailId, setDetailId] = useState(null); const [date, setDate] = useState(new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" })); const [description, setDescription] = useState(""); @@ -213,12 +215,23 @@ export default function AccountingJournalEntriesPage() { + + qc.invalidateQueries({ queryKey: ["journal-entries", cid] })} + /> + opening) + date: string; // YYYY-MM-DD + acctCode: string; + acctName: string; // GLAccountName with any leading code stripped + amount: number; // signed: + debit, - credit + memo: string; + reference: string; + payee: string; +}; + +type Txn = { + externalId: string; + date: string; + description: string; + reference: string; + lines: { accountId: string; debit: number; credit: number; description: string }[]; + balanced: boolean; + imbalance: number; +}; + +const norm = (s: string) => s.toLowerCase().replace(/[\s_\-./,]/g, ""); +const stripLeadingCode = (name: string) => name.replace(/^\s*\d+[\s\-]+/, "").trim(); + +function toIso(raw: string): string | null { + const s = (raw || "").trim(); + if (!s) return null; + const iso = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/); + if (iso) return `${iso[1]}-${iso[2].padStart(2, "0")}-${iso[3].padStart(2, "0")}`; + const mdy = s.match(/^(\d{1,2})[/](\d{1,2})[/](\d{2,4})/); + if (mdy) { + let [, m, d, y] = mdy; + if (y.length === 2) y = `20${y}`; + return `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`; + } + return null; +} + +// Read the first sheet of a CSV/XLSX file into rows of objects keyed by header. +async function readRows(file: File): Promise[]> { + const buf = await file.arrayBuffer(); + const wb = XLSX.read(buf, { type: "array" }); + const sheet = wb.Sheets[wb.SheetNames[0]]; + const rows = XLSX.utils.sheet_to_json>(sheet, { defval: "", raw: false }); + return rows.map((r) => { + const o: Record = {}; + for (const k of Object.keys(r)) o[k.trim()] = String(r[k] ?? "").trim(); + return o; + }); +} + +// Find a column whose header matches one of the candidates (case/space-insensitive). +function findCol(headers: string[], candidates: string[]): string | null { + const map = new Map(headers.map((h) => [norm(h), h])); + for (const c of candidates) { + const hit = map.get(norm(c)); + if (hit) return hit; + } + return null; +} + +export default function GLImportDialog({ + open, onOpenChange, companyId, accounts, onSuccess, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; + companyId: string; + accounts: CoaAccount[]; + onSuccess?: () => void; +}) { + const [parsing, setParsing] = useState(false); + const [importing, setImporting] = useState(false); + const [fileName, setFileName] = useState(""); + const [error, setError] = useState(null); + const [lines, setLines] = useState([]); + const fileRef = useRef(null); + + // Account lookup: by code, then by normalized name. + const byCode = useMemo(() => { + const m = new Map(); + for (const a of accounts) if (a.code) m.set(String(a.code).trim(), a); + return m; + }, [accounts]); + const byName = useMemo(() => { + const m = new Map(); + for (const a of accounts) m.set(norm(stripLeadingCode(a.name)), a); + return m; + }, [accounts]); + + const resolveAccount = (code: string, name: string): CoaAccount | null => + (code && byCode.get(code.trim())) || byName.get(norm(name)) || null; + + function reset() { + setLines([]); setError(null); setFileName(""); + if (fileRef.current) fileRef.current.value = ""; + } + + async function onFile(file: File) { + setParsing(true); setError(null); setLines([]); setFileName(file.name); + try { + const rows = await readRows(file); + if (rows.length === 0) { setError("No rows found in the file."); return; } + const headers = Object.keys(rows[0]); + const col = { + id: findCol(headers, ["Id", "JournalId", "TransactionId"]), + date: findCol(headers, ["EntryDate", "Date", "PostDate", "TransactionDate"]), + amount: findCol(headers, ["Amount"]), + code: findCol(headers, ["AccountNumber", "GLAccountNumber", "AccountCode"]), + name: findCol(headers, ["GLAccountName", "AccountName", "GL Account", "Account"]), + memo: findCol(headers, ["PostingMemo", "JournalMemo", "Memo", "Description"]), + jmemo: findCol(headers, ["JournalMemo"]), + ref: findCol(headers, ["ReferenceNumber", "Reference", "CheckNumber"]), + payee: findCol(headers, ["PayeeName", "Payee", "Name"]), + isParent: findCol(headers, ["IsParent"]), + isCash: findCol(headers, ["IsCashPosting"]), + }; + if (!col.amount || !col.name || !col.date) { + setError("This doesn't look like a Buildium G/L export — missing Date, Amount, or Account columns."); + return; + } + const isTrue = (v: string) => ["true", "1", "yes", "y"].includes(String(v).trim().toLowerCase()); + const out: ParsedLine[] = []; + for (const r of rows) { + if (col.isParent && isTrue(r[col.isParent])) continue; // skip rollup parents + if (col.isCash && isTrue(r[col.isCash])) continue; // skip cash book (accrual only) + const amount = Number(String(r[col.amount]).replace(/[$,]/g, "")) || 0; + if (amount === 0) continue; + const txnId = col.id ? String(r[col.id]).trim() : ""; + const date = toIso(r[col.date]) ?? ""; + out.push({ + txnId, + date, + acctCode: col.code ? String(r[col.code]).trim() : "", + acctName: stripLeadingCode(String(r[col.name])), + amount, + memo: col.memo ? String(r[col.memo]).trim() : "", + reference: col.ref ? String(r[col.ref]).trim() : "", + payee: col.payee ? String(r[col.payee]).trim() : "", + }); + } + if (out.length === 0) { setError("No postable lines found (all rows were parents, cash-book, or zero)."); return; } + setLines(out); + } catch (e: any) { + setError(e?.message || "Failed to read file."); + } finally { + setParsing(false); + } + } + + // Build the preview: group into balanced transactions, find unmatched accounts. + const preview = useMemo(() => { + if (lines.length === 0) return null; + const unmatched = new Map(); + const groups = new Map(); + const dates: string[] = []; + for (const l of lines) { + const acct = resolveAccount(l.acctCode, l.acctName); + if (!acct) { + const key = l.acctCode || l.acctName; + const e = unmatched.get(key) ?? { code: l.acctCode, name: l.acctName, count: 0 }; + e.count++; unmatched.set(key, e); + } + const gk = l.txnId || "OPENING"; + (groups.get(gk) ?? groups.set(gk, []).get(gk)!).push(l); + if (l.date) dates.push(l.date); + } + const txns: Txn[] = []; + let typeTotals: Record = {}; + const typeById = new Map(accounts.map((a) => [a.id, a.type])); + for (const [gk, ls] of groups) { + const isOpening = gk === "OPENING"; + const ext = isOpening ? `OPENING-${(dates.slice().sort()[0] ?? "").trim() || "open"}` : gk; + const tlines = ls + .map((l) => { + const acct = resolveAccount(l.acctCode, l.acctName); + if (!acct) return null; + if (typeById.get(acct.id)) { + const t = typeById.get(acct.id)!; + typeTotals[t] = (typeTotals[t] ?? 0) + l.amount; + } + return { + accountId: acct.id, + debit: l.amount > 0 ? l.amount : 0, + credit: l.amount < 0 ? -l.amount : 0, + description: l.memo, + }; + }) + .filter(Boolean) as Txn["lines"]; + const first = ls[0]; + const base = first.memo || "Buildium entry"; + const desc = isOpening ? "Beginning balance" + : first.payee && !base.toLowerCase().includes(first.payee.toLowerCase()) + ? `${base} · ${first.payee}` : base; + const imbalance = ls.reduce((s, l) => s + l.amount, 0); + txns.push({ + externalId: ext, + date: isOpening ? (dates.slice().sort()[0] ?? "") : (first.date || dates.slice().sort()[0] || ""), + description: desc.slice(0, 200), + reference: first.reference, + lines: tlines, + balanced: Math.abs(imbalance) < 0.005, + imbalance, + }); + } + const sorted = dates.slice().sort(); + return { + txns, + unmatched: [...unmatched.values()].sort((a, b) => b.count - a.count), + unbalanced: txns.filter((t) => !t.balanced), + typeTotals, + lineCount: lines.length, + dateRange: sorted.length ? [sorted[0], sorted[sorted.length - 1]] : ["", ""], + }; + }, [lines, accounts]); + + const canImport = preview && preview.unmatched.length === 0 && preview.unbalanced.length === 0 && !importing; + + async function doImport() { + if (!preview) return; + setImporting(true); setError(null); + try { + // Skip transactions already imported (idempotent by external_id). + const exts = preview.txns.map((t) => t.externalId); + const existing = new Set(); + for (let i = 0; i < exts.length; i += 500) { + const { data } = await accounting.from("journal_entries") + .select("external_id") + .eq("company_id", companyId).eq("external_source", EXTERNAL_SOURCE) + .in("external_id", exts.slice(i, i + 500)); + for (const r of data ?? []) if (r.external_id) existing.add(String(r.external_id)); + } + const todo = preview.txns.filter((t) => !existing.has(t.externalId)); + if (todo.length === 0) { toast.info("Everything in this file is already imported."); setImporting(false); return; } + + // Insert headers, then lines (chunked). + const headerRows = todo.map((t) => ({ + company_id: companyId, date: t.date, description: t.description, + reference: t.reference || null, external_source: EXTERNAL_SOURCE, external_id: t.externalId, + })); + const idByExt = new Map(); + for (let i = 0; i < headerRows.length; i += 200) { + const { data, error } = await accounting.from("journal_entries") + .insert(headerRows.slice(i, i + 200)).select("id,external_id"); + if (error) throw error; + for (const r of data ?? []) idByExt.set(String(r.external_id), r.id); + } + const lineRows = todo.flatMap((t) => + t.lines.map((l) => ({ + journal_entry_id: idByExt.get(t.externalId)!, + account_id: l.accountId, debit: l.debit, credit: l.credit, description: l.description || null, + })), + ); + for (let i = 0; i < lineRows.length; i += 500) { + const { error } = await accounting.from("journal_entry_lines").insert(lineRows.slice(i, i + 500)); + if (error) throw error; + } + toast.success(`Imported ${todo.length} transactions (${lineRows.length} lines).`); + onSuccess?.(); + onOpenChange(false); + reset(); + } catch (e: any) { + setError(e?.message || "Import failed."); + } finally { + setImporting(false); + } + } + + return ( + { onOpenChange(o); if (!o) reset(); }}> + + + Import General Ledger + + Upload a Buildium G/L export (CSV or Excel). Transactions are grouped by Buildium Id and + posted as journal entries; re-importing the same file skips anything already imported. + + + +
+
+ { const f = e.target.files?.[0]; if (f) onFile(f); }} + /> + +
+ + {error && ( + + + Couldn't import + {error} + + )} + + {preview && ( + <> +
+ + + +
+ +
+

Trial balance (debit + / credit −)

+
+ {Object.entries(preview.typeTotals).sort().map(([t, v]) => ( +
{t}{money(v, "USD")}
+ ))} +
+
+ + {preview.unmatched.length > 0 ? ( + + + {preview.unmatched.length} account(s) not in this association's chart of accounts + + Add these to the chart of accounts (matching code or name), then re-upload: + + CodeNameLines + + {preview.unmatched.slice(0, 15).map((u) => ( + {u.code || "—"}{u.name}{u.count} + ))} + +
+
+
+ ) : preview.unbalanced.length > 0 ? ( + + + {preview.unbalanced.length} transaction(s) don't balance + + e.g. Id {preview.unbalanced[0].externalId} is off by {money(preview.unbalanced[0].imbalance, "USD")}. Check the export. + + + ) : ( + + + Ready to import + All accounts matched and every transaction balances. + + )} + + )} +
+ + + + + +
+
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/supabase/.temp/cli-latest b/supabase/.temp/cli-latest index 95ea209..114c98e 100644 --- a/supabase/.temp/cli-latest +++ b/supabase/.temp/cli-latest @@ -1 +1 @@ -v2.106.0 \ No newline at end of file +v2.107.0 \ No newline at end of file