mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string | null>(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() {
|
||||
<Button asChild variant="outline">
|
||||
<Link to="/dashboard/accounting/recurring"><Repeat className="h-4 w-4 mr-1" /> Recurring</Link>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setImportOpen(true)}>
|
||||
<Upload className="h-4 w-4 mr-1" /> Import G/L
|
||||
</Button>
|
||||
<Button onClick={() => { resetForm(); setOpen(true); }}>
|
||||
<Plus className="h-4 w-4 mr-1" /> New entry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GLImportDialog
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
companyId={cid}
|
||||
accounts={accounts as any[]}
|
||||
onSuccess={() => qc.invalidateQueries({ queryKey: ["journal-entries", cid] })}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<PeriodPicker
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import * as XLSX from "xlsx";
|
||||
import { toast } from "sonner";
|
||||
import { accounting } from "@/lib/accountingClient";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { AlertCircle, CheckCircle2, AlertTriangle, Loader2, Upload } from "lucide-react";
|
||||
import { money } from "../lib/format";
|
||||
|
||||
// Importer for a Buildium General Ledger / journal export (CSV or XLSX).
|
||||
//
|
||||
// Buildium's journal export has one row per GL line. Lines that share an `Id`
|
||||
// form one transaction (PostingId is the per-line id). `Amount` is signed —
|
||||
// debit-positive / credit-negative — and the lines of a transaction sum to 0.
|
||||
// Beginning-balance rows carry a blank `Id` and are grouped into one opening
|
||||
// entry. Parent rollup accounts (IsParent) and the cash book (IsCashPosting)
|
||||
// are skipped so the accrual book isn't double-counted.
|
||||
//
|
||||
// Accounts are matched to the association's chart of accounts by code
|
||||
// (AccountNumber) first, then by name (GLAccountName, leading code stripped).
|
||||
// Transactions are keyed on the Buildium `Id` (external_id) so re-importing the
|
||||
// same file is a no-op — already-imported transactions are skipped.
|
||||
|
||||
const EXTERNAL_SOURCE = "buildium_gl_csv";
|
||||
|
||||
type CoaAccount = { id: string; code: string | null; name: string; type: string };
|
||||
|
||||
type ParsedLine = {
|
||||
txnId: string; // Buildium Id ("" -> 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<Record<string, string>[]> {
|
||||
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<Record<string, any>>(sheet, { defval: "", raw: false });
|
||||
return rows.map((r) => {
|
||||
const o: Record<string, string> = {};
|
||||
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<string>("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lines, setLines] = useState<ParsedLine[]>([]);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Account lookup: by code, then by normalized name.
|
||||
const byCode = useMemo(() => {
|
||||
const m = new Map<string, CoaAccount>();
|
||||
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<string, CoaAccount>();
|
||||
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<string, { code: string; name: string; count: number }>();
|
||||
const groups = new Map<string, ParsedLine[]>();
|
||||
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<string, number> = {};
|
||||
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<string>();
|
||||
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<string, string>();
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(o) => { onOpenChange(o); if (!o) reset(); }}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import General Ledger</DialogTitle>
|
||||
<DialogDescription>
|
||||
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.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<input
|
||||
ref={fileRef} type="file" accept=".csv,.xlsx,.xls"
|
||||
className="hidden"
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) onFile(f); }}
|
||||
/>
|
||||
<Button variant="outline" onClick={() => fileRef.current?.click()} disabled={parsing}>
|
||||
{parsing ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Upload className="h-4 w-4 mr-1" />}
|
||||
{fileName || "Choose file…"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Couldn't import</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3 text-sm">
|
||||
<Stat label="Transactions" value={String(preview.txns.length)} />
|
||||
<Stat label="Lines" value={String(preview.lineCount)} />
|
||||
<Stat label="Date range" value={preview.dateRange[0] ? `${preview.dateRange[0]} → ${preview.dateRange[1]}` : "—"} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border p-3">
|
||||
<p className="text-sm font-medium mb-2">Trial balance (debit + / credit −)</p>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm tabular-nums">
|
||||
{Object.entries(preview.typeTotals).sort().map(([t, v]) => (
|
||||
<div key={t} className="flex justify-between"><span className="capitalize text-muted-foreground">{t}</span><span>{money(v, "USD")}</span></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{preview.unmatched.length > 0 ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>{preview.unmatched.length} account(s) not in this association's chart of accounts</AlertTitle>
|
||||
<AlertDescription>
|
||||
Add these to the chart of accounts (matching code or name), then re-upload:
|
||||
<Table className="mt-2">
|
||||
<TableHeader><TableRow><TableHead>Code</TableHead><TableHead>Name</TableHead><TableHead className="text-right">Lines</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{preview.unmatched.slice(0, 15).map((u) => (
|
||||
<TableRow key={u.code + u.name}><TableCell>{u.code || "—"}</TableCell><TableCell>{u.name}</TableCell><TableCell className="text-right">{u.count}</TableCell></TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : preview.unbalanced.length > 0 ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>{preview.unbalanced.length} transaction(s) don't balance</AlertTitle>
|
||||
<AlertDescription>
|
||||
e.g. Id {preview.unbalanced[0].externalId} is off by {money(preview.unbalanced[0].imbalance, "USD")}. Check the export.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
<AlertTitle>Ready to import</AlertTitle>
|
||||
<AlertDescription>All accounts matched and every transaction balances.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button onClick={doImport} disabled={!canImport}>
|
||||
{importing ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : null}
|
||||
Import{preview ? ` ${preview.txns.length}` : ""}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border p-3">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-sm font-medium mt-0.5">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
v2.106.0
|
||||
v2.107.0
|
||||
Reference in New Issue
Block a user