mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
7eb08ad29f
The monthly P&L was bound to the page Period, which defaults to the current month — so 'Monthly columns' showed only a single column. When the selected period is a single month, widen the start to the fiscal year containing the end date (per company fiscal_year_start) so every month of the FY-to-date gets a column with none missing. A multi-month selected range is still respected as-is. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2642 lines
136 KiB
TypeScript
2642 lines
136 KiB
TypeScript
import { Link } from "react-router-dom";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { Fragment, useEffect, useMemo, useState } from "react";
|
||
import { accounting } from "@/lib/accountingClient";
|
||
import { supabase } from "@/integrations/supabase/client";
|
||
import { useCompanyId } from "./lib/useCompanyId";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RTooltip, Legend, ResponsiveContainer } from "recharts";
|
||
import { FileText, Download, FileDown, Eye, RefreshCw, Layers, Trash2, Loader2 } from "lucide-react";
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
import { generateBatchPdf, BATCHABLE_REPORTS } from "./lib/batchReports";
|
||
import { toast } from "sonner";
|
||
import { money, fmtDate } from "./lib/format";
|
||
import jsPDF from "jspdf";
|
||
import autoTable from "jspdf-autotable";
|
||
import {
|
||
renderReportPdf, fmtAmount,
|
||
type StructuredReport, type StructuredRow,
|
||
} from "./lib/reportPdf";
|
||
import { reconcile, type RecAccount, type RecLine, type RecCheck } from "./lib/reconcile";
|
||
import {
|
||
computePnL, computeMargins, toMinor, fromMinor, PnlValidationError,
|
||
type PnlAccount, type PnlClassification, type Posting as PnlPosting, type PnlResult,
|
||
} from "./lib/pnl";
|
||
import { Lock } from "lucide-react";
|
||
import { TrialBalanceReport } from "./components/TrialBalanceReport";
|
||
import { GeneralLedgerReport } from "./components/GeneralLedgerReport";
|
||
import { ReserveFundReport } from "./components/ReserveFundReport";
|
||
import { ARAgingPropertyReport } from "./components/ARAgingPropertyReport";
|
||
import { PrepaidHomeownersReport } from "./components/PrepaidHomeownersReport";
|
||
import { CashDisbursementReport } from "./components/CashDisbursementReport";
|
||
import { ReportSheet } from "./components/ReportSheet";
|
||
import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter, type BrandedLogo } from "./lib/reportHeader";
|
||
import { generateBudgetVsActualPdf } from "@/lib/budgetVsActualPdf";
|
||
|
||
type ReportId =
|
||
| "pnl" | "income-statement" | "balance-sheet" | "cash-flow" | "movement-of-equity" | "budget-vs-actuals"
|
||
| "trial-balance" | "general-ledger"
|
||
| "invoice-summary" | "customer-balances" | "ar-aging" | "ar-aging-property" | "prepaid-homeowners" | "homeowner-summary" | "delinquency"
|
||
| "expense-summary" | "vendor-balances" | "ap-aging" | "cash-disbursement" | "reconciliation"
|
||
| "reserve-fund";
|
||
|
||
const APP_NAME = "Cozy Books";
|
||
const FINANCIAL: ReportId[] = ["pnl", "balance-sheet", "cash-flow", "movement-of-equity"];
|
||
|
||
const GROUPS = [
|
||
{ name: "Business Overview", reports: [
|
||
{ id: "pnl" as ReportId, name: "Profit & Loss" },
|
||
{ id: "balance-sheet" as ReportId, name: "Balance Sheet" },
|
||
{ id: "cash-flow" as ReportId, name: "Cash Flow Statement" },
|
||
{ id: "movement-of-equity" as ReportId, name: "Movement of Equity" },
|
||
{ id: "trial-balance" as ReportId, name: "Trial Balance" },
|
||
{ id: "general-ledger" as ReportId, name: "General Ledger" },
|
||
{ id: "budget-vs-actuals" as ReportId, name: "Budget vs Actuals" },
|
||
]},
|
||
{ name: "Receivables", reports: [
|
||
{ id: "ar-aging-property" as ReportId, name: "AR Aging (Property)" },
|
||
{ id: "prepaid-homeowners" as ReportId, name: "Pre-Paid Homeowners" },
|
||
{ id: "ar-aging" as ReportId, name: "AR Aging Details" },
|
||
{ id: "homeowner-summary" as ReportId, name: "Homeowner Balance Summary" },
|
||
{ id: "customer-balances" as ReportId, name: "Invoice Summary by Customer" },
|
||
{ id: "invoice-summary" as ReportId, name: "Invoice Summary" },
|
||
{ id: "delinquency" as ReportId, name: "Delinquency Report" },
|
||
]},
|
||
{ name: "Payables", reports: [
|
||
{ id: "cash-disbursement" as ReportId, name: "Cash Disbursement" },
|
||
{ id: "ap-aging" as ReportId, name: "AP Aging Details" },
|
||
{ id: "expense-summary" as ReportId, name: "Expense Summary" },
|
||
{ id: "vendor-balances" as ReportId, name: "Vendor Balance Summary" },
|
||
]},
|
||
{ name: "Reserves", reports: [
|
||
{ id: "reserve-fund" as ReportId, name: "Reserve Fund Schedule" },
|
||
]},
|
||
{ name: "Audit", reports: [
|
||
{ id: "reconciliation" as ReportId, name: "Reconciliation Checks" },
|
||
]},
|
||
];
|
||
|
||
const TZ_ET = "America/New_York";
|
||
function etDateStr(d = new Date()) { return d.toLocaleDateString("en-CA", { timeZone: TZ_ET }); } // YYYY-MM-DD in ET
|
||
function startOfYear() { return `${new Date().toLocaleDateString("en-US", { timeZone: TZ_ET, year: "numeric" })}-01-01`; }
|
||
function today() { return etDateStr(); }
|
||
function shiftBack(from: string, to: string) {
|
||
const d1 = new Date(from), d2 = new Date(to);
|
||
const span = d2.getTime() - d1.getTime();
|
||
const pTo = new Date(d1.getTime() - 86400000);
|
||
const pFrom = new Date(pTo.getTime() - span);
|
||
return { from: pFrom.toISOString().slice(0, 10), to: pTo.toISOString().slice(0, 10) };
|
||
}
|
||
|
||
// PostgREST caps each response at 1000 rows. Reports aggregate GL lines
|
||
// client-side, so for companies with >1000 journal lines we must page through
|
||
// all of them — otherwise balances are truncated (accounts whose activity
|
||
// falls past row 1000 silently read as $0). Order by a stable key (id) so the
|
||
// pages don't overlap or skip rows.
|
||
const GL_PAGE = 1000;
|
||
async function fetchAllGLLines(cid: string, to: string, select: string, from?: string, includeArchived = false): Promise<any[]> {
|
||
const out: any[] = [];
|
||
for (let offset = 0; ; offset += GL_PAGE) {
|
||
let q = accounting
|
||
.from("journal_entry_lines")
|
||
.select(select)
|
||
.eq("journal_entries.company_id", cid)
|
||
.lte("journal_entries.date", to);
|
||
// Archived accounts are normally kept off the financial statements, BUT an
|
||
// archived account that still holds a balance MUST stay on them — otherwise
|
||
// its balance is silently dropped and the Balance Sheet goes out of balance
|
||
// by exactly that amount. Including archived accounts here is safe: this
|
||
// queries journal lines, so archived accounts with no activity never appear.
|
||
if (!includeArchived) q = q.eq("accounts.is_archived", false);
|
||
if (from) q = q.gte("journal_entries.date", from);
|
||
const { data, error } = await q.order("id", { ascending: true }).range(offset, offset + GL_PAGE - 1);
|
||
if (error) throw error;
|
||
const rows = (data ?? []) as any[];
|
||
out.push(...rows);
|
||
if (rows.length < GL_PAGE) break;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Shared fetch for the financial reports (also used by the report-batch engine).
|
||
export async function fetchReportData(cid: string, from: string, to: string) {
|
||
const ytdStart = new Date(new Date().getFullYear(), 0, 1).toISOString().slice(0,10);
|
||
const [inv, bills, accs, exp, custs, vends, ob, ytdInv, ytdExp, ytdBills, allBills, glRes, glCumRes, allInvRes, companyRes, periodBillItemsRes] = await Promise.all([
|
||
accounting.from("invoices").select("number,total,paid_amount,status,issue_date,customers(name)").eq("company_id", cid).gte("issue_date", from).lte("issue_date", to),
|
||
accounting.from("bills").select("number,total,paid_amount,status,issue_date,due_date,vendors(name)").eq("company_id", cid).gte("issue_date", from).lte("issue_date", to),
|
||
accounting.from("accounts").select("id,name,code,type,subtype,balance,is_bank,parent_account_id").eq("company_id", cid).eq("is_archived", false),
|
||
accounting.from("expenses").select("date,category,amount,vendor_name,vendors(name)").eq("company_id", cid).gte("date", from).lte("date", to),
|
||
accounting.from("customers").select("id,name,balance,email,phone,property_address,lot_number").eq("company_id", cid).order("name"),
|
||
accounting.from("vendors").select("id,name").eq("company_id", cid),
|
||
accounting.from("opening_balances").select("account_id,debit,credit").eq("company_id", cid),
|
||
accounting.from("invoices").select("total,status,issue_date").eq("company_id", cid).gte("issue_date", ytdStart).lte("issue_date", to),
|
||
accounting.from("expenses").select("amount,date").eq("company_id", cid).gte("date", ytdStart).lte("date", to),
|
||
// YTD bills (accrual — for net income in Balance Sheet / Movement of Equity)
|
||
accounting.from("bills").select("total,status,issue_date").eq("company_id", cid).gte("issue_date", ytdStart).lte("issue_date", to),
|
||
// All bills (not date-filtered) for AP aging
|
||
accounting.from("bills").select("id,vendor_id,total,paid_amount,status,due_date,issue_date,vendors(id,name)").eq("company_id", cid),
|
||
// General-ledger lines in period — P&L is built from these, grouped by account
|
||
fetchAllGLLines(cid, to, "id,debit,credit,accounts!inner(id,name,code,type,parent_account_id),journal_entries!inner(company_id,date)", from, true),
|
||
// Cumulative GL through `to` — Balance Sheet is built from these (as-of balances)
|
||
fetchAllGLLines(cid, to, "id,debit,credit,account_id,accounts!inner(id,name,code,type),journal_entries!inner(company_id,date)", undefined, true),
|
||
// All invoices (not date-filtered) — Accounts Receivable = unpaid invoices
|
||
accounting.from("invoices").select("total,paid_amount,status").eq("company_id", cid),
|
||
// Whether the platform manages this company's GL (A/R-A/P sub-ledgers tie to the GL).
|
||
// Imported-GL companies (gl_auto_post=false) keep their own AR/AP, so the sub-ledger
|
||
// vs GL control reconciliation (R7/R8) does not apply to them.
|
||
accounting.from("companies").select("gl_auto_post").eq("id", cid).maybeSingle(),
|
||
// Bill items for bills issued in the period — lets the Expense Summary
|
||
// back out the expense of bills that aren't paid yet (cash-aware view).
|
||
accounting.from("bill_items").select("account_id,amount,bills!inner(total,paid_amount,status,issue_date,company_id)").eq("bills.company_id", cid).gte("bills.issue_date", from).lte("bills.issue_date", to).not("account_id", "is", null),
|
||
]);
|
||
return {
|
||
invoices: inv.data ?? [], bills: bills.data ?? [], accounts: accs.data ?? [],
|
||
expenses: exp.data ?? [], customers: custs.data ?? [], vendors: vends.data ?? [],
|
||
openingBalances: ob.data ?? [],
|
||
ytdInvoices: ytdInv.data ?? [], ytdExpenses: ytdExp.data ?? [], ytdBills: ytdBills.data ?? [],
|
||
allBills: allBills.data ?? [],
|
||
glLines: glRes ?? [],
|
||
glCumulative: glCumRes ?? [],
|
||
allInvoices: allInvRes.data ?? [],
|
||
glManaged: companyRes.data ? companyRes.data.gl_auto_post !== false : true,
|
||
periodBillItems: periodBillItemsRes.data ?? [],
|
||
from, asOf: to,
|
||
};
|
||
}
|
||
|
||
function useReportData(cid: string, from: string, to: string) {
|
||
return useQuery({
|
||
queryKey: ["reports-data", cid, from, to],
|
||
enabled: !!cid,
|
||
queryFn: () => fetchReportData(cid, from, to),
|
||
});
|
||
}
|
||
|
||
// ── Period preset helpers ────────────────────────────────────────────────────
|
||
|
||
function etNow() {
|
||
// Current date/time as a Date object normalised to ET by parsing the ET date string
|
||
const etStr = new Date().toLocaleDateString("en-US", { timeZone: TZ_ET, year: "numeric", month: "2-digit", day: "2-digit" });
|
||
return new Date(etStr); // local Date at ET midnight
|
||
}
|
||
function startOfMonth(d = etNow()) {
|
||
return new Date(d.getFullYear(), d.getMonth(), 1).toLocaleDateString("en-CA");
|
||
}
|
||
function endOfMonth(d = etNow()) {
|
||
return new Date(d.getFullYear(), d.getMonth() + 1, 0).toLocaleDateString("en-CA");
|
||
}
|
||
function startOfLastMonth() {
|
||
const d = etNow(); d.setMonth(d.getMonth() - 1);
|
||
return startOfMonth(d);
|
||
}
|
||
function endOfLastMonth() {
|
||
const d = etNow(); d.setMonth(d.getMonth() - 1);
|
||
return endOfMonth(d);
|
||
}
|
||
function startOfLastYear() {
|
||
return `${etNow().getFullYear() - 1}-01-01`;
|
||
}
|
||
function endOfLastYear() {
|
||
return `${etNow().getFullYear() - 1}-12-31`;
|
||
}
|
||
|
||
type Preset = "this-month" | "last-month" | "this-quarter" | "ytd" | "last-year" | "custom";
|
||
type CompareMode = "none" | "prior-period" | "prior-year" | "custom";
|
||
|
||
function presetDates(p: Preset): { from: string; to: string } {
|
||
const now = new Date();
|
||
const q = Math.floor(now.getMonth() / 3);
|
||
switch (p) {
|
||
case "this-month": return { from: startOfMonth(), to: endOfMonth() };
|
||
case "last-month": return { from: startOfLastMonth(), to: endOfLastMonth() };
|
||
case "this-quarter": return { from: new Date(now.getFullYear(), q * 3, 1).toISOString().slice(0, 10), to: today() };
|
||
case "ytd": return { from: startOfYear(), to: today() };
|
||
case "last-year": return { from: startOfLastYear(), to: endOfLastYear() };
|
||
default: return { from: startOfYear(), to: today() };
|
||
}
|
||
}
|
||
|
||
function compareDates(mode: CompareMode, from: string, to: string, customFrom: string, customTo: string) {
|
||
if (mode === "none") return null;
|
||
if (mode === "prior-period") return shiftBack(from, to);
|
||
if (mode === "prior-year") {
|
||
const py = (d: string) => `${parseInt(d.slice(0, 4)) - 1}${d.slice(4)}`;
|
||
return { from: py(from), to: py(to) };
|
||
}
|
||
if (mode === "custom") return { from: customFrom, to: customTo };
|
||
return null;
|
||
}
|
||
|
||
export default function AccountingReportsPage({ association }: { association?: { id: string; name?: string } | null } = {}) {
|
||
const { companyId, associationName, associationId } = useCompanyId(association);
|
||
const qc = useQueryClient();
|
||
const [refreshing, setRefreshing] = useState(false);
|
||
const refreshReport = async () => {
|
||
setRefreshing(true);
|
||
await qc.invalidateQueries();
|
||
setTimeout(() => setRefreshing(false), 600);
|
||
};
|
||
const cid = companyId ?? "";
|
||
const cur = "USD";
|
||
const [active, setActive] = useState<ReportId>("pnl");
|
||
// Drill-down: clicking an account amount in a financial report opens the
|
||
// General Ledger focused on that account (its transaction list for the COA).
|
||
const [drillAccountId, setDrillAccountId] = useState<string | null>(null);
|
||
const drillToAccount = (accountId: string) => { setDrillAccountId(accountId); setActive("general-ledger"); };
|
||
|
||
// Period
|
||
const [preset, setPreset] = useState<Preset>("ytd");
|
||
const [from, setFrom] = useState(startOfMonth());
|
||
const [to, setTo] = useState(today());
|
||
|
||
// ── Report batches (saved per-company report packets) ──
|
||
const [batchOpen, setBatchOpen] = useState(false);
|
||
const [batchName, setBatchName] = useState("");
|
||
const [batchReportIds, setBatchReportIds] = useState<string[]>(["balance-sheet", "pnl", "trial-balance", "general-ledger"]);
|
||
const [batchLoadedId, setBatchLoadedId] = useState<string | null>(null);
|
||
const [generatingBatch, setGeneratingBatch] = useState(false);
|
||
const { data: savedBatches = [], refetch: refetchBatches } = useQuery({
|
||
queryKey: ["report-batches", cid],
|
||
enabled: !!cid,
|
||
queryFn: async () => (await accounting.from("report_batches").select("*").eq("company_id", cid).order("name")).data ?? [],
|
||
});
|
||
|
||
const toggleBatchReport = (id: string) =>
|
||
setBatchReportIds((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]);
|
||
|
||
const saveBatch = async () => {
|
||
const name = batchName.trim();
|
||
if (!name) { toast.error("Name the batch first"); return; }
|
||
if (batchReportIds.length === 0) { toast.error("Select at least one report"); return; }
|
||
if (batchLoadedId) {
|
||
const { error } = await accounting.from("report_batches").update({ name, report_ids: batchReportIds, updated_at: new Date().toISOString() }).eq("id", batchLoadedId);
|
||
if (error) { toast.error(error.message); return; }
|
||
} else {
|
||
const { data, error } = await accounting.from("report_batches").insert({ company_id: cid, name, report_ids: batchReportIds }).select("id").single();
|
||
if (error) { toast.error(error.message); return; }
|
||
setBatchLoadedId(data.id);
|
||
}
|
||
toast.success(`Batch "${name}" saved`);
|
||
refetchBatches();
|
||
};
|
||
|
||
const loadBatch = (b: any) => {
|
||
setBatchLoadedId(b.id); setBatchName(b.name);
|
||
setBatchReportIds(Array.isArray(b.report_ids) ? b.report_ids : []);
|
||
};
|
||
const deleteBatch = async (id: string) => {
|
||
await accounting.from("report_batches").delete().eq("id", id);
|
||
if (batchLoadedId === id) { setBatchLoadedId(null); setBatchName(""); }
|
||
refetchBatches();
|
||
toast.success("Batch deleted");
|
||
};
|
||
|
||
const generateBatch = async () => {
|
||
if (batchReportIds.length === 0) { toast.error("Select at least one report"); return; }
|
||
setGeneratingBatch(true);
|
||
try {
|
||
const doc = await generateBatchPdf({
|
||
companyId: cid, companyName: associationName ?? "Company",
|
||
from, to, currency: cur, fetchReportData, buildFinancial,
|
||
}, batchReportIds);
|
||
const slug = (associationName ?? "report").replace(/[^a-z0-9]+/gi, "-").toLowerCase().slice(0, 40);
|
||
doc.save(`${slug}-report-packet-${from}-to-${to}.pdf`);
|
||
toast.success("Report package generated");
|
||
} catch (e: any) {
|
||
toast.error(e?.message || "Could not generate package");
|
||
} finally {
|
||
setGeneratingBatch(false);
|
||
}
|
||
};
|
||
|
||
const applyPreset = (p: Preset) => {
|
||
setPreset(p);
|
||
if (p !== "custom") {
|
||
const d = presetDates(p);
|
||
setFrom(d.from);
|
||
setTo(d.to);
|
||
}
|
||
};
|
||
|
||
// Comparison
|
||
const [compareMode, setCompareMode] = useState<CompareMode>("none");
|
||
const [compareFrom, setCompareFrom] = useState(startOfLastYear());
|
||
const [compareTo, setCompareTo] = useState(endOfLastYear());
|
||
const showCompare = compareMode !== "none";
|
||
const comparePeriod = compareDates(compareMode, from, to, compareFrom, compareTo);
|
||
|
||
// Toggles
|
||
const [showCodes, setShowCodes] = useState(false);
|
||
const [showZero, setShowZero] = useState(false);
|
||
const [pnlMonthView, setPnlMonthView] = useState(false);
|
||
|
||
const { data: companyMeta } = useQuery({
|
||
queryKey: ["company-fy", cid],
|
||
enabled: !!cid,
|
||
queryFn: async () => (await accounting.from("companies").select("fiscal_year_start").eq("id", cid).maybeSingle()).data,
|
||
});
|
||
const fiscalYearStart = (companyMeta as any)?.fiscal_year_start || "01-01";
|
||
|
||
// Association logo for branded reports (ACM fallback handled downstream).
|
||
const { data: assocMeta } = useQuery({
|
||
queryKey: ["assoc-logo", associationId],
|
||
enabled: !!associationId,
|
||
queryFn: async () => (await supabase.from("associations").select("logo_url").eq("id", associationId!).maybeSingle()).data,
|
||
});
|
||
const logoUrl = (assocMeta as any)?.logo_url || null;
|
||
// Preloaded logo dataURL for synchronous PDF header drawing.
|
||
const { data: brandedLogo } = useQuery({
|
||
queryKey: ["branded-logo", logoUrl],
|
||
queryFn: async () => await loadBrandedLogo(logoUrl),
|
||
});
|
||
|
||
const { data } = useReportData(cid, from, to);
|
||
const { data: prevData } = useReportData(
|
||
showCompare && comparePeriod ? cid : "",
|
||
comparePeriod?.from ?? from,
|
||
comparePeriod?.to ?? to
|
||
);
|
||
|
||
// AR open invoices — used by AR aging, homeowner summary, delinquency
|
||
const arReports: ReportId[] = ["customer-balances", "ar-aging", "homeowner-summary", "delinquency"];
|
||
const { data: arOpen = [] } = useQuery({
|
||
queryKey: ["ar-aging", cid],
|
||
enabled: !!cid && arReports.includes(active),
|
||
queryFn: async () => {
|
||
const { data } = await accounting
|
||
.from("invoices")
|
||
.select("id,customer_id,total,paid_amount,due_date,issue_date,status,number,customers(id,name)")
|
||
.eq("company_id", cid);
|
||
return data ?? [];
|
||
},
|
||
});
|
||
|
||
const isFinancial = FINANCIAL.includes(active);
|
||
const activeMeta = GROUPS.flatMap(g => g.reports).find(r => r.id === active)!;
|
||
const rangeLabel = active === "balance-sheet"
|
||
? `As of ${fmtDate(to)}`
|
||
: `${fmtDate(from)} – ${fmtDate(to)}`;
|
||
|
||
const structured = useMemo<StructuredReport | null>(() => {
|
||
if (!data || !isFinancial) return null;
|
||
return buildFinancial(active, data, prevData, showCompare);
|
||
}, [active, data, prevData, showCompare, isFinancial]);
|
||
|
||
const flat = useMemo(() => (data && !isFinancial) ? buildFlat(active, data, cur) : null, [active, data, cur, isFinancial]);
|
||
|
||
// Build exportable flat data for custom-rendered reports (aging, delinquency, etc.)
|
||
const exportFlat = useMemo((): Flat | null => {
|
||
if (structured || flat) return null; // already have a better source
|
||
const m = (n: number) => money(n, cur);
|
||
const now = new Date();
|
||
|
||
if (active === "reconciliation") {
|
||
if (!data) return null;
|
||
const checks = buildReconChecks(data);
|
||
return {
|
||
title: "Reconciliation Checks",
|
||
columns: ["Check", "Residual", "Status"],
|
||
rows: checks.map((c) => [`${c.id} ${c.label}`, m(c.residual), c.pass ? "Pass" : "FAIL"]),
|
||
boldRows: [],
|
||
};
|
||
}
|
||
|
||
if (active === "ar-aging" || active === "customer-balances") {
|
||
type AR = { name: string; current: number; d30: number; d60: number; d90: number; d90p: number; total: number };
|
||
const byC = new Map<string, AR>();
|
||
for (const inv of arOpen as any[]) {
|
||
const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0);
|
||
if (open <= 0) continue;
|
||
const cid2 = inv.customer_id; if (!cid2) continue;
|
||
const name = inv.customers?.name ?? "—";
|
||
const days = Math.floor((now.getTime() - new Date(inv.due_date ?? inv.issue_date).getTime()) / 86400000);
|
||
const r = byC.get(cid2) ?? { name, current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 };
|
||
if (days <= 0) r.current += open; else if (days <= 30) r.d30 += open; else if (days <= 60) r.d60 += open; else if (days <= 90) r.d90 += open; else r.d90p += open;
|
||
r.total += open; byC.set(cid2, r);
|
||
}
|
||
const list = [...byC.values()].sort((a, b) => b.total - a.total);
|
||
const tot = list.reduce((s, r) => ({ current: s.current + r.current, d30: s.d30 + r.d30, d60: s.d60 + r.d60, d90: s.d90 + r.d90, d90p: s.d90p + r.d90p, total: s.total + r.total }), { current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 });
|
||
return {
|
||
title: activeMeta.name, columns: ["Homeowner", "Current", "1-30 days", "31-60 days", "61-90 days", "90+ days", "Total"],
|
||
rows: [...list.map(r => [r.name, m(r.current), m(r.d30), m(r.d60), m(r.d90), m(r.d90p), m(r.total)]),
|
||
["TOTAL", m(tot.current), m(tot.d30), m(tot.d60), m(tot.d90), m(tot.d90p), m(tot.total)]],
|
||
boldRows: [list.length],
|
||
};
|
||
}
|
||
|
||
if (active === "ap-aging") {
|
||
type AP = { name: string; current: number; d30: number; d60: number; d90: number; d90p: number; total: number };
|
||
const byV = new Map<string, AP>();
|
||
for (const b of ((data as any)?.allBills ?? []) as any[]) {
|
||
const open = Number(b.total ?? 0) - Number(b.paid_amount ?? 0);
|
||
if (open <= 0) continue;
|
||
const vid = b.vendor_id ?? b.id; const name = b.vendors?.name ?? "Unknown";
|
||
const days = Math.floor((now.getTime() - new Date(b.due_date ?? b.issue_date ?? Date.now()).getTime()) / 86400000);
|
||
const r = byV.get(vid) ?? { name, current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 };
|
||
if (days <= 0) r.current += open; else if (days <= 30) r.d30 += open; else if (days <= 60) r.d60 += open; else if (days <= 90) r.d90 += open; else r.d90p += open;
|
||
r.total += open; byV.set(vid, r);
|
||
}
|
||
const list = [...byV.values()].sort((a, b) => b.total - a.total);
|
||
return {
|
||
title: "AP Aging Details", columns: ["Vendor", "Current", "1-30 days", "31-60 days", "61-90 days", "90+ days", "Total"],
|
||
rows: list.map(r => [r.name, m(r.current), m(r.d30), m(r.d60), m(r.d90), m(r.d90p), m(r.total)]),
|
||
};
|
||
}
|
||
|
||
if (active === "homeowner-summary") {
|
||
const customers = (data as any)?.customers ?? [];
|
||
const openByC = new Map<string, { open: number; count: number }>();
|
||
for (const inv of arOpen as any[]) {
|
||
const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0);
|
||
if (open > 0) { const e = openByC.get(inv.customer_id) ?? { open: 0, count: 0 }; e.open += open; e.count++; openByC.set(inv.customer_id, e); }
|
||
}
|
||
const rows = (customers as any[])
|
||
.map((c: any) => [c.name, c.property_address ?? "—", c.lot_number ? `Lot ${c.lot_number}` : "—", String(openByC.get(c.id)?.count ?? 0), m(openByC.get(c.id)?.open ?? 0)])
|
||
.sort((a, b) => parseFloat(String(b[4]).replace(/[^0-9.]/g, "")) - parseFloat(String(a[4]).replace(/[^0-9.]/g, "")));
|
||
return { title: "Homeowner Balance Summary", columns: ["Homeowner", "Property", "Lot", "Open Invoices", "Outstanding"] , rows };
|
||
}
|
||
|
||
if (active === "delinquency") {
|
||
const customers = (data as any)?.customers ?? [];
|
||
const overdue = new Map<string, { name: string; property: string; email: string; phone: string; amount: number; oldest: number }>();
|
||
for (const inv of arOpen as any[]) {
|
||
const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0);
|
||
if (open <= 0) continue;
|
||
const due = new Date(inv.due_date ?? inv.issue_date);
|
||
const days = Math.floor((now.getTime() - due.getTime()) / 86400000);
|
||
if (days <= 0) continue;
|
||
const cust = (customers as any[]).find((c: any) => c.id === inv.customer_id);
|
||
const r = overdue.get(inv.customer_id) ?? { name: cust?.name ?? "—", property: cust?.property_address ?? "—", email: cust?.email ?? "—", phone: cust?.phone ?? "—", amount: 0, oldest: 0 };
|
||
r.amount += open; r.oldest = Math.max(r.oldest, days); overdue.set(inv.customer_id, r);
|
||
}
|
||
const list = [...overdue.values()].sort((a, b) => b.amount - a.amount);
|
||
return {
|
||
title: "Delinquency Report", columns: ["Homeowner", "Property", "Email", "Phone", "Days Overdue", "Amount Overdue"],
|
||
rows: list.map(r => [r.name, r.property, r.email, r.phone, String(r.oldest), m(r.amount)]),
|
||
};
|
||
}
|
||
|
||
return null;
|
||
}, [active, arOpen, data, flat, structured, cur, activeMeta.name]);
|
||
|
||
// Reports whose export is handled internally (own PDF/CSV buttons inside the component)
|
||
const hasOwnExport = active === "trial-balance" || active === "general-ledger" || active === "budget-vs-actuals"
|
||
|| (active === "pnl" && pnlMonthView)
|
||
|| active === "ar-aging-property" || active === "prepaid-homeowners" || active === "cash-disbursement";
|
||
const anyExportable = !!(structured || flat || exportFlat);
|
||
|
||
const doExportPDF = async () => {
|
||
const fileBase = `${activeMeta.name.replace(/\s+/g, "-").toLowerCase()}-${from}-to-${to}`;
|
||
const src = flat ?? exportFlat;
|
||
const logo = brandedLogo ?? (await loadBrandedLogo(logoUrl));
|
||
if (structured) {
|
||
const doc = renderReportPdf(
|
||
structured,
|
||
{ companyName: associationName ?? "Company", appName: APP_NAME, rangeLabel, currency: cur, showCodes, showCompare, showZero, logo },
|
||
);
|
||
doc.save(`${fileBase}.pdf`);
|
||
} else if (src) {
|
||
const doc = new jsPDF({ unit: "pt", format: "letter", orientation: src.columns.length > 6 ? "landscape" : "portrait" });
|
||
const startY = drawBrandedHeader(doc, {
|
||
logo, title: src.title,
|
||
metaLines: [{ label: "Properties:", value: associationName ?? "" }, { label: "Period:", value: rangeLabel }],
|
||
});
|
||
const lastCol = src.columns.length - 1;
|
||
autoTable(doc, {
|
||
head: [src.columns],
|
||
body: src.rows.map(r => r.map(String)),
|
||
startY,
|
||
margin: { left: 40, right: 40 },
|
||
styles: { font: "helvetica", fontSize: 8, textColor: [33, 37, 41], lineColor: [222, 226, 230], lineWidth: 0.1 },
|
||
headStyles: { fillColor: [237, 239, 242], textColor: [33, 37, 41], fontStyle: "bold", lineColor: [196, 200, 205], lineWidth: 0.2 },
|
||
alternateRowStyles: { fillColor: [247, 248, 250] },
|
||
columnStyles: { [lastCol]: { halign: "right" } },
|
||
didParseCell: ({ row, cell }) => { if (src.boldRows?.includes(row.index)) cell.styles.fontStyle = "bold"; },
|
||
});
|
||
drawBrandedFooter(doc);
|
||
doc.save(`${fileBase}.pdf`);
|
||
} else {
|
||
toast.error("No data to export for this report");
|
||
}
|
||
};
|
||
|
||
const doExportCSV = () => {
|
||
const fileBase = `${activeMeta.name.replace(/\s+/g, "-").toLowerCase()}-${from}-to-${to}`;
|
||
let lines: string[] = [];
|
||
if (structured) {
|
||
lines.push(["Account", "Amount", showCompare ? "Previous" : ""].filter(Boolean).join(","));
|
||
for (const r of structured.rows) {
|
||
if (r.kind === "spacer") continue;
|
||
if (r.kind === "sub" && !showZero && (r.amount ?? 0) === 0) continue;
|
||
const label = (r.kind === "sub" ? " " : "") + (showCodes && r.code ? `${r.code} ${r.label}` : r.label);
|
||
const cells = [`"${label}"`, fmtAmount(r.amount)];
|
||
if (showCompare) cells.push(fmtAmount(r.compare));
|
||
lines.push(cells.join(","));
|
||
}
|
||
} else {
|
||
const src = flat ?? exportFlat;
|
||
if (src) {
|
||
lines.push(src.columns.map(c => `"${c}"`).join(","));
|
||
for (const r of src.rows) lines.push(r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(","));
|
||
}
|
||
}
|
||
if (!lines.length) { toast.error("No data to export"); return; }
|
||
const blob = new Blob([lines.join("\n")], { type: "text/csv" });
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob); a.download = `${fileBase}.csv`; a.click();
|
||
};
|
||
|
||
// Keep old names for any remaining references
|
||
const exportPDF = doExportPDF;
|
||
const exportCSV = doExportCSV;
|
||
|
||
return (
|
||
<div className="flex gap-6">
|
||
<aside className="w-60 shrink-0">
|
||
<Card>
|
||
<CardContent className="p-3 space-y-4">
|
||
{GROUPS.map((g) => (
|
||
<div key={g.name}>
|
||
<div className="px-2 pb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{g.name}</div>
|
||
<ul className="space-y-0.5">
|
||
{g.reports.map((r) => (
|
||
<li key={r.id}>
|
||
<button
|
||
onClick={() => setActive(r.id)}
|
||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors ${active === r.id ? "bg-primary text-primary-foreground" : "hover:bg-muted"}`}
|
||
>
|
||
<FileText className="h-3.5 w-3.5 shrink-0" />
|
||
<span className="truncate">{r.name}</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
</aside>
|
||
|
||
<div className="min-w-0 flex-1 space-y-4">
|
||
<div className="space-y-3">
|
||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||
<div>
|
||
<h1 className="text-2xl font-semibold">{activeMeta.name}</h1>
|
||
<p className="text-sm text-muted-foreground">{rangeLabel}</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button variant="outline" onClick={() => setBatchOpen(true)}>
|
||
<Layers className="mr-1 h-4 w-4" /> Report Batches
|
||
</Button>
|
||
<Button variant="outline" onClick={refreshReport} disabled={refreshing}>
|
||
<RefreshCw className={`mr-1 h-4 w-4 ${refreshing ? "animate-spin" : ""}`} /> Refresh
|
||
</Button>
|
||
{hasOwnExport ? (
|
||
<span className="text-xs text-muted-foreground self-center">Export available inside the report ↓</span>
|
||
) : (
|
||
<>
|
||
<Button variant="outline" onClick={exportCSV} disabled={!anyExportable}><Download className="mr-1 h-4 w-4" /> CSV</Button>
|
||
<Button onClick={exportPDF} disabled={!anyExportable}><FileDown className="mr-1 h-4 w-4" /> PDF</Button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Period presets */}
|
||
<Card>
|
||
<CardContent className="py-3 px-4 space-y-3">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="text-xs font-medium text-muted-foreground w-14">Period</span>
|
||
{([
|
||
{ v: "this-month", l: "This Month" },
|
||
{ v: "last-month", l: "Last Month" },
|
||
{ v: "this-quarter", l: "This Quarter" },
|
||
{ v: "ytd", l: "YTD" },
|
||
{ v: "last-year", l: "Last Year" },
|
||
{ v: "custom", l: "Custom" },
|
||
] as { v: Preset; l: string }[]).map(({ v, l }) => (
|
||
<button key={v} onClick={() => applyPreset(v)}
|
||
className={`rounded-md px-3 py-1 text-xs font-medium border transition-colors ${preset === v ? "bg-primary text-primary-foreground border-primary" : "bg-background border-border text-muted-foreground hover:text-foreground hover:border-foreground/30"}`}>
|
||
{l}
|
||
</button>
|
||
))}
|
||
{preset === "custom" && (
|
||
<div className="flex items-center gap-2 ml-2">
|
||
<Input type="date" value={from} onChange={e => setFrom(e.target.value)} className="h-7 w-36 text-xs" />
|
||
<span className="text-muted-foreground text-xs">to</span>
|
||
<Input type="date" value={to} onChange={e => setTo(e.target.value)} className="h-7 w-36 text-xs" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Comparison period — only for financial reports */}
|
||
{isFinancial && (
|
||
<div className="flex flex-wrap items-center gap-2 border-t pt-3">
|
||
<span className="text-xs font-medium text-muted-foreground w-14">Compare</span>
|
||
{([
|
||
{ v: "none", l: "None" },
|
||
{ v: "prior-period", l: "Prior Period" },
|
||
{ v: "prior-year", l: "Prior Year" },
|
||
{ v: "custom", l: "Custom" },
|
||
] as { v: CompareMode; l: string }[]).map(({ v, l }) => (
|
||
<button key={v} onClick={() => setCompareMode(v)}
|
||
className={`rounded-md px-3 py-1 text-xs font-medium border transition-colors ${compareMode === v ? "bg-primary text-primary-foreground border-primary" : "bg-background border-border text-muted-foreground hover:text-foreground hover:border-foreground/30"}`}>
|
||
{l}
|
||
</button>
|
||
))}
|
||
{compareMode === "custom" && (
|
||
<div className="flex items-center gap-2 ml-2">
|
||
<Input type="date" value={compareFrom} onChange={e => setCompareFrom(e.target.value)} className="h-7 w-36 text-xs" />
|
||
<span className="text-muted-foreground text-xs">to</span>
|
||
<Input type="date" value={compareTo} onChange={e => setCompareTo(e.target.value)} className="h-7 w-36 text-xs" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Display toggles */}
|
||
<div className="flex flex-wrap gap-4 border-t pt-3">
|
||
<Toggle id="t-codes" checked={showCodes} onChange={setShowCodes} label="Account codes" />
|
||
<Toggle id="t-zero" checked={showZero} onChange={setShowZero} label="Zero-balance accounts" />
|
||
{active === "pnl" && (
|
||
<Toggle id="t-pnl-month" checked={pnlMonthView} onChange={setPnlMonthView} label="Monthly columns" />
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
{active === "budget-vs-actuals" && (
|
||
<BudgetVsActuals companyId={cid} from={from} to={to} currency={cur} companyName={associationName ?? "Company"} rangeLabel={rangeLabel} logoUrl={logoUrl} />
|
||
)}
|
||
{active === "pnl" && pnlMonthView && (
|
||
<IncomeStatementReport companyId={cid} companyName={associationName ?? "Company"} from={from} to={to} fiscalYearStart={fiscalYearStart} currency={cur} logoUrl={logoUrl} />
|
||
)}
|
||
{active === "trial-balance" && (
|
||
<TrialBalanceReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} to={to} />
|
||
)}
|
||
{active === "general-ledger" && (
|
||
<GeneralLedgerReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} initialAccountId={drillAccountId} from={from} to={to} />
|
||
)}
|
||
{active === "reserve-fund" && (
|
||
<ReserveFundReport companyId={cid} companyName={associationName ?? ""} fiscalYearStart={fiscalYearStart} logoUrl={logoUrl} to={to} />
|
||
)}
|
||
{active === "ar-aging-property" && (
|
||
<ARAgingPropertyReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} to={to} />
|
||
)}
|
||
{active === "prepaid-homeowners" && (
|
||
<PrepaidHomeownersReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} to={to} />
|
||
)}
|
||
{active === "cash-disbursement" && (
|
||
<CashDisbursementReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} from={from} to={to} />
|
||
)}
|
||
{active === "reconciliation" && (
|
||
<ReportSheet title="Reconciliation Checks" companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}>
|
||
<ReconciliationReport d={data} currency={cur} companyName={associationName ?? "Company"} rangeLabel={rangeLabel} />
|
||
</ReportSheet>
|
||
)}
|
||
{isFinancial && !(active === "pnl" && pnlMonthView) && (
|
||
!data ? (
|
||
<Card><CardContent className="p-6"><div className="py-8 text-center text-sm text-muted-foreground">Loading…</div></CardContent></Card>
|
||
) : structured ? (
|
||
<ReportSheet title={activeMeta.name} subtitle="Accrual basis" companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}>
|
||
<StructuredTable report={structured} showCodes={showCodes} showCompare={showCompare} showZero={showZero} currency={cur} onDrill={drillToAccount} />
|
||
</ReportSheet>
|
||
) : (
|
||
<Card><CardContent className="p-6"><div className="py-12 text-center text-sm text-muted-foreground">No data for this report in the selected range.</div></CardContent></Card>
|
||
)
|
||
)}
|
||
{!isFinancial && active !== "budget-vs-actuals" && active !== "income-statement" && active !== "trial-balance" && active !== "general-ledger" && active !== "reserve-fund" && active !== "reconciliation" && active !== "ar-aging-property" && active !== "prepaid-homeowners" && active !== "cash-disbursement" && (
|
||
<ReportSheet title={activeMeta.name} companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}>
|
||
{!data ? (
|
||
<div className="text-sm text-muted-foreground">Loading…</div>
|
||
) : structured ? (
|
||
<StructuredTable report={structured} showCodes={showCodes} showCompare={showCompare} showZero={showZero} currency={cur} />
|
||
) : active === "customer-balances" ? (
|
||
<ARAgingTable rows={arOpen as any[]} currency={cur} />
|
||
) : active === "ar-aging" ? (
|
||
<ARAgingTable rows={arOpen as any[]} currency={cur} detailed />
|
||
) : active === "ap-aging" ? (
|
||
<APAgingTable rows={(data as any)?.allBills ?? []} currency={cur} />
|
||
) : active === "homeowner-summary" ? (
|
||
<HomeownerSummaryTable customers={(data as any)?.customers ?? []} invoices={arOpen as any[]} currency={cur} />
|
||
) : active === "delinquency" ? (
|
||
<DelinquencyTable customers={(data as any)?.customers ?? []} invoices={arOpen as any[]} currency={cur} />
|
||
) : flat && flat.rows.length > 0 ? (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>{flat.columns.map((c, i) => (
|
||
<TableHead key={c} className={i === flat.columns.length - 1 ? "text-right" : ""}>{c}</TableHead>
|
||
))}</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{flat.rows.map((row, i) => (
|
||
<TableRow key={i} className={flat.boldRows?.includes(i) ? "font-semibold bg-muted/30" : ""}>
|
||
{row.map((cell, j) => (
|
||
<TableCell key={j} className={j === row.length - 1 ? "text-right" : ""}>{cell}</TableCell>
|
||
))}
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
) : (
|
||
<div className="py-12 text-center text-sm text-muted-foreground">No data for this report in the selected range.</div>
|
||
)}
|
||
</ReportSheet>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Report Batches dialog ── */}
|
||
<Dialog open={batchOpen} onOpenChange={setBatchOpen}>
|
||
<DialogContent className="max-w-lg">
|
||
<DialogHeader><DialogTitle>Report Batches</DialogTitle></DialogHeader>
|
||
<div className="space-y-4">
|
||
<p className="text-sm text-muted-foreground">
|
||
Pick a set of reports to combine into one PDF for <strong>{associationName ?? "this association"}</strong>.
|
||
The package uses the period selected on this page (<span className="font-medium">{rangeLabel}</span>).
|
||
</p>
|
||
|
||
{savedBatches.length > 0 && (
|
||
<div>
|
||
<Label className="text-xs uppercase text-muted-foreground">Saved batches</Label>
|
||
<div className="mt-1 space-y-1">
|
||
{(savedBatches as any[]).map((b) => (
|
||
<div key={b.id} className={`flex items-center gap-2 rounded border px-2 py-1.5 text-sm ${batchLoadedId === b.id ? "border-primary bg-primary/5" : ""}`}>
|
||
<button className="flex-1 text-left truncate" onClick={() => loadBatch(b)}>
|
||
{b.name} <span className="text-xs text-muted-foreground">· {(b.report_ids ?? []).length} reports</span>
|
||
</button>
|
||
<Button size="icon" variant="ghost" className="h-7 w-7 text-muted-foreground hover:text-destructive" onClick={() => deleteBatch(b.id)}>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<Label className="text-xs">Batch name</Label>
|
||
<Input value={batchName} onChange={(e) => setBatchName(e.target.value)} placeholder="e.g. Monthly Board Package" className="mt-1" />
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="text-xs uppercase text-muted-foreground">Reports (in packet order)</Label>
|
||
<div className="mt-1 space-y-1 max-h-64 overflow-y-auto rounded border p-2">
|
||
{BATCHABLE_REPORTS.map((r) => (
|
||
<label key={r.id} className="flex items-center gap-2 px-1.5 py-1 rounded hover:bg-accent cursor-pointer text-sm">
|
||
<Checkbox checked={batchReportIds.includes(r.id)} onCheckedChange={() => toggleBatchReport(r.id)} />
|
||
{r.name}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<DialogFooter className="flex-wrap gap-2">
|
||
{batchLoadedId && (
|
||
<Button variant="ghost" onClick={() => { setBatchLoadedId(null); setBatchName(""); }}>New batch</Button>
|
||
)}
|
||
<Button variant="outline" onClick={saveBatch}>{batchLoadedId ? "Update" : "Save"} batch</Button>
|
||
<Button onClick={generateBatch} disabled={generatingBatch || batchReportIds.length === 0}>
|
||
{generatingBatch ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <FileDown className="mr-1 h-4 w-4" />}
|
||
Generate PDF
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Toggle({ id, checked, onChange, label, disabled }: { id: string; checked: boolean; onChange: (v: boolean) => void; label: string; disabled?: boolean }) {
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<Switch id={id} checked={checked} onCheckedChange={onChange} disabled={disabled} />
|
||
<Label htmlFor={id} className={disabled ? "text-muted-foreground" : ""}>{label}</Label>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Income Statement (multi-period: by month / quarter / year) ─────────────────
|
||
type ISGran = "month" | "quarter" | "year";
|
||
const IS_TEAL: [number, number, number] = [0, 137, 123];
|
||
|
||
function isPad2(n: number) { return String(n).padStart(2, "0"); }
|
||
|
||
/** Period columns spanning [from, to] at the chosen granularity. */
|
||
function isBuildPeriods(from: string, to: string, gran: ISGran): { key: string; label: string }[] {
|
||
const out: { key: string; label: string }[] = [];
|
||
const fy = Number(from.slice(0, 4)), fm = Number(from.slice(5, 7));
|
||
const ty = Number(to.slice(0, 4)), tm = Number(to.slice(5, 7));
|
||
if (gran === "month") {
|
||
let cy = fy, cm = fm;
|
||
while (cy < ty || (cy === ty && cm <= tm)) {
|
||
out.push({ key: `${cy}-${isPad2(cm)}`, label: `${isPad2(cm)}-${cy}` });
|
||
cm++; if (cm > 12) { cm = 1; cy++; }
|
||
}
|
||
} else if (gran === "quarter") {
|
||
let cy = fy, cq = Math.floor((fm - 1) / 3) + 1;
|
||
const tq = Math.floor((tm - 1) / 3) + 1;
|
||
while (cy < ty || (cy === ty && cq <= tq)) {
|
||
out.push({ key: `${cy}-Q${cq}`, label: `Q${cq} ${cy}` });
|
||
cq++; if (cq > 4) { cq = 1; cy++; }
|
||
}
|
||
} else {
|
||
for (let cy = fy; cy <= ty; cy++) out.push({ key: `${cy}`, label: `${cy}` });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** First day of the fiscal year that contains `to`, given an "MM-DD" FY start. */
|
||
function isFyStartFor(to: string, fyStart: string): string {
|
||
const [mm, dd] = (fyStart || "01-01").split("-").map(Number);
|
||
const ty = Number(to.slice(0, 4));
|
||
const candidate = `${ty}-${isPad2(mm || 1)}-${isPad2(dd || 1)}`;
|
||
return to >= candidate ? candidate : `${ty - 1}-${isPad2(mm || 1)}-${isPad2(dd || 1)}`;
|
||
}
|
||
|
||
/** Period key a given YYYY-MM-DD date falls into, for the chosen granularity. */
|
||
function isPeriodKey(date: string, gran: ISGran): string {
|
||
if (gran === "month") return date.slice(0, 7);
|
||
if (gran === "year") return date.slice(0, 4);
|
||
const q = Math.floor((Number(date.slice(5, 7)) - 1) / 3) + 1;
|
||
return `${date.slice(0, 4)}-Q${q}`;
|
||
}
|
||
|
||
/** Detail-cell number: 2dp, thousands-separated, parens for negatives, blank for zero. */
|
||
function isNum(n: number): string {
|
||
const v = Math.round((n + Number.EPSILON) * 100) / 100 || 0;
|
||
if (v === 0) return "";
|
||
const abs = Math.abs(v).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
return v < 0 ? `(${abs})` : abs;
|
||
}
|
||
|
||
type ISAcct = { id: string; code: string | null; name: string; type: string; category: string | null; by: Map<string, number>; total: number };
|
||
type ISTotal = { by: Map<string, number>; total: number };
|
||
type ISGroup = { name: string | null; accts: ISAcct[] } & ISTotal;
|
||
|
||
const isByCode = (a: ISAcct, b: ISAcct) => String(a.code ?? "").localeCompare(String(b.code ?? "")) || a.name.localeCompare(b.name);
|
||
|
||
function isSumRows(rows: { by: Map<string, number>; total: number }[], periods: { key: string }[]): ISTotal {
|
||
const by = new Map<string, number>(); let total = 0;
|
||
for (const p of periods) by.set(p.key, rows.reduce((s, r) => s + (r.by.get(p.key) ?? 0), 0));
|
||
for (const r of rows) total += r.total;
|
||
return { by, total };
|
||
}
|
||
|
||
/** Group accounts by their `category` into Buildium-style subgroups; ungrouped (null) sorts last. */
|
||
function isGroupAccts(accts: ISAcct[], periods: { key: string }[]): ISGroup[] {
|
||
const map = new Map<string, ISAcct[]>();
|
||
for (const a of accts) {
|
||
const k = (a.category ?? "").trim();
|
||
(map.get(k) ?? map.set(k, []).get(k)!).push(a);
|
||
}
|
||
const groups: ISGroup[] = [];
|
||
for (const [name, list] of map) {
|
||
list.sort(isByCode);
|
||
groups.push({ name: name || null, accts: list, ...isSumRows(list, periods) });
|
||
}
|
||
groups.sort((a, b) => (a.name === null ? 1 : 0) - (b.name === null ? 1 : 0) || String(a.name).localeCompare(String(b.name)));
|
||
return groups;
|
||
}
|
||
|
||
function IncomeStatementReport({ companyId, companyName, from, to, fiscalYearStart, currency, logoUrl }: {
|
||
companyId: string; companyName: string; from: string; to: string; fiscalYearStart?: string; currency: string; logoUrl?: string | null;
|
||
}) {
|
||
const [gran, setGran] = useState<ISGran>("month");
|
||
|
||
// The point of the monthly P&L is a column per period. If the page Period is a
|
||
// single month (the default), there'd be just one column — so widen the start
|
||
// to the fiscal year containing `to`, giving every month of the FY-to-date.
|
||
// A multi-month selected range is respected as-is.
|
||
const effFrom = useMemo(
|
||
() => (from.slice(0, 7) === to.slice(0, 7) ? isFyStartFor(to, fiscalYearStart || "01-01") : from),
|
||
[from, to, fiscalYearStart],
|
||
);
|
||
|
||
const { data: glLines = [], isLoading } = useQuery({
|
||
queryKey: ["income-statement-gl", companyId, effFrom, to],
|
||
enabled: !!companyId,
|
||
queryFn: () => fetchAllGLLines(
|
||
companyId, to,
|
||
"id,debit,credit,accounts!inner(id,name,code,type,category),journal_entries!inner(company_id,date)",
|
||
effFrom,
|
||
),
|
||
});
|
||
|
||
const periods = useMemo(() => isBuildPeriods(effFrom, to, gran), [effFrom, to, gran]);
|
||
|
||
const model = useMemo(() => {
|
||
const accts = new Map<string, ISAcct>();
|
||
for (const l of glLines as any[]) {
|
||
const a = l.accounts; if (!a) continue;
|
||
const type = a.type as string; if (type !== "income" && type !== "expense") continue;
|
||
const date: string = l.journal_entries?.date ?? ""; if (!date) continue;
|
||
const debit = Number(l.debit || 0), credit = Number(l.credit || 0);
|
||
const amt = type === "income" ? credit - debit : debit - credit; // both shown positive
|
||
let rec = accts.get(a.id);
|
||
if (!rec) { rec = { id: a.id, code: a.code, name: a.name, type, category: a.category ?? null, by: new Map(), total: 0 }; accts.set(a.id, rec); }
|
||
const key = isPeriodKey(date, gran);
|
||
rec.by.set(key, (rec.by.get(key) ?? 0) + amt);
|
||
rec.total += amt;
|
||
}
|
||
const live = [...accts.values()].filter((a) => Math.abs(a.total) > 0.005);
|
||
const income = live.filter((a) => a.type === "income");
|
||
const expense = live.filter((a) => a.type === "expense");
|
||
const incomeGroups = isGroupAccts(income, periods);
|
||
const expenseGroups = isGroupAccts(expense, periods);
|
||
const incTot = isSumRows(income, periods), expTot = isSumRows(expense, periods);
|
||
const net: ISTotal = {
|
||
by: new Map(periods.map((p) => [p.key, (incTot.by.get(p.key) ?? 0) - (expTot.by.get(p.key) ?? 0)])),
|
||
total: incTot.total - expTot.total,
|
||
};
|
||
return { incomeGroups, expenseGroups, incTot, expTot, net, hasRows: income.length > 0 || expense.length > 0 };
|
||
}, [glLines, periods, gran]);
|
||
|
||
const subtitle = `${fmtDate(effFrom)} – ${fmtDate(to)}, By ${gran[0].toUpperCase()}${gran.slice(1)}, Accrual basis`;
|
||
const { hasRows } = model;
|
||
|
||
const exportPdf = async () => {
|
||
const doc = new jsPDF({ unit: "pt", format: "letter", orientation: "landscape" });
|
||
const logo = await loadBrandedLogo(logoUrl);
|
||
const startY = drawBrandedHeader(doc, {
|
||
logo, title: "Profit & Loss", subtitle,
|
||
metaLines: [{ label: "Properties:", value: companyName }],
|
||
});
|
||
const head = [["Account", ...periods.map((p) => p.label), "Total"]];
|
||
const body: any[] = [];
|
||
const fillRows = new Set<number>(); // section headers (Income / Expense)
|
||
const boldRows = new Set<number>(); // group headers, subtotals, totals, net
|
||
const pushRow = (label: string, t: ISTotal | null, kind: "section" | "group" | "account" | "subtotal" | "total") => {
|
||
const asMoney = kind === "subtotal" || kind === "total";
|
||
const cells = [
|
||
label,
|
||
...periods.map((p) => (t ? (asMoney ? money(t.by.get(p.key) ?? 0, currency) : isNum(t.by.get(p.key) ?? 0)) : "")),
|
||
t == null ? "" : (asMoney ? money(t.total, currency) : isNum(t.total)),
|
||
];
|
||
if (kind === "section") fillRows.add(body.length);
|
||
if (kind !== "account") boldRows.add(body.length);
|
||
body.push(cells);
|
||
};
|
||
const emitSection = (title: string, groups: ISGroup[], totalLabel: string, total: ISTotal) => {
|
||
pushRow(title, null, "section");
|
||
for (const g of groups) {
|
||
if (g.name) pushRow(` ${g.name}`, null, "group");
|
||
for (const a of g.accts) pushRow(`${g.name ? " " : " "}${a.code ? a.code + " " : ""}${a.name}`, a, "account");
|
||
if (g.name) pushRow(` Total for ${g.name}`, g, "subtotal");
|
||
}
|
||
pushRow(totalLabel, total, "total");
|
||
};
|
||
emitSection("Income", model.incomeGroups, "Total Income", model.incTot);
|
||
emitSection("Expense", model.expenseGroups, "Total Expense", model.expTot);
|
||
pushRow("Net Income", model.net, "total");
|
||
|
||
const colStyles: Record<number, any> = { 0: { halign: "left", cellWidth: 160 } };
|
||
for (let i = 1; i <= periods.length + 1; i++) colStyles[i] = { halign: "right" };
|
||
autoTable(doc, {
|
||
startY, head, body,
|
||
styles: { fontSize: 7, cellPadding: 3, overflow: "linebreak" },
|
||
headStyles: { fillColor: IS_TEAL, textColor: 255, halign: "right", fontSize: 7 },
|
||
columnStyles: colStyles,
|
||
margin: { left: 40, right: 40 },
|
||
didParseCell: (data: any) => {
|
||
if (data.section !== "body") return;
|
||
if (boldRows.has(data.row.index)) data.cell.styles.fontStyle = "bold";
|
||
if (fillRows.has(data.row.index)) data.cell.styles.fillColor = [241, 245, 249];
|
||
},
|
||
});
|
||
drawBrandedFooter(doc);
|
||
doc.save(`profit-loss-${gran}-${effFrom}-to-${to}.pdf`);
|
||
};
|
||
|
||
const exportCsv = () => {
|
||
const esc = (s: string) => `"${String(s).replace(/"/g, '""')}"`;
|
||
const lines = [["Account", ...periods.map((p) => p.label), "Total"].map(esc).join(",")];
|
||
const f = (n: number) => (Math.round((n + Number.EPSILON) * 100) / 100 || 0).toFixed(2);
|
||
const row = (label: string, t: ISTotal | null) =>
|
||
lines.push([esc(label), ...periods.map((p) => (t ? f(t.by.get(p.key) ?? 0) : "")), t == null ? "" : f(t.total)].join(","));
|
||
const section = (title: string, groups: ISGroup[], totalLabel: string, total: ISTotal) => {
|
||
row(title, null);
|
||
for (const g of groups) {
|
||
if (g.name) row(g.name, null);
|
||
for (const a of g.accts) row(`${a.code ? a.code + " " : ""}${a.name}`, a);
|
||
if (g.name) row(`Total for ${g.name}`, g);
|
||
}
|
||
row(totalLabel, total);
|
||
};
|
||
section("Income", model.incomeGroups, "Total Income", model.incTot);
|
||
section("Expense", model.expenseGroups, "Total Expense", model.expTot);
|
||
row("Net Income", model.net);
|
||
const blob = new Blob([lines.join("\n")], { type: "text/csv" });
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `profit-loss-${gran}-${effFrom}-to-${to}.csv`;
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
};
|
||
|
||
const numCell = "px-3 py-1.5 text-right tabular-nums whitespace-nowrap";
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<Card>
|
||
<CardContent className="flex flex-wrap items-end gap-4 py-4">
|
||
<div>
|
||
<Label className="text-xs text-muted-foreground">By</Label>
|
||
<Select value={gran} onValueChange={(v) => setGran(v as ISGran)}>
|
||
<SelectTrigger className="w-36 mt-1 h-9"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="month">Month</SelectItem>
|
||
<SelectItem value="quarter">Quarter</SelectItem>
|
||
<SelectItem value="year">Year</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="text-xs text-muted-foreground pb-1">{periods.length} period{periods.length !== 1 ? "s" : ""} · Accrual basis</div>
|
||
{hasRows && (
|
||
<div className="ml-auto flex gap-2">
|
||
<Button variant="outline" onClick={exportCsv}><Download className="mr-1 h-4 w-4" /> CSV</Button>
|
||
<Button onClick={exportPdf}><FileDown className="mr-1 h-4 w-4" /> PDF</Button>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{isLoading ? (
|
||
<Card><CardContent className="p-8 text-center text-sm text-muted-foreground">Loading…</CardContent></Card>
|
||
) : !hasRows ? (
|
||
<Card><CardContent className="p-8 text-center text-sm text-muted-foreground">No income or expense activity in this range.</CardContent></Card>
|
||
) : (
|
||
<ReportSheet title="Profit & Loss" subtitle={subtitle} companyName={companyName} logoUrl={logoUrl}>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-y text-[11px] uppercase tracking-wide text-muted-foreground">
|
||
<th className="px-3 py-2 text-left font-semibold">Account</th>
|
||
{periods.map((p) => <th key={p.key} className="px-3 py-2 text-right font-semibold whitespace-nowrap">{p.label}</th>)}
|
||
<th className="px-3 py-2 text-right font-semibold">Total</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<ISSection title="Income" groups={model.incomeGroups} totalLabel="Total Income" total={model.incTot} periods={periods} currency={currency} numCell={numCell} />
|
||
<ISSection title="Expense" groups={model.expenseGroups} totalLabel="Total Expense" total={model.expTot} periods={periods} currency={currency} numCell={numCell} />
|
||
<tr className="border-t-2 border-primary font-bold">
|
||
<td className="px-3 py-2.5">Net Income</td>
|
||
{periods.map((p) => <td key={p.key} className={numCell}>{money(model.net.by.get(p.key) ?? 0, currency)}</td>)}
|
||
<td className={numCell}>{money(model.net.total, currency)}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</ReportSheet>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ISSection({ title, groups, periods, totalLabel, total, currency, numCell }: {
|
||
title: string; groups: ISGroup[]; periods: { key: string; label: string }[];
|
||
totalLabel: string; total: ISTotal; currency: string; numCell: string;
|
||
}) {
|
||
return (
|
||
<>
|
||
<tr className="bg-muted/40 font-semibold">
|
||
<td className="px-3 py-1.5" colSpan={periods.length + 2}>{title}</td>
|
||
</tr>
|
||
{groups.map((g, gi) => (
|
||
<Fragment key={g.name ?? `__none-${gi}`}>
|
||
{g.name && (
|
||
<tr className="font-semibold text-[13px]">
|
||
<td className="px-3 pl-6 py-1.5" colSpan={periods.length + 2}>{g.name}</td>
|
||
</tr>
|
||
)}
|
||
{g.accts.map((a) => (
|
||
<tr key={a.id} className="border-b hover:bg-muted/20">
|
||
<td className={`px-3 py-1.5 ${g.name ? "pl-10" : "pl-6"}`}>
|
||
{a.code && <span className="font-mono text-xs text-muted-foreground mr-2">{a.code}</span>}{a.name}
|
||
</td>
|
||
{periods.map((p) => <td key={p.key} className={numCell}>{isNum(a.by.get(p.key) ?? 0)}</td>)}
|
||
<td className={numCell + " font-medium"}>{isNum(a.total)}</td>
|
||
</tr>
|
||
))}
|
||
{g.name && (
|
||
<tr className="border-b font-medium text-muted-foreground">
|
||
<td className="px-3 pl-6 py-1.5">Total for {g.name}</td>
|
||
{periods.map((p) => <td key={p.key} className={numCell}>{money(g.by.get(p.key) ?? 0, currency)}</td>)}
|
||
<td className={numCell}>{money(g.total, currency)}</td>
|
||
</tr>
|
||
)}
|
||
</Fragment>
|
||
))}
|
||
<tr className="border-y-2 border-primary/40 font-semibold">
|
||
<td className="px-3 py-1.5">{totalLabel}</td>
|
||
{periods.map((p) => <td key={p.key} className={numCell}>{money(total.by.get(p.key) ?? 0, currency)}</td>)}
|
||
<td className={numCell}>{money(total.total, currency)}</td>
|
||
</tr>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function StructuredTable({ report, showCodes, showCompare, showZero, currency, onDrill }: {
|
||
report: StructuredReport; showCodes: boolean; showCompare: boolean; showZero: boolean; currency: string;
|
||
onDrill?: (accountId: string, label: string) => void;
|
||
}) {
|
||
let alt = false;
|
||
const span = showCompare ? 5 : 2;
|
||
const pctStr = (amount?: number, compare?: number) => {
|
||
if (amount === undefined || compare === undefined || Math.abs(compare) < 0.005) return "—";
|
||
return `${(((amount - compare) / Math.abs(compare)) * 100).toFixed(1)}%`;
|
||
};
|
||
return (
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b text-xs uppercase tracking-wide text-muted-foreground">
|
||
<th className="py-2 text-left font-semibold">Account</th>
|
||
<th className="py-2 text-right font-semibold">Amount</th>
|
||
{showCompare && <th className="py-2 text-right font-semibold">Comparative</th>}
|
||
{showCompare && <th className="py-2 text-right font-semibold">Change</th>}
|
||
{showCompare && <th className="py-2 text-right font-semibold">Change %</th>}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{report.rows.map((r, i) => {
|
||
if (r.kind === "spacer") { alt = false; return <tr key={i}><td colSpan={span} className="h-3" /></tr>; }
|
||
if (r.kind === "sub" && !showZero && (r.amount ?? 0) === 0) return null;
|
||
if (r.kind === "section") {
|
||
alt = false;
|
||
return (
|
||
<tr key={i} className="bg-[hsl(174_47%_94%)]">
|
||
<td colSpan={span} className="py-2 px-2 font-semibold text-[hsl(174_70%_25%)] text-sm">{r.label}</td>
|
||
</tr>
|
||
);
|
||
}
|
||
if (r.kind === "group") {
|
||
alt = false;
|
||
return (
|
||
<tr key={i}>
|
||
<td colSpan={span} className="pt-2.5 pb-1 pl-4 font-semibold text-sm">{r.label}</td>
|
||
</tr>
|
||
);
|
||
}
|
||
const bold = r.kind === "total" || r.kind === "grand";
|
||
const shaded = r.kind === "sub" && alt;
|
||
if (r.kind === "sub") alt = !alt;
|
||
const delta = (r.amount !== undefined && r.compare !== undefined) ? r.amount - r.compare : undefined;
|
||
const drillable = r.kind === "sub" && !!r.accountId && !!onDrill;
|
||
return (
|
||
<tr key={i}
|
||
onClick={drillable ? () => onDrill!(r.accountId!, r.label) : undefined}
|
||
title={drillable ? "View transactions for this account" : undefined}
|
||
className={[
|
||
shaded ? "bg-muted/40" : "",
|
||
bold ? "border-t font-semibold" : "",
|
||
drillable ? "cursor-pointer hover:bg-primary/5" : "",
|
||
].join(" ")}>
|
||
<td className={r.kind === "sub" ? "pl-6 py-1.5 border-l-2 border-muted ml-2" : "py-1.5 px-2"}>
|
||
{showCodes && r.code && <span className="text-xs text-muted-foreground mr-2 font-mono">{r.code}</span>}
|
||
<span className={drillable ? "text-primary underline-offset-2 hover:underline" : ""}>{r.label}</span>
|
||
</td>
|
||
<AmountCell n={r.amount} bold={bold} doubleUnderline={r.kind === "grand"} />
|
||
{showCompare && <AmountCell n={r.compare} />}
|
||
{showCompare && <AmountCell n={delta} bold={bold} />}
|
||
{showCompare && <td className={`py-1.5 px-2 text-right tabular-nums text-muted-foreground ${bold ? "font-semibold" : ""}`}>{r.amount !== undefined ? pctStr(r.amount, r.compare) : ""}</td>}
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
{report.balanced !== undefined && (
|
||
<tfoot>
|
||
<tr>
|
||
<td colSpan={span} className="pt-3">
|
||
<div className={`rounded-md px-4 py-2 text-sm font-semibold text-white ${report.balanced ? "bg-primary" : "bg-red-600"}`}>
|
||
{report.balanced ? "Balance Sheet is balanced ✓" : `Balance Sheet is OUT OF BALANCE by ${money(report.outOfBalanceAmount ?? 0, currency)} (Assets − Liabilities − Equity)`}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tfoot>
|
||
)}
|
||
{report.cashHighlight && (
|
||
<tfoot>
|
||
<tr><td colSpan={span} className="pt-3">
|
||
<div className="flex items-center justify-between rounded-md border border-primary bg-[hsl(174_47%_94%)] px-4 py-2.5 text-sm font-semibold text-[hsl(174_70%_25%)]">
|
||
<span>{report.cashHighlight.label}</span>
|
||
<span className={report.cashHighlight.amount < 0 ? "text-red-600" : ""}>{fmtAmount(report.cashHighlight.amount)}</span>
|
||
</div>
|
||
</td></tr>
|
||
</tfoot>
|
||
)}
|
||
</table>
|
||
);
|
||
}
|
||
|
||
function AmountCell({ n, bold, doubleUnderline }: { n?: number; bold?: boolean; doubleUnderline?: boolean }) {
|
||
if (n === undefined) return <td className="py-1.5 px-2" />;
|
||
const cls = [
|
||
"py-1.5 px-2 text-right tabular-nums",
|
||
n < 0 ? "text-red-600" : "",
|
||
bold ? "font-semibold" : "",
|
||
doubleUnderline ? "border-b-4 border-double border-primary" : "",
|
||
].join(" ");
|
||
return <td className={cls}>{fmtAmount(n)}</td>;
|
||
}
|
||
|
||
function PreviewSheet({ report, companyName, rangeLabel, showCodes, showCompare, showZero }: {
|
||
report: StructuredReport | null;
|
||
companyName: string; rangeLabel: string; showCodes: boolean; showCompare: boolean; showZero: boolean;
|
||
}) {
|
||
if (!report) return <div className="text-center text-muted-foreground py-12">No data.</div>;
|
||
return (
|
||
<div className="mx-auto bg-white shadow-md" style={{ width: 816, minHeight: 1056, fontFamily: "Georgia, 'Times New Roman', serif" }}>
|
||
<div className="px-12 pt-10 pb-6">
|
||
<div className="flex items-start gap-4">
|
||
<div className="h-11 w-11 rounded bg-primary flex items-center justify-center text-white font-bold text-lg">
|
||
{(companyName[0] ?? "?").toUpperCase()}
|
||
</div>
|
||
<div className="text-xl font-bold text-gray-900 mt-1">{companyName}</div>
|
||
</div>
|
||
<h1 className="text-2xl font-bold text-center mt-4">{report.title}</h1>
|
||
<div className="text-center text-sm text-gray-500 mt-1">{rangeLabel}</div>
|
||
<div className="mt-3 h-[1.5px] bg-primary" />
|
||
</div>
|
||
<div className="px-12 pb-12">
|
||
<StructuredTable report={report} showCodes={showCodes} showCompare={showCompare} showZero={showZero} currency="" />
|
||
</div>
|
||
<div className="px-12 pb-6">
|
||
<div className="h-[1px] bg-primary mb-3" />
|
||
<div className="text-center text-xs text-gray-500">Page 1 of 1</div>
|
||
<div className="text-center text-xs text-gray-500">Generated by {APP_NAME} on {new Date().toLocaleDateString()}</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------- Financial report builders (structured) ----------
|
||
|
||
// Reconciliation matrix (§9) surfaced as visible residuals — never plug a residual.
|
||
// Shared so both the on-screen report and the PDF/CSV export run identical checks.
|
||
function buildReconChecks(d: any): RecCheck[] {
|
||
const accounts: RecAccount[] = ((d.accounts ?? []) as any[]).map((a) => ({
|
||
id: a.id, type: a.type, name: a.name,
|
||
is_cash: !!a.is_bank || /cash|undeposited/i.test(String(a.name || "")),
|
||
}));
|
||
const knownAcctIds = new Set(accounts.map((a) => a.id));
|
||
for (const l of (d.glCumulative ?? []) as any[]) {
|
||
const meta = l.accounts; const id = l.account_id;
|
||
if (!meta || !id || knownAcctIds.has(id)) continue;
|
||
knownAcctIds.add(id);
|
||
accounts.push({ id, type: meta.type, name: meta.name, is_cash: /cash|undeposited/i.test(String(meta.name || "")) });
|
||
}
|
||
const lines: RecLine[] = ((d.glCumulative ?? []) as any[]).map((l) => ({
|
||
account_id: l.account_id, date: String(l.journal_entries?.date ?? ""),
|
||
debit: Number(l.debit || 0), credit: Number(l.credit || 0),
|
||
}));
|
||
const openInv = ((d.allInvoices ?? []) as any[]).filter((i) => i.status !== "void").reduce((s, i) => s + (Number(i.total || 0) - Number(i.paid_amount || 0)), 0);
|
||
const openBill = ((d.allBills ?? []) as any[]).filter((b) => b.status !== "void").reduce((s, b) => s + (Number(b.total || 0) - Number(b.paid_amount || 0)), 0);
|
||
const pl = buildPnL(d, undefined, false);
|
||
const plNI = pl.rows.find((r) => r.kind === "grand" && /net income/i.test(r.label))?.amount;
|
||
const bs = buildBalanceSheet(d);
|
||
const bsEquity = bs.rows.find((r) => r.kind === "total" && /total equity/i.test(r.label))?.amount;
|
||
const sce = buildMovementOfEquity(d, undefined, false);
|
||
const sceEnding = sce.rows.find((r) => r.kind === "grand" && /closing equity/i.test(r.label))?.amount;
|
||
return reconcile({
|
||
accounts, lines, periodStart: d.from, openInvoices: openInv, openBills: openBill,
|
||
arApApplicable: d.glManaged,
|
||
reportPLNetIncome: plNI, sceEndingEquity: sceEnding, bsTotalEquity: bsEquity,
|
||
});
|
||
}
|
||
|
||
function ReconciliationReport({ d, currency, companyName, rangeLabel }: { d: any; currency: string; companyName?: string; rangeLabel?: string }) {
|
||
if (!d) return <div className="text-sm text-muted-foreground">Loading…</div>;
|
||
const checks = buildReconChecks(d);
|
||
const ok = (r: number) => Math.abs(r) < 0.005;
|
||
const allPass = checks.every((c) => c.pass);
|
||
|
||
// Self-contained PDF (doesn't depend on the page-level exporter).
|
||
const downloadPdf = () => {
|
||
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
||
doc.setFontSize(16); doc.setFont("helvetica", "bold");
|
||
doc.text("Reconciliation Checks", 40, 50);
|
||
doc.setFontSize(10); doc.setFont("helvetica", "normal"); doc.setTextColor(90);
|
||
if (companyName) doc.text(companyName, 40, 68);
|
||
if (rangeLabel) doc.text(rangeLabel, 40, 82);
|
||
doc.setTextColor(0);
|
||
autoTable(doc, {
|
||
startY: 96,
|
||
head: [["Check", "Assertion", "Residual", "Status"]],
|
||
body: checks.map((c) => [c.id, c.label, money(c.residual, currency), c.pass ? "Pass" : "FAIL"]),
|
||
styles: { font: "helvetica", fontSize: 9, cellPadding: 5 },
|
||
headStyles: { fillColor: [30, 41, 59], textColor: 255, fontStyle: "bold" },
|
||
columnStyles: { 0: { cellWidth: 50 }, 2: { halign: "right", cellWidth: 90 }, 3: { halign: "right", cellWidth: 60 } },
|
||
didParseCell: ({ section, row, column, cell }) => {
|
||
if (section === "body" && !checks[row.index]?.pass && (column.index === 2 || column.index === 3)) {
|
||
cell.styles.textColor = [220, 38, 38]; cell.styles.fontStyle = "bold";
|
||
}
|
||
},
|
||
margin: { left: 40, right: 40 },
|
||
});
|
||
doc.save(`reconciliation-checks-${(companyName || "company").replace(/[^a-z0-9]+/gi, "_")}.pdf`);
|
||
};
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||
<CardTitle className="text-base flex items-center gap-2">
|
||
Reconciliation Checks
|
||
<span className={`text-xs rounded px-2 py-0.5 ${allPass ? "bg-emerald-100 text-emerald-700" : "bg-red-100 text-red-700"}`}>
|
||
{allPass ? "All passing" : "Residuals present"}
|
||
</span>
|
||
</CardTitle>
|
||
<Button variant="outline" size="sm" onClick={downloadPdf}><FileDown className="mr-1 h-4 w-4" /> PDF</Button>
|
||
</CardHeader>
|
||
<CardContent className="p-0">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="w-16">Check</TableHead>
|
||
<TableHead>Assertion</TableHead>
|
||
<TableHead className="text-right">Residual</TableHead>
|
||
<TableHead className="text-right">Status</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{checks.map((c) => (
|
||
<TableRow key={c.id}>
|
||
<TableCell className="font-mono">{c.id}</TableCell>
|
||
<TableCell>{c.label}</TableCell>
|
||
<TableCell className={`text-right tabular-nums ${ok(c.residual) ? "" : "text-red-600 font-semibold"}`}>{money(c.residual, currency)}</TableCell>
|
||
<TableCell className="text-right">{ok(c.residual) ? "✓" : "✗"}</TableCell>
|
||
</TableRow>
|
||
))}
|
||
{/* R6 / R9 are not numeric residuals in this model — shown for matrix completeness. */}
|
||
<TableRow className="text-muted-foreground">
|
||
<TableCell className="font-mono">R6</TableCell>
|
||
<TableCell>GL closing balance = Trial Balance / Balance Sheet balance</TableCell>
|
||
<TableCell className="text-right">—</TableCell>
|
||
<TableCell className="text-right" title="Trial Balance and Balance Sheet are both derived directly from the GL, so they agree by construction.">n/a</TableCell>
|
||
</TableRow>
|
||
<TableRow className="text-muted-foreground">
|
||
<TableCell className="font-mono">R9</TableCell>
|
||
<TableCell>Direct-method CFO = Indirect-method CFO</TableCell>
|
||
<TableCell className="text-right">—</TableCell>
|
||
<TableCell className="text-right" title="Only the indirect-method cash flow is produced, so there is no second method to reconcile against.">n/a</TableCell>
|
||
</TableRow>
|
||
</TableBody>
|
||
</Table>
|
||
<p className="text-xs text-muted-foreground p-4">
|
||
A non-zero residual is a bug signal (§9), not to be plugged. R1/R2 failing means the ledger is
|
||
unbalanced (often an imported single-sided entry). R3 checks the P&L's net income against the raw GL;
|
||
R5 checks the Movement of Equity against the Balance Sheet. R7/R8 failing means A/R or A/P is summing
|
||
gross billings instead of open balances, or a sub-ledger doesn't tie to the GL control account.
|
||
R6 is satisfied by construction (Trial Balance and Balance Sheet both derive from the GL); R9 is N/A
|
||
because only the indirect-method cash flow is produced.
|
||
{!d.glManaged && " R7/R8 are omitted for this company: its GL is imported, so its A/R/A/P are maintained in the GL rather than from the platform's invoice/bill sub-ledgers."}
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
export function buildFinancial(id: ReportId, d: any, p: any | undefined, useCompare: boolean): StructuredReport {
|
||
if (id === "pnl") return buildPnL(d, p, useCompare);
|
||
if (id === "balance-sheet") return buildBalanceSheet(d, p, useCompare);
|
||
if (id === "movement-of-equity") return buildMovementOfEquity(d, p, useCompare);
|
||
return buildCashFlow(d, p, useCompare);
|
||
}
|
||
|
||
function buildMovementOfEquity(d: any, p: any | undefined, useCompare: boolean): StructuredReport {
|
||
// GL-consistent with the Balance Sheet and P&L: net income is the GL's
|
||
// current-year earnings (not a separate sub-ledger figure), and the equity
|
||
// rolls forward to exactly the Balance Sheet's total equity (ties R5).
|
||
const equityAccs = (d.accounts ?? []).filter((a: any) => a.type === "equity");
|
||
const draws = equityAccs.filter((a: any) => /draw|dividend/i.test(a.name));
|
||
const nonDraw = equityAccs.filter((a: any) => !/draw|dividend/i.test(a.name));
|
||
|
||
// Per-dataset equity figures derived entirely from the GL (via bsBalances).
|
||
const eq = (ds: any) => {
|
||
const bs = bsBalances(ds);
|
||
const bal = (a: any) => bs.glByAcct.get(a.id) ?? 0;
|
||
const nonDrawGL = nonDraw.reduce((s: number, a: any) => s + bal(a), 0);
|
||
const drawsGL = draws.reduce((s: number, a: any) => s + bal(a), 0);
|
||
const opening = nonDrawGL + bs.rePrior; // capital + opening RE + prior-year earnings
|
||
const closing = opening + bs.cye + drawsGL; // = Balance Sheet total equity (by construction)
|
||
return { bal, drawsGL, opening, closing, netIncome: bs.cye, rePrior: bs.rePrior };
|
||
};
|
||
|
||
const cur = eq(d);
|
||
const prev = useCompare && p ? eq(p) : undefined;
|
||
const cmp = (v: number | undefined) => (prev ? v : undefined);
|
||
|
||
const rows: StructuredRow[] = [
|
||
{ kind: "section", label: "Opening Equity" },
|
||
...nonDraw.map((a: any) => ({ kind: "sub" as const, label: a.name, code: a.code ?? undefined, amount: cur.bal(a), compare: cmp(prev ? prev.bal(a) : undefined) })),
|
||
{ kind: "sub", label: "Retained Earnings (prior years)", amount: cur.rePrior, compare: cmp(prev?.rePrior) },
|
||
{ kind: "total", label: "Total Opening Equity", amount: cur.opening, compare: cmp(prev?.opening) },
|
||
{ kind: "spacer", label: "" },
|
||
|
||
{ kind: "section", label: "Period Activity" },
|
||
{ kind: "sub", label: "Net Income", amount: cur.netIncome, compare: cmp(prev?.netIncome) },
|
||
...draws.map((a: any) => ({ kind: "sub" as const, label: `Less: ${a.name}`, amount: cur.bal(a), compare: cmp(prev ? prev.bal(a) : undefined) })),
|
||
{ kind: "total", label: "Net Change in Equity", amount: cur.netIncome + cur.drawsGL, compare: cmp(prev ? prev.netIncome + prev.drawsGL : undefined) },
|
||
{ kind: "spacer", label: "" },
|
||
|
||
{ kind: "grand", label: "Closing Equity", amount: cur.closing, compare: cmp(prev?.closing) },
|
||
];
|
||
|
||
return { title: "Movement of Equity", rows };
|
||
}
|
||
|
||
// ── P&L from the general ledger (grouped by parent account) ───────────────────
|
||
|
||
type GLAccountTotal = { name: string; code: string | null; amount: number; parentId: string | null };
|
||
|
||
/**
|
||
* Group journal-entry lines by GL account, split into income/expense.
|
||
* Income accounts net to credit−debit; expense accounts to debit−credit.
|
||
*/
|
||
function groupGLByAccount(lines: any[]) {
|
||
const income = new Map<string, GLAccountTotal>();
|
||
const expense = new Map<string, GLAccountTotal>();
|
||
for (const l of lines ?? []) {
|
||
const acc = l.accounts;
|
||
if (!acc) continue;
|
||
const debit = Number(l.debit ?? 0);
|
||
const credit = Number(l.credit ?? 0);
|
||
const parentId = acc.parent_account_id ?? null;
|
||
if (acc.type === "income") {
|
||
const e = income.get(acc.id) ?? { name: acc.name, code: acc.code ?? null, amount: 0, parentId };
|
||
e.amount += credit - debit;
|
||
income.set(acc.id, e);
|
||
} else if (acc.type === "expense") {
|
||
const e = expense.get(acc.id) ?? { name: acc.name, code: acc.code ?? null, amount: 0, parentId };
|
||
e.amount += debit - credit;
|
||
expense.set(acc.id, e);
|
||
}
|
||
}
|
||
return { income, expense };
|
||
}
|
||
|
||
/**
|
||
* Emit a P&L section, grouping accounts under their parent account. Top-level
|
||
* accounts (no parent) are listed directly; child accounts are nested under a
|
||
* parent group header with a "Total for <parent>" subtotal. Returns section totals.
|
||
*/
|
||
function pushPnLSection(
|
||
rows: StructuredRow[], label: string,
|
||
accMap: Map<string, GLAccountTotal>, prevMap: Map<string, GLAccountTotal>,
|
||
parentName: Map<string, string>, useCompare: boolean
|
||
): { total: number; prevTotal: number } {
|
||
rows.push({ kind: "section", label });
|
||
|
||
const ungrouped: [string, GLAccountTotal][] = [];
|
||
const groups = new Map<string, [string, GLAccountTotal][]>();
|
||
for (const entry of accMap.entries()) {
|
||
const pid = entry[1].parentId;
|
||
if (pid && parentName.has(pid)) {
|
||
const g = groups.get(pid) ?? [];
|
||
g.push(entry); groups.set(pid, g);
|
||
} else ungrouped.push(entry);
|
||
}
|
||
|
||
let total = 0, prevTotal = 0;
|
||
const byName = (a: [string, GLAccountTotal], b: [string, GLAccountTotal]) => a[1].name.localeCompare(b[1].name);
|
||
const emit = (id: string, v: GLAccountTotal) => {
|
||
total += v.amount; prevTotal += prevMap.get(id)?.amount ?? 0;
|
||
rows.push({ kind: "sub", label: v.name, code: v.code ?? undefined, amount: v.amount, compare: useCompare ? prevMap.get(id)?.amount : undefined });
|
||
};
|
||
|
||
for (const [id, v] of ungrouped.sort(byName)) emit(id, v);
|
||
|
||
const sortedGroups = [...groups.entries()].sort((a, b) => (parentName.get(a[0]) ?? "").localeCompare(parentName.get(b[0]) ?? ""));
|
||
for (const [pid, entries] of sortedGroups) {
|
||
const gName = parentName.get(pid) ?? "Group";
|
||
rows.push({ kind: "group", label: gName });
|
||
let gTotal = 0, gPrev = 0;
|
||
for (const [id, v] of entries.sort(byName)) {
|
||
gTotal += v.amount; gPrev += prevMap.get(id)?.amount ?? 0;
|
||
emit(id, v);
|
||
}
|
||
rows.push({ kind: "total", label: `Total for ${gName}`, amount: gTotal, compare: useCompare ? gPrev : undefined });
|
||
}
|
||
|
||
return { total, prevTotal };
|
||
}
|
||
|
||
// P&L sub-classification is read from the account's explicit `subtype` field
|
||
// (never inferred from names — see the P&L spec §4). Unclassified nominal
|
||
// accounts default to plain revenue / operating expense, which is correct for
|
||
// HOA charts that have no COGS, tax, or contra accounts.
|
||
const PNL_SUBTYPE_MAP: Record<string, PnlClassification> = {
|
||
revenue: "REVENUE", sales: "REVENUE", income: "REVENUE", operating_income: "REVENUE",
|
||
contra_revenue: "CONTRA_REVENUE", sales_returns: "CONTRA_REVENUE",
|
||
sales_allowances: "CONTRA_REVENUE", sales_discounts: "CONTRA_REVENUE",
|
||
cogs: "COGS", cost_of_goods_sold: "COGS", cost_of_sales: "COGS",
|
||
contra_cogs: "CONTRA_COGS", purchase_discounts: "CONTRA_COGS", purchase_returns: "CONTRA_COGS",
|
||
operating_expense: "OPERATING_EXPENSE", opex: "OPERATING_EXPENSE", sga: "OPERATING_EXPENSE",
|
||
selling_general_admin: "OPERATING_EXPENSE", depreciation: "OPERATING_EXPENSE",
|
||
other_income: "OTHER_INCOME", non_operating_income: "OTHER_INCOME", interest_income: "OTHER_INCOME",
|
||
other_expense: "OTHER_EXPENSE", non_operating_expense: "OTHER_EXPENSE", interest_expense: "OTHER_EXPENSE",
|
||
tax: "TAX_EXPENSE", income_tax: "TAX_EXPENSE", tax_expense: "TAX_EXPENSE",
|
||
};
|
||
|
||
function classifyAccount(a: any): PnlClassification {
|
||
const key = String(a.subtype ?? "").trim().toLowerCase().replace(/[\s-]+/g, "_");
|
||
if (key && PNL_SUBTYPE_MAP[key]) return PNL_SUBTYPE_MAP[key];
|
||
return a.type === "income" ? "REVENUE" : "OPERATING_EXPENSE";
|
||
}
|
||
|
||
function runPnLEngine(d: any): PnlResult | { error: string } {
|
||
const nominal = ((d.accounts ?? []) as any[]).filter((a) => a.type === "income" || a.type === "expense");
|
||
const accts: PnlAccount[] = nominal.map((a) => ({ id: a.id, name: a.name, type: a.type, classification: classifyAccount(a) }));
|
||
const allow = new Set(nominal.map((a) => a.id));
|
||
const postings: PnlPosting[] = ((d.glLines ?? []) as any[])
|
||
.filter((l) => l.accounts && allow.has(l.accounts.id))
|
||
.map((l) => ({
|
||
accountId: l.accounts.id,
|
||
debitMinor: toMinor(l.debit ?? 0),
|
||
creditMinor: toMinor(l.credit ?? 0),
|
||
date: l.journal_entries?.date ?? d.from ?? d.asOf,
|
||
}));
|
||
try {
|
||
return computePnL(postings, accts, { periodStart: d.from ?? d.asOf, periodEnd: d.asOf });
|
||
} catch (e) {
|
||
if (e instanceof PnlValidationError) return { error: e.message };
|
||
return { error: e instanceof Error ? e.message : String(e) };
|
||
}
|
||
}
|
||
|
||
function buildPnL(d: any, p: any | undefined, useCompare: boolean): StructuredReport {
|
||
const cur = runPnLEngine(d);
|
||
if ("error" in cur) {
|
||
return {
|
||
title: "Profit & Loss",
|
||
rows: [
|
||
{ kind: "section", label: "Profit & Loss could not be computed (validation failed)" },
|
||
{ kind: "section", label: cur.error },
|
||
],
|
||
};
|
||
}
|
||
const prevRes = useCompare && p ? runPnLEngine(p) : undefined;
|
||
const prevOk = prevRes && !("error" in prevRes) ? (prevRes as PnlResult) : undefined;
|
||
|
||
const s = cur.subtotals;
|
||
const ps = prevOk?.subtotals;
|
||
const D = fromMinor;
|
||
const cmp = (v?: number) => (useCompare && v !== undefined ? D(v) : undefined);
|
||
const rows: StructuredRow[] = [];
|
||
const prevByAcct = new Map((prevOk?.lines ?? []).map((l) => [l.accountId, l.amountMinor]));
|
||
|
||
// Emit per-account line items for the given classifications. `displaySign`
|
||
// shows contra/expense reductions as negative figures in the column.
|
||
const lineRows = (classes: PnlClassification[], displaySign: 1 | -1) => {
|
||
for (const l of cur.lines.filter((x) => classes.includes(x.classification)).sort((a, b) => a.name.localeCompare(b.name))) {
|
||
rows.push({
|
||
kind: "sub", label: l.name, amount: D(l.amountMinor * displaySign),
|
||
compare: useCompare ? D((prevByAcct.get(l.accountId) ?? 0) * displaySign) : undefined,
|
||
accountId: l.accountId,
|
||
});
|
||
}
|
||
};
|
||
|
||
const has = (cs: PnlClassification[]) => cur.lines.some((l) => cs.includes(l.classification));
|
||
const hasContra = has(["CONTRA_REVENUE"]);
|
||
const hasCogs = has(["COGS", "CONTRA_COGS"]) || s.costOfGoodsSold !== 0;
|
||
const hasOther = has(["OTHER_INCOME", "OTHER_EXPENSE"]) || s.otherNet !== 0;
|
||
const hasTax = has(["TAX_EXPENSE"]) || s.incomeTaxExpense !== 0;
|
||
|
||
// Revenue → Net Revenue
|
||
rows.push({ kind: "section", label: "Revenue" });
|
||
lineRows(["REVENUE"], 1);
|
||
if (hasContra) lineRows(["CONTRA_REVENUE"], -1);
|
||
rows.push({ kind: "total", label: "Net Revenue", amount: D(s.netRevenue), compare: cmp(ps?.netRevenue) });
|
||
rows.push({ kind: "spacer", label: "" });
|
||
|
||
// Cost of Goods Sold → Gross Profit (only when present)
|
||
if (hasCogs) {
|
||
rows.push({ kind: "section", label: "Cost of Goods Sold" });
|
||
lineRows(["COGS"], 1);
|
||
if (has(["CONTRA_COGS"])) lineRows(["CONTRA_COGS"], -1);
|
||
rows.push({ kind: "total", label: "Total Cost of Goods Sold", amount: D(s.costOfGoodsSold), compare: cmp(ps?.costOfGoodsSold) });
|
||
rows.push({ kind: "total", label: "Gross Profit", amount: D(s.grossProfit), compare: cmp(ps?.grossProfit) });
|
||
rows.push({ kind: "spacer", label: "" });
|
||
}
|
||
|
||
// Operating Expenses → Operating Income (EBIT)
|
||
rows.push({ kind: "section", label: "Operating Expenses" });
|
||
lineRows(["OPERATING_EXPENSE"], 1);
|
||
rows.push({ kind: "total", label: "Total Operating Expenses", amount: D(s.operatingExpenses), compare: cmp(ps?.operatingExpenses) });
|
||
rows.push({ kind: "total", label: "Operating Income (EBIT)", amount: D(s.operatingIncome), compare: cmp(ps?.operatingIncome) });
|
||
|
||
// Other Income / (Expense)
|
||
if (hasOther) {
|
||
rows.push({ kind: "spacer", label: "" });
|
||
rows.push({ kind: "section", label: "Other Income / (Expense)" });
|
||
lineRows(["OTHER_INCOME"], 1);
|
||
lineRows(["OTHER_EXPENSE"], -1);
|
||
rows.push({ kind: "total", label: "Total Other Income / (Expense)", amount: D(s.otherNet), compare: cmp(ps?.otherNet) });
|
||
}
|
||
|
||
// Pre-Tax Income (EBT) — shown when it differs from Operating Income
|
||
if (hasOther || hasTax) {
|
||
rows.push({ kind: "total", label: "Pre-Tax Income (EBT)", amount: D(s.preTaxIncome), compare: cmp(ps?.preTaxIncome) });
|
||
}
|
||
|
||
// Income Tax
|
||
if (hasTax) {
|
||
rows.push({ kind: "spacer", label: "" });
|
||
rows.push({ kind: "section", label: "Income Tax" });
|
||
lineRows(["TAX_EXPENSE"], 1);
|
||
rows.push({ kind: "total", label: "Income Tax Expense", amount: D(s.incomeTaxExpense), compare: cmp(ps?.incomeTaxExpense) });
|
||
}
|
||
|
||
rows.push({ kind: "spacer", label: "" });
|
||
rows.push({ kind: "grand", label: "Net Income", amount: D(s.netIncome), compare: cmp(ps?.netIncome) });
|
||
|
||
// Margins (presentation-only, §5)
|
||
const m = computeMargins(cur);
|
||
if (m.netMargin !== null) {
|
||
const pct = (x: number | null) => (x === null ? "—" : `${(x * 100).toFixed(1)}%`);
|
||
rows.push({ kind: "spacer", label: "" });
|
||
rows.push({ kind: "section", label: `Margins — Gross ${pct(m.grossMargin)} · Operating ${pct(m.operatingMargin)} · Net ${pct(m.netMargin)}` });
|
||
}
|
||
|
||
return { title: "Profit & Loss", rows };
|
||
}
|
||
|
||
// Per-account natural balances + retained-earnings split from a dataset's GL.
|
||
function bsBalances(ds: any) {
|
||
const fyStart = `${String(ds?.asOf ?? "").slice(0, 4)}-01-01`;
|
||
const isDebitNormal = (t: string) => t === "asset" || t === "expense";
|
||
const glByAcct = new Map<string, number>();
|
||
let incomeAll = 0, expenseAll = 0, incomePrior = 0, expensePrior = 0;
|
||
for (const l of (ds?.glCumulative ?? []) as any[]) {
|
||
const t = l.accounts?.type;
|
||
if (!t) continue;
|
||
const debit = Number(l.debit || 0), credit = Number(l.credit || 0);
|
||
const nat = isDebitNormal(t) ? debit - credit : credit - debit;
|
||
glByAcct.set(l.account_id, (glByAcct.get(l.account_id) ?? 0) + nat);
|
||
const isPrior = String(l.journal_entries?.date ?? "") < fyStart;
|
||
if (t === "income") { incomeAll += credit - debit; if (isPrior) incomePrior += credit - debit; }
|
||
else if (t === "expense") { expenseAll += debit - credit; if (isPrior) expensePrior += debit - credit; }
|
||
}
|
||
const rePrior = incomePrior - expensePrior;
|
||
const cye = (incomeAll - expenseAll) - rePrior;
|
||
return { glByAcct, rePrior, cye };
|
||
}
|
||
|
||
function buildBalanceSheet(d: any, p?: any, useCompare?: boolean): StructuredReport {
|
||
// GL-derived (opening balances posted to the GL). Current-Year Earnings is
|
||
// computed independently from actuals (income − expense), never plugged.
|
||
const accounts = (d.accounts ?? []) as any[];
|
||
const cur = bsBalances(d);
|
||
const prev = useCompare && p ? bsBalances(p) : undefined;
|
||
// Surface accounts missing from the active COA list (i.e. archived) that still
|
||
// carry a balance, so their balance lands on the statement instead of being
|
||
// dropped (which would unbalance the Balance Sheet). Archived income/expense
|
||
// accounts already flow into Net Income via bsBalances; asset/liability/equity
|
||
// ones must appear as line items here. Zero-balance accounts are not added.
|
||
const knownIds = new Set(accounts.map((a) => a.id));
|
||
const extraAccounts: any[] = [];
|
||
const seenExtra = new Set<string>();
|
||
for (const l of (d.glCumulative ?? []) as any[]) {
|
||
const id = l.account_id; const meta = l.accounts;
|
||
if (!meta || !id || knownIds.has(id) || seenExtra.has(id)) continue;
|
||
seenExtra.add(id);
|
||
if (Math.abs(cur.glByAcct.get(id) ?? 0) > 0.005) extraAccounts.push({ id, name: meta.name, code: meta.code, type: meta.type });
|
||
}
|
||
const allAccounts = extraAccounts.length ? [...accounts, ...extraAccounts] : accounts;
|
||
const balOf = (a: any) => (cur.glByAcct.get(a.id) ?? 0);
|
||
const balOfP = (a: any) => (prev ? (prev.glByAcct.get(a.id) ?? 0) : undefined);
|
||
const cmp = (v: number | undefined) => (prev ? v : undefined);
|
||
const byType = (t: string) => allAccounts.filter((a) => a.type === t);
|
||
const sumBal = (rows: any[]) => rows.reduce((s, a) => s + balOf(a), 0);
|
||
const sumBalP = (rows: any[]) => (prev ? rows.reduce((s, a) => s + (prev.glByAcct.get(a.id) ?? 0), 0) : undefined);
|
||
|
||
// Account-number + parent-category labels (Buildium style:
|
||
// "3011 SIRS Reserves - 3066 Painting/Waterproofing"). Codes are baked into the
|
||
// label so they always show, and rows are sorted so siblings group by parent.
|
||
const acctMeta = new Map<string, { code: any; name: string }>(allAccounts.map((a: any) => [a.id, { code: a.code ?? null, name: a.name }]));
|
||
const codeName = (code: any, name: any) => `${code != null && String(code).trim() !== "" ? String(code) + " " : ""}${name ?? ""}`.trim();
|
||
const bsLabel = (a: any) => {
|
||
const p = a.parent_account_id ? acctMeta.get(a.parent_account_id) : null;
|
||
return p ? `${codeName(p.code, p.name)} - ${codeName(a.code, a.name)}` : codeName(a.code, a.name);
|
||
};
|
||
const bsKey = (a: any) => {
|
||
const p = a.parent_account_id ? acctMeta.get(a.parent_account_id) : null;
|
||
return `${p ? String(p.code ?? "") : String(a.code ?? "")}|${String(a.code ?? "")}`;
|
||
};
|
||
const bsSort = (arr: any[]) => [...arr].sort((x, y) => bsKey(x).localeCompare(bsKey(y), undefined, { numeric: true }));
|
||
|
||
const rows: StructuredRow[] = [];
|
||
|
||
// Assets
|
||
rows.push({ kind: "section", label: "Assets" });
|
||
const assets = byType("asset");
|
||
for (const a of bsSort(assets)) rows.push({ kind: "sub", label: bsLabel(a), amount: balOf(a), compare: cmp(balOfP(a)), accountId: a.id });
|
||
const totalA = sumBal(assets);
|
||
rows.push({ kind: "grand", label: "TOTAL ASSETS", amount: totalA, compare: cmp(sumBalP(assets)) });
|
||
rows.push({ kind: "spacer", label: "" });
|
||
|
||
// Liabilities
|
||
rows.push({ kind: "section", label: "Liabilities" });
|
||
const liabs = byType("liability");
|
||
for (const a of bsSort(liabs)) rows.push({ kind: "sub", label: bsLabel(a), amount: balOf(a), compare: cmp(balOfP(a)), accountId: a.id });
|
||
const totalL = sumBal(liabs);
|
||
rows.push({ kind: "total", label: "Total Liabilities", amount: totalL, compare: cmp(sumBalP(liabs)) });
|
||
rows.push({ kind: "spacer", label: "" });
|
||
|
||
// Equity — equity accounts + calculated RE / current-year earnings
|
||
rows.push({ kind: "section", label: "Equity" });
|
||
const equityAccs = byType("equity");
|
||
// A real "Retained Earnings" equity account is postable via journal entries.
|
||
// Fold its posted balance into the calculated "Retained Earnings (prior years)"
|
||
// line so manual JE adjustments show there instead of as a separate line.
|
||
// Posted "Retained Earnings" and "Current Year Earnings" equity accounts are
|
||
// postable via journal entries / opening balances. Fold their balances into the
|
||
// calculated prior-years and Net Income lines (instead of separate lines) so the
|
||
// figures the user enters in Opening Balances show up where expected.
|
||
// Fold ONLY the standard "Retained Earnings" / "Current Year Earnings" accounts
|
||
// into the calculated lines. Distinctly-named equity accounts (e.g. "Retained
|
||
// Earnings Savings", a reserve-equity account) must NOT be swallowed — they
|
||
// render as their own equity line. Match the full name, not a substring.
|
||
const reAccts = equityAccs.filter((a) => /^\s*retained\s+earnings(\s*\(.*\))?\s*$/i.test(String(a.name || "")));
|
||
const cyeAccts = equityAccs.filter((a) => /^\s*current\s*year\s*(earnings|income)(\s*\(.*\))?\s*$/i.test(String(a.name || "")) || /^\s*net\s*income\s*$/i.test(String(a.name || "")));
|
||
const foldedIds = new Set([...reAccts, ...cyeAccts].map((a) => a.id));
|
||
const otherEquity = equityAccs.filter((a) => !foldedIds.has(a.id));
|
||
for (const a of bsSort(otherEquity)) rows.push({ kind: "sub", label: bsLabel(a), amount: balOf(a), compare: cmp(balOfP(a)), accountId: a.id });
|
||
const rePosted = reAccts.reduce((s, a) => s + balOf(a), 0);
|
||
const rePostedP = prev ? reAccts.reduce((s, a) => s + (prev.glByAcct.get(a.id) ?? 0), 0) : undefined;
|
||
const cyePosted = cyeAccts.reduce((s, a) => s + balOf(a), 0);
|
||
const cyePostedP = prev ? cyeAccts.reduce((s, a) => s + (prev.glByAcct.get(a.id) ?? 0), 0) : undefined;
|
||
rows.push({ kind: "sub", label: "Retained Earnings (prior years)", amount: cur.rePrior + rePosted, compare: cmp(prev ? prev.rePrior + (rePostedP ?? 0) : undefined) });
|
||
rows.push({ kind: "sub", label: "Net Income", amount: cur.cye + cyePosted, compare: cmp(prev ? prev.cye + (cyePostedP ?? 0) : undefined) });
|
||
const totalE = sumBal(equityAccs) + cur.rePrior + cur.cye;
|
||
const totalEP = prev ? (sumBalP(equityAccs)! + prev.rePrior + prev.cye) : undefined;
|
||
rows.push({ kind: "total", label: "Total Equity", amount: totalE, compare: cmp(totalEP) });
|
||
rows.push({ kind: "spacer", label: "" });
|
||
|
||
rows.push({ kind: "grand", label: "TOTAL LIABILITIES & EQUITY", amount: totalL + totalE, compare: cmp(prev ? (sumBalP(liabs)! + totalEP!) : undefined) });
|
||
|
||
// Residual surfaced (never plugged): Assets − (Liabilities + Equity).
|
||
const cents = (n: number) => Math.round(n * 100);
|
||
const residualCents = cents(totalA) - cents(totalL + totalE);
|
||
const balanced = residualCents === 0;
|
||
return {
|
||
title: "Balance Sheet",
|
||
rows,
|
||
balanced,
|
||
outOfBalanceAmount: balanced ? undefined : residualCents / 100,
|
||
};
|
||
}
|
||
|
||
|
||
// Indirect-method cash flow built from the GL (§5). It ties to the change in the
|
||
// Balance Sheet cash accounts by construction (R4): because every entry balances,
|
||
// the cash impact of net income + all non-cash balance movements equals ΔCash.
|
||
type CashFlowCalc = {
|
||
netIncome: number; operating: { label: string; amount: number }[];
|
||
cfo: number; cfi: number; cff: number; netChange: number;
|
||
beginCash: number; endCash: number; residual: number;
|
||
};
|
||
|
||
function computeCashFlow(d: any): CashFlowCalc {
|
||
const from: string = d.from;
|
||
const acctById = new Map<string, any>((d.accounts ?? []).map((a: any) => [a.id, a]));
|
||
const isCash = (a: any) => !!a && (a.is_bank || /cash|undeposited/i.test(String(a.name || "")));
|
||
const beginRaw = new Map<string, number>();
|
||
const endRaw = new Map<string, number>();
|
||
for (const l of (d.glCumulative ?? []) as any[]) {
|
||
const raw = Number(l.debit || 0) - Number(l.credit || 0);
|
||
endRaw.set(l.account_id, (endRaw.get(l.account_id) ?? 0) + raw);
|
||
if (String(l.journal_entries?.date ?? "") < from) beginRaw.set(l.account_id, (beginRaw.get(l.account_id) ?? 0) + raw);
|
||
}
|
||
const ids = new Set<string>([...endRaw.keys(), ...beginRaw.keys()]);
|
||
const deltaRaw = (id: string) => (endRaw.get(id) ?? 0) - (beginRaw.get(id) ?? 0);
|
||
|
||
let beginCash = 0, endCash = 0, revenue = 0, expense = 0, cfi = 0, cff = 0;
|
||
const operating: { label: string; amount: number }[] = [];
|
||
for (const id of ids) {
|
||
const a = acctById.get(id);
|
||
if (!a) continue;
|
||
if (isCash(a)) { beginCash += beginRaw.get(id) ?? 0; endCash += endRaw.get(id) ?? 0; continue; }
|
||
if (a.type === "income") { revenue += -deltaRaw(id); continue; }
|
||
if (a.type === "expense") { expense += deltaRaw(id); continue; }
|
||
const impact = -deltaRaw(id);
|
||
if (Math.abs(impact) < 0.005) continue;
|
||
const name = String(a.name || "").toLowerCase();
|
||
if (a.type === "asset") {
|
||
const naturalUp = deltaRaw(id) > 0;
|
||
if (/investment|property|equipment|fixed|capital asset/.test(name)) cfi += impact;
|
||
else operating.push({ label: `${naturalUp ? "Increase" : "Decrease"} in ${a.name}`, amount: impact });
|
||
} else if (a.type === "liability") {
|
||
const naturalUp = -deltaRaw(id) > 0;
|
||
if (/loan|note|mortgage|debt|bond/.test(name)) cff += impact;
|
||
else operating.push({ label: `${naturalUp ? "Increase" : "Decrease"} in ${a.name}`, amount: impact });
|
||
} else if (a.type === "equity") { cff += impact; }
|
||
}
|
||
const netIncome = revenue - expense;
|
||
const cfo = netIncome + operating.reduce((s, r) => s + r.amount, 0);
|
||
const netChange = cfo + cfi + cff;
|
||
return { netIncome, operating, cfo, cfi, cff, netChange, beginCash, endCash, residual: netChange - (endCash - beginCash) };
|
||
}
|
||
|
||
function buildCashFlow(d: any, p: any | undefined, useCompare: boolean): StructuredReport {
|
||
const cur = computeCashFlow(d);
|
||
const prev = useCompare && p ? computeCashFlow(p) : undefined;
|
||
const cmp = (v: number | undefined) => (useCompare && prev ? v : undefined);
|
||
// Match prior-period operating line items by label for the compare column.
|
||
const prevByLabel = new Map((prev?.operating ?? []).map((r) => [r.label, r.amount]));
|
||
|
||
const rows: StructuredRow[] = [];
|
||
rows.push({ kind: "section", label: "Operating Activities" });
|
||
rows.push({ kind: "sub", label: "Net Income", amount: cur.netIncome, compare: cmp(prev?.netIncome) });
|
||
for (const r of cur.operating) rows.push({ kind: "sub", label: r.label, amount: r.amount, compare: cmp(prevByLabel.get(r.label) ?? 0) });
|
||
rows.push({ kind: "total", label: "Net Cash from Operating Activities", amount: cur.cfo, compare: cmp(prev?.cfo) });
|
||
rows.push({ kind: "spacer", label: "" });
|
||
rows.push({ kind: "section", label: "Investing Activities" });
|
||
rows.push({ kind: "total", label: "Net Cash from Investing Activities", amount: cur.cfi, compare: cmp(prev?.cfi) });
|
||
rows.push({ kind: "spacer", label: "" });
|
||
rows.push({ kind: "section", label: "Financing Activities" });
|
||
rows.push({ kind: "total", label: "Net Cash from Financing Activities", amount: cur.cff, compare: cmp(prev?.cff) });
|
||
rows.push({ kind: "spacer", label: "" });
|
||
rows.push({ kind: "sub", label: "Beginning Cash", amount: cur.beginCash, compare: cmp(prev?.beginCash) });
|
||
rows.push({ kind: "sub", label: "Ending Cash", amount: cur.endCash, compare: cmp(prev?.endCash) });
|
||
if (Math.abs(cur.residual) >= 0.005) {
|
||
rows.push({ kind: "total", label: "⚠ Out of balance — R4 residual (CFO+CFI+CFF − ΔCash)", amount: cur.residual });
|
||
}
|
||
|
||
return {
|
||
title: "Cash Flow Statement",
|
||
rows,
|
||
cashHighlight: { label: "Net Change in Cash", amount: cur.netChange },
|
||
};
|
||
}
|
||
|
||
// ---------- Flat (non-financial) report builders ----------
|
||
|
||
type Flat = { title: string; columns: string[]; rows: (string | number)[][]; boldRows?: number[] };
|
||
|
||
function buildFlat(id: ReportId, d: any, cur: string): Flat | null {
|
||
const m = (n: number) => money(n, cur);
|
||
switch (id) {
|
||
case "invoice-summary":
|
||
return {
|
||
title: "Invoice Summary", columns: ["Invoice #", "Customer", "Date", "Status", "Amount"],
|
||
rows: d.invoices.map((i: any) => [i.number, i.customers?.name ?? "—", fmtDate(i.issue_date), i.status, m(Number(i.total))]),
|
||
};
|
||
case "customer-balances":
|
||
return {
|
||
title: "Customer Balances", columns: ["Customer", "Balance"],
|
||
rows: d.customers.map((c: any) => [c.name, m(Number(c.balance ?? 0))]),
|
||
};
|
||
case "expense-summary": {
|
||
// GL-driven, billed-date recognition (a bill's expense counts on the bill
|
||
// date — Dr Expense / Cr A/P — and a direct vendor payment on the payment
|
||
// date — Dr Expense / Cr Bank), then we back out the expense of bills issued
|
||
// in the period that aren't paid yet, so the summary reflects amounts the
|
||
// association has actually paid. Direct payments are cash already, so they
|
||
// stay; only the unpaid portion of open bills is removed.
|
||
const byAcct: Record<string, number> = {};
|
||
for (const l of (d.glLines ?? []) as any[]) {
|
||
const acc = l.accounts;
|
||
if (acc?.type !== "expense") continue;
|
||
const amt = Number(l.debit) - Number(l.credit);
|
||
if (amt === 0) continue;
|
||
const name = acc.name ?? "Expense";
|
||
byAcct[name] = (byAcct[name] ?? 0) + amt;
|
||
}
|
||
// Remove the unpaid portion of period bills, prorated, keyed by account name.
|
||
const acctNameById = new Map((d.accounts ?? []).map((a: any) => [a.id, a.name]));
|
||
for (const bi of (d.periodBillItems ?? []) as any[]) {
|
||
const b = bi.bills;
|
||
if (!b || b.status === "void") continue;
|
||
const total = Number(b.total) || 0;
|
||
if (total <= 0) continue;
|
||
const unpaid = total - (Number(b.paid_amount) || 0);
|
||
if (unpaid <= 0) continue;
|
||
const name = acctNameById.get(bi.account_id);
|
||
if (!name || byAcct[name] === undefined) continue;
|
||
byAcct[name] -= Number(bi.amount) * (unpaid / total);
|
||
}
|
||
const kept = Object.entries(byAcct).filter(([, amt]) => Math.abs(amt) >= 0.005);
|
||
const rows = kept.sort((a, b) => b[1] - a[1]).map(([acct, amt]) => [acct, m(amt)]);
|
||
const total = kept.reduce((s, [, v]) => s + v, 0);
|
||
return { title: "Expense Summary (Paid)", columns: ["Expense Account", "Amount"], rows: [...rows, ["TOTAL", m(total)]], boldRows: [rows.length] };
|
||
}
|
||
case "vendor-balances": {
|
||
const byVendor: Record<string, number> = {};
|
||
for (const b of d.bills) {
|
||
const name = b.vendors?.name ?? "—";
|
||
const bal = Number(b.total) - Number(b.paid_amount ?? 0);
|
||
if (bal > 0) byVendor[name] = (byVendor[name] ?? 0) + bal;
|
||
}
|
||
return {
|
||
title: "Vendor Balances (open bills)", columns: ["Vendor", "Outstanding"],
|
||
rows: Object.entries(byVendor).map(([n, v]) => [n, m(v)]),
|
||
};
|
||
}
|
||
default: return null;
|
||
}
|
||
}
|
||
|
||
function flatToStructured(flat: Flat, title: string): StructuredReport {
|
||
const rows: StructuredRow[] = [{ kind: "section", label: flat.columns.join(" · ") }];
|
||
for (const r of flat.rows) {
|
||
const label = r.slice(0, r.length - 1).map(String).join(" · ");
|
||
const last = r[r.length - 1];
|
||
const num = typeof last === "number" ? last : parseFloat(String(last).replace(/[^0-9.\-]/g, "")) || 0;
|
||
rows.push({ kind: "sub", label, amount: num });
|
||
}
|
||
return { title, rows };
|
||
}
|
||
|
||
// ---------- Budget vs Actuals ----------
|
||
|
||
/** Order accounts as a tree (parents first, children indented) instead of by number. */
|
||
function orderAccountsHierarchically(accs: any[]): any[] {
|
||
const byId = new Map(accs.map((a) => [a.id, a]));
|
||
const childrenByParent = new Map<string, any[]>();
|
||
const roots: any[] = [];
|
||
for (const a of accs) {
|
||
if (a.parent_account_id && byId.has(a.parent_account_id)) {
|
||
const arr = childrenByParent.get(a.parent_account_id) ?? [];
|
||
arr.push(a); childrenByParent.set(a.parent_account_id, arr);
|
||
} else {
|
||
roots.push(a);
|
||
}
|
||
}
|
||
const byCode = (a: any, b: any) => String(a.code ?? "").localeCompare(String(b.code ?? ""));
|
||
roots.sort(byCode);
|
||
const out: any[] = [];
|
||
const visit = (node: any, depth: number) => {
|
||
out.push({ ...node, _depth: depth });
|
||
for (const k of (childrenByParent.get(node.id) ?? []).sort(byCode)) visit(k, depth + 1);
|
||
};
|
||
for (const r of roots) visit(r, 0);
|
||
return out;
|
||
}
|
||
|
||
async function fetchBvaActuals(companyId: string, f: string, t: string) {
|
||
// Actuals come straight from the General Ledger so Budget vs. Actuals ties to
|
||
// the Income Statement and works for GL-import / import-mode associations whose
|
||
// activity lives only in journal entries. Sourcing from the operational tables
|
||
// (bills + payment transactions + a budget-weighted invoice "plug") both
|
||
// double-counted (a bill counted as its line item AND its payment, plus any
|
||
// redundant imported bills) and diverged from the posted books.
|
||
const lines = await fetchAllGLLines(
|
||
companyId,
|
||
t,
|
||
"debit,credit,account_id,accounts!inner(id,type),journal_entries!inner(company_id,date)",
|
||
f,
|
||
);
|
||
return { lines };
|
||
}
|
||
|
||
// Actuals per account, netted from the GL: income = credit − debit,
|
||
// expense = debit − credit (the same convention as every other report here).
|
||
function computeBvaActuals(actualsData: any): Record<string, number> {
|
||
const m: Record<string, number> = {};
|
||
for (const l of (actualsData?.lines ?? []) as any[]) {
|
||
const acctId = l.account_id ?? l.accounts?.id;
|
||
if (!acctId) continue;
|
||
const type = l.accounts?.type;
|
||
if (type !== "income" && type !== "expense") continue;
|
||
const debit = Number(l.debit || 0);
|
||
const credit = Number(l.credit || 0);
|
||
m[acctId] = (m[acctId] ?? 0) + (type === "income" ? credit - debit : debit - credit);
|
||
}
|
||
return m;
|
||
}
|
||
|
||
// Budget vs. Actuals column shading — a light→dark grey gradient with left
|
||
// vertical borders so the Budget / Actual / Variance / Variance % columns are
|
||
// easy to tell apart. Applied to the header, group-total and detail cells.
|
||
// Page view stays plain; the grey gradient lives on the PDF export only.
|
||
const BVA_COL = {
|
||
budget: "text-right tabular-nums",
|
||
actual: "text-right tabular-nums",
|
||
variance: "text-right tabular-nums",
|
||
pct: "text-right tabular-nums",
|
||
cmp: "text-right tabular-nums",
|
||
};
|
||
|
||
function BudgetVsActuals({ companyId, from, to, currency, companyName, rangeLabel, logoUrl }: { companyId: string; from: string; to: string; currency: string; companyName: string; rangeLabel: string; logoUrl?: string | null }) {
|
||
const { data: budgets = [] } = useQuery({
|
||
queryKey: ["budgets-active", companyId],
|
||
enabled: !!companyId,
|
||
queryFn: async () => (await accounting.from("budgets").select("*").eq("company_id", companyId).eq("status", "active").order("created_at", { ascending: false })).data ?? [],
|
||
});
|
||
|
||
const [budgetId, setBudgetId] = useState<string>("");
|
||
useEffect(() => { if (!budgetId && budgets.length) setBudgetId(budgets[0].id); }, [budgets, budgetId]);
|
||
|
||
// Actuals comparison window — defaults to the report period, but the user can
|
||
// choose any custom date range to compare against the budget.
|
||
const [actFrom, setActFrom] = useState(from);
|
||
const [actTo, setActTo] = useState(to);
|
||
useEffect(() => { setActFrom(from); setActTo(to); }, [from, to]);
|
||
const actualsLabel = `${actFrom} to ${actTo}`;
|
||
|
||
const { data: accounts = [] } = useQuery({
|
||
queryKey: ["accounts", companyId, "budget-actuals"],
|
||
enabled: !!companyId,
|
||
queryFn: async () => (await accounting.from("accounts").select("*").eq("company_id", companyId).eq("is_archived", false).order("code")).data ?? [],
|
||
});
|
||
|
||
const { data: entries = [] } = useQuery({
|
||
queryKey: ["budget-entries", budgetId],
|
||
enabled: !!budgetId,
|
||
queryFn: async () => (await accounting.from("budget_entries").select("*").eq("budget_id", budgetId)).data ?? [],
|
||
});
|
||
|
||
// Optional comparison window (B3): compare actuals to another date range.
|
||
const [cmpOn, setCmpOn] = useState(false);
|
||
const [cmpFrom, setCmpFrom] = useState("");
|
||
const [cmpTo, setCmpTo] = useState("");
|
||
|
||
const { data: actualsData } = useQuery({
|
||
queryKey: ["bva-actuals", companyId, actFrom, actTo],
|
||
enabled: !!companyId,
|
||
queryFn: () => fetchBvaActuals(companyId, actFrom, actTo),
|
||
});
|
||
|
||
const { data: cmpActualsData } = useQuery({
|
||
queryKey: ["bva-actuals-cmp", companyId, cmpFrom, cmpTo],
|
||
enabled: !!companyId && cmpOn && !!cmpFrom && !!cmpTo,
|
||
queryFn: () => fetchBvaActuals(companyId, cmpFrom, cmpTo),
|
||
});
|
||
|
||
const TYPES_LOCAL = [
|
||
{ value: "income", label: "Income", favorableWhen: "over" as const },
|
||
{ value: "expense", label: "Expenses", favorableWhen: "under" as const },
|
||
];
|
||
|
||
const grouped = useMemo(() => {
|
||
const out: Record<string, any[]> = { income: [], expense: [] };
|
||
for (const a of accounts) if (a.type === "income" || a.type === "expense") out[a.type].push(a);
|
||
return out;
|
||
}, [accounts]);
|
||
|
||
// Same accounts, ordered as a parent→child tree (each carries `_depth`).
|
||
const groupedOrdered = useMemo(() => ({
|
||
income: orderAccountsHierarchically(grouped.income ?? []),
|
||
expense: orderAccountsHierarchically(grouped.expense ?? []),
|
||
} as Record<string, any[]>), [grouped]);
|
||
|
||
const selectedBudget = useMemo(() => (budgets as any[]).find((b) => b.id === budgetId), [budgets, budgetId]);
|
||
|
||
// Budget for the selected actuals window: include each budget period's FULL
|
||
// amount, exactly as entered, for any period the window touches — no proration.
|
||
// period_index maps to months (monthly), quarters (quarterly), or the whole
|
||
// year (annual). E.g. Jan 1 – Jun 16 = full Jan…Jun monthly budgets.
|
||
const budgetByAcct = useMemo(() => {
|
||
const m: Record<string, number> = {};
|
||
const pt = String(selectedBudget?.period_type ?? "annual");
|
||
const fy = Number(selectedBudget?.fiscal_year) || new Date(actFrom || actTo || Date.now()).getFullYear();
|
||
const fromT = actFrom ? new Date(actFrom).getTime() : -Infinity;
|
||
const toT = actTo ? new Date(actTo).getTime() : Infinity;
|
||
const span = (idx: number): [number, number] => {
|
||
if (pt === "monthly") return [new Date(fy, idx, 1).getTime(), new Date(fy, idx + 1, 0).getTime()];
|
||
if (pt === "quarterly") return [new Date(fy, idx * 3, 1).getTime(), new Date(fy, idx * 3 + 3, 0).getTime()];
|
||
return [new Date(fy, 0, 1).getTime(), new Date(fy, 11, 31).getTime()];
|
||
};
|
||
for (const e of (entries as any[])) {
|
||
const [s, en] = span(Number(e.period_index) || 0);
|
||
if (s > toT || en < fromT) continue; // period not touched by the window
|
||
m[e.account_id] = (m[e.account_id] ?? 0) + Number(e.amount);
|
||
}
|
||
return m;
|
||
}, [entries, selectedBudget, actFrom, actTo]);
|
||
|
||
const actualByAcct = useMemo(() => computeBvaActuals(actualsData), [actualsData]);
|
||
const cmpActualByAcct = useMemo(() => computeBvaActuals(cmpActualsData), [cmpActualsData]);
|
||
|
||
// Comparison-window budget (full per-period amounts like budgetByAcct, over
|
||
// [cmpFrom, cmpTo]). No proration.
|
||
const cmpBudgetByAcct = useMemo(() => {
|
||
const m: Record<string, number> = {};
|
||
if (!cmpOn || !cmpFrom || !cmpTo) return m;
|
||
const pt = String(selectedBudget?.period_type ?? "annual");
|
||
const fy = Number(selectedBudget?.fiscal_year) || new Date(cmpFrom || cmpTo || Date.now()).getFullYear();
|
||
const fromT = new Date(cmpFrom).getTime();
|
||
const toT = new Date(cmpTo).getTime();
|
||
const span = (idx: number): [number, number] => {
|
||
if (pt === "monthly") return [new Date(fy, idx, 1).getTime(), new Date(fy, idx + 1, 0).getTime()];
|
||
if (pt === "quarterly") return [new Date(fy, idx * 3, 1).getTime(), new Date(fy, idx * 3 + 3, 0).getTime()];
|
||
return [new Date(fy, 0, 1).getTime(), new Date(fy, 11, 31).getTime()];
|
||
};
|
||
for (const e of (entries as any[])) {
|
||
const [s, en] = span(Number(e.period_index) || 0);
|
||
if (s > toT || en < fromT) continue; // period not touched by the window
|
||
m[e.account_id] = (m[e.account_id] ?? 0) + Number(e.amount);
|
||
}
|
||
return m;
|
||
}, [entries, selectedBudget, cmpOn, cmpFrom, cmpTo]);
|
||
|
||
// Full-year budget per account (no pro-ration) for the Annual Budget column.
|
||
const annualBudgetByAcct = useMemo(() => {
|
||
const m: Record<string, number> = {};
|
||
for (const e of (entries as any[])) m[e.account_id] = (m[e.account_id] ?? 0) + Number(e.amount);
|
||
return m;
|
||
}, [entries]);
|
||
|
||
const chartData = useMemo(() => {
|
||
const sumGroup = (type: "income" | "expense") => {
|
||
const accs = grouped[type] ?? [];
|
||
const b = accs.reduce((s, a) => s + (budgetByAcct[a.id] ?? 0), 0);
|
||
const ac = accs.reduce((s, a) => s + (actualByAcct[a.id] ?? 0), 0);
|
||
return { b, ac };
|
||
};
|
||
const inc = sumGroup("income"); const exp = sumGroup("expense");
|
||
return [
|
||
{ name: "Income", Budget: inc.b, Actual: inc.ac },
|
||
{ name: "Expenses", Budget: exp.b, Actual: exp.ac },
|
||
];
|
||
}, [grouped, budgetByAcct, actualByAcct]);
|
||
|
||
// Flattened rows (group totals + accounts) shared by the CSV and PDF exports.
|
||
const exportRows = useMemo(() => {
|
||
const rows: { label: string; budget: number; actual: number; variance: number; pct: string; group: boolean }[] = [];
|
||
for (const t of TYPES_LOCAL) {
|
||
const accs = grouped[t.value] ?? [];
|
||
if (!accs.length) continue;
|
||
const tb = accs.reduce((s, a) => s + (budgetByAcct[a.id] ?? 0), 0);
|
||
const ta = accs.reduce((s, a) => s + (actualByAcct[a.id] ?? 0), 0);
|
||
const tv = ta - tb;
|
||
rows.push({ label: t.label, budget: tb, actual: ta, variance: tv, pct: tb ? `${((tv / tb) * 100).toFixed(1)}%` : "—", group: true });
|
||
for (const a of groupedOrdered[t.value] ?? []) {
|
||
const b = budgetByAcct[a.id] ?? 0; const ac = actualByAcct[a.id] ?? 0; const v = ac - b;
|
||
const indent = " ".repeat(a._depth ?? 0);
|
||
rows.push({ label: `${indent}${a.code ? `${a.name} (${a.code})` : a.name}`, budget: b, actual: ac, variance: v, pct: b ? `${((v / b) * 100).toFixed(1)}%` : "—", group: false });
|
||
}
|
||
}
|
||
return rows;
|
||
}, [grouped, groupedOrdered, budgetByAcct, actualByAcct]);
|
||
|
||
const fileBase = `budget-vs-actuals-${actFrom}-to-${actTo}`;
|
||
|
||
const exportCSV = () => {
|
||
const esc = (s: any) => `"${String(s).replace(/"/g, '""')}"`;
|
||
const lines = [["Account", "Budget", "Actual", "Variance", "Variance %"].join(",")];
|
||
for (const r of exportRows) lines.push([esc(r.label), r.budget.toFixed(2), r.actual.toFixed(2), r.variance.toFixed(2), esc(r.pct)].join(","));
|
||
const blob = new Blob([lines.join("\n")], { type: "text/csv;charset=utf-8" });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url; a.download = `${fileBase}.csv`; a.click();
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
// Export using the shared branded Budget vs. Actuals generator (current period +
|
||
// optional comparison + Annual Budget columns), matching the main report style.
|
||
const exportPDF = async () => {
|
||
const nameById = new Map((accounts as any[]).map((a) => [a.id, a.name]));
|
||
const parentIds = new Set((accounts as any[]).filter((a) => a.parent_account_id).map((a) => a.parent_account_id));
|
||
const rows: any[] = [];
|
||
for (const type of ["income", "expense"] as const) {
|
||
for (const a of (grouped[type] ?? [])) {
|
||
if (parentIds.has(a.id)) continue; // parent accounts render as group headers
|
||
const budget = budgetByAcct[a.id] ?? 0;
|
||
const actual = actualByAcct[a.id] ?? 0;
|
||
const cmpA = cmpActualByAcct[a.id] ?? 0;
|
||
const cmpB = cmpBudgetByAcct[a.id] ?? 0;
|
||
const annual = annualBudgetByAcct[a.id] ?? 0;
|
||
rows.push({
|
||
id: a.id,
|
||
category: a.code ? `${a.code} ${a.name}` : a.name,
|
||
accountType: type,
|
||
parentId: a.parent_account_id ?? null,
|
||
parentCategory: a.parent_account_id ? (nameById.get(a.parent_account_id) ?? null) : null,
|
||
budget, annualBudget: annual, actual, variance: actual - budget,
|
||
pctOfBudget: budget ? (actual / budget) * 100 : 0,
|
||
comparisonActual: cmpA, comparisonBudget: cmpB, comparisonVariance: cmpA - cmpB,
|
||
comparisonPctOfBudget: cmpB ? (cmpA / cmpB) * 100 : 0, cmpDelta: 0,
|
||
});
|
||
}
|
||
}
|
||
const inc = rows.filter((r) => r.accountType === "income");
|
||
const exp = rows.filter((r) => r.accountType !== "income");
|
||
const sum = (rs: any[], k: string) => rs.reduce((s, r) => s + (Number(r[k]) || 0), 0);
|
||
await generateBudgetVsActualPdf({
|
||
association: { name: companyName, logo_url: logoUrl ?? null },
|
||
fiscalYear: Number(selectedBudget?.fiscal_year) || new Date().getFullYear(),
|
||
rangeLabel: actualsLabel,
|
||
comparisonLabel: cmpOn && cmpFrom && cmpTo ? "Comparison" : null,
|
||
comparisonRangeLabel: cmpOn && cmpFrom && cmpTo ? `${cmpFrom} to ${cmpTo}` : null,
|
||
rows,
|
||
totals: {
|
||
incomeBudget: sum(inc, "budget"), incomeActual: sum(inc, "actual"),
|
||
incomeCmp: sum(inc, "comparisonActual"), incomeCmpBudget: sum(inc, "comparisonBudget"),
|
||
expenseBudget: sum(exp, "budget"), expenseActual: sum(exp, "actual"),
|
||
expenseCmp: sum(exp, "comparisonActual"), expenseCmpBudget: sum(exp, "comparisonBudget"),
|
||
},
|
||
comparisonBudgetMonths: null,
|
||
});
|
||
};
|
||
|
||
if (!budgets.length) {
|
||
return (
|
||
<Card><CardContent className="py-12 text-center text-sm text-muted-foreground">
|
||
No active budgets yet. <Link to="/dashboard/accounting/budgets" className="text-primary underline">Create one</Link> to compare against actuals.
|
||
</CardContent></Card>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<Card>
|
||
<CardContent className="flex flex-wrap items-center gap-3 py-4">
|
||
<Label className="text-sm">Budget:</Label>
|
||
<Select value={budgetId} onValueChange={setBudgetId}>
|
||
<SelectTrigger className="h-9 w-[280px]"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{budgets.map((b: any) => <SelectItem key={b.id} value={b.id}>{b.name} (FY {b.fiscal_year})</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
<div className="flex items-center gap-2">
|
||
<Label className="text-sm">Actuals from</Label>
|
||
<Input type="date" value={actFrom} onChange={(e) => setActFrom(e.target.value)} className="h-9 w-40" />
|
||
<Label className="text-sm">to</Label>
|
||
<Input type="date" value={actTo} onChange={(e) => setActTo(e.target.value)} className="h-9 w-40" />
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Label className="text-sm flex items-center gap-1">
|
||
<input type="checkbox" checked={cmpOn} onChange={(e) => setCmpOn(e.target.checked)} /> Compare to
|
||
</Label>
|
||
{cmpOn && (<>
|
||
<Input type="date" value={cmpFrom} onChange={(e) => setCmpFrom(e.target.value)} className="h-9 w-40" />
|
||
<Label className="text-sm">to</Label>
|
||
<Input type="date" value={cmpTo} onChange={(e) => setCmpTo(e.target.value)} className="h-9 w-40" />
|
||
</>)}
|
||
</div>
|
||
<div className="ml-auto flex gap-2">
|
||
<Button variant="outline" onClick={exportCSV}><Download className="mr-1 h-4 w-4" /> CSV</Button>
|
||
<Button onClick={exportPDF}><FileDown className="mr-1 h-4 w-4" /> PDF</Button>
|
||
</div>
|
||
<div className="w-full text-xs text-muted-foreground">
|
||
Budget shows the full {String((selectedBudget as any)?.period_type ?? "annual")} amounts for every period the selected range touches — exactly as entered, no proration.
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<ReportSheet title="Budget vs. Actuals" subtitle="Accrual basis" companyName={companyName} period={actualsLabel} logoUrl={logoUrl}>
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>Account</TableHead>
|
||
<TableHead className={BVA_COL.budget}>Budget</TableHead>
|
||
<TableHead className={BVA_COL.actual}>Actual</TableHead>
|
||
<TableHead className={BVA_COL.variance}>Variance</TableHead>
|
||
<TableHead className={BVA_COL.pct}>Variance %</TableHead>
|
||
{cmpOn && <TableHead className={BVA_COL.cmp}>Compare</TableHead>}
|
||
{cmpOn && <TableHead className={BVA_COL.cmp}>Δ vs Compare</TableHead>}
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{TYPES_LOCAL.map((t) => {
|
||
const accs = grouped[t.value] ?? [];
|
||
if (!accs.length) return null;
|
||
const totalB = accs.reduce((s, a) => s + (budgetByAcct[a.id] ?? 0), 0);
|
||
const totalA = accs.reduce((s, a) => s + (actualByAcct[a.id] ?? 0), 0);
|
||
const totalC = accs.reduce((s, a) => s + (cmpActualByAcct[a.id] ?? 0), 0);
|
||
const totalVar = totalA - totalB;
|
||
const totalPct = totalB ? (totalVar / totalB) * 100 : 0;
|
||
const totalFavorable = t.favorableWhen === "over" ? totalVar >= 0 : totalVar <= 0;
|
||
return (
|
||
<Fragment key={t.value}>
|
||
<TableRow className="bg-muted/60 font-semibold">
|
||
<TableCell>{t.label}</TableCell>
|
||
<TableCell className={BVA_COL.budget}>{money(totalB, currency)}</TableCell>
|
||
<TableCell className={BVA_COL.actual}>{money(totalA, currency)}</TableCell>
|
||
<TableCell className={`${BVA_COL.variance} ${totalFavorable ? "text-emerald-700" : "text-red-700"}`}>{money(totalVar, currency)}</TableCell>
|
||
<TableCell className={`${BVA_COL.pct} ${totalFavorable ? "text-emerald-700" : "text-red-700"}`}>{totalB ? `${totalPct.toFixed(1)}%` : "—"}</TableCell>
|
||
{cmpOn && <TableCell className={BVA_COL.cmp}>{money(totalC, currency)}</TableCell>}
|
||
{cmpOn && <TableCell className={BVA_COL.cmp}>{money(totalA - totalC, currency)}</TableCell>}
|
||
</TableRow>
|
||
{(groupedOrdered[t.value] ?? []).map((a: any) => {
|
||
const b = budgetByAcct[a.id] ?? 0;
|
||
const ac = actualByAcct[a.id] ?? 0;
|
||
const c = cmpActualByAcct[a.id] ?? 0;
|
||
const v = ac - b;
|
||
const pct = b ? (v / b) * 100 : 0;
|
||
const fav = t.favorableWhen === "over" ? v >= 0 : v <= 0;
|
||
const depth = a._depth ?? 0;
|
||
return (
|
||
<TableRow key={a.id} className="hover:bg-muted/20">
|
||
<TableCell style={{ paddingLeft: `${16 + depth * 20}px` }}>
|
||
<span className={depth === 0 ? "font-semibold" : "font-medium"}>{a.name}</span>
|
||
{a.code && <span className="text-xs text-muted-foreground font-mono ml-2">{a.code}</span>}
|
||
</TableCell>
|
||
<TableCell className={BVA_COL.budget}>{money(b, currency)}</TableCell>
|
||
<TableCell className={BVA_COL.actual}>{money(ac, currency)}</TableCell>
|
||
<TableCell className={`${BVA_COL.variance} ${fav ? "text-emerald-700" : "text-red-700"}`}>{money(v, currency)}</TableCell>
|
||
<TableCell className={`${BVA_COL.pct} ${fav ? "text-emerald-700" : "text-red-700"}`}>{b ? `${pct.toFixed(1)}%` : "—"}</TableCell>
|
||
{cmpOn && <TableCell className={BVA_COL.cmp}>{money(c, currency)}</TableCell>}
|
||
{cmpOn && <TableCell className={BVA_COL.cmp}>{money(ac - c, currency)}</TableCell>}
|
||
</TableRow>
|
||
);
|
||
})}
|
||
</Fragment>
|
||
);
|
||
})}
|
||
</TableBody>
|
||
</Table>
|
||
</ReportSheet>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── AP Aging ─────────────────────────────────────────────────────────────────
|
||
|
||
function APAgingTable({ rows, currency }: { rows: any[]; currency: string }) {
|
||
const now = new Date();
|
||
type APRow = { id: string; name: string; current: number; d30: number; d60: number; d90: number; d90p: number; total: number };
|
||
const byVendor = new Map<string, APRow>();
|
||
for (const bill of rows) {
|
||
const open = Number(bill.total ?? 0) - Number(bill.paid_amount ?? 0);
|
||
if (open <= 0) continue;
|
||
const vid = bill.vendor_id ?? bill.vendors?.id ?? bill.id;
|
||
const name = bill.vendors?.name ?? "Unknown Vendor";
|
||
const due = bill.due_date ? new Date(bill.due_date) : new Date(bill.issue_date ?? Date.now());
|
||
const days = Math.floor((now.getTime() - due.getTime()) / 86400000);
|
||
const r = byVendor.get(vid) ?? { id: vid, name, current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 };
|
||
if (days <= 0) r.current += open;
|
||
else if (days <= 30) r.d30 += open;
|
||
else if (days <= 60) r.d60 += open;
|
||
else if (days <= 90) r.d90 += open;
|
||
else r.d90p += open;
|
||
r.total += open;
|
||
byVendor.set(vid, r);
|
||
}
|
||
const list = Array.from(byVendor.values()).sort((a, b) => b.total - a.total);
|
||
const totals = list.reduce(
|
||
(s, r) => ({ current: s.current + r.current, d30: s.d30 + r.d30, d60: s.d60 + r.d60, d90: s.d90 + r.d90, d90p: s.d90p + r.d90p, total: s.total + r.total }),
|
||
{ current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 }
|
||
);
|
||
return (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>Vendor</TableHead>
|
||
<TableHead className="text-right">Current</TableHead>
|
||
<TableHead className="text-right">1–30 days</TableHead>
|
||
<TableHead className="text-right">31–60 days</TableHead>
|
||
<TableHead className="text-right">61–90 days</TableHead>
|
||
<TableHead className="text-right">90+ days</TableHead>
|
||
<TableHead className="text-right font-semibold">Total</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{list.map((r) => (
|
||
<TableRow key={r.id} className="hover:bg-muted/40">
|
||
<TableCell className="font-medium">{r.name}</TableCell>
|
||
<TableCell className="text-right tabular-nums">{money(r.current, currency)}</TableCell>
|
||
<TableCell className="text-right tabular-nums text-amber-600">{money(r.d30, currency)}</TableCell>
|
||
<TableCell className="text-right tabular-nums text-orange-600">{money(r.d60, currency)}</TableCell>
|
||
<TableCell className="text-right tabular-nums text-red-500">{money(r.d90, currency)}</TableCell>
|
||
<TableCell className="text-right tabular-nums text-red-700 font-medium">{money(r.d90p, currency)}</TableCell>
|
||
<TableCell className="text-right tabular-nums font-semibold">{money(r.total, currency)}</TableCell>
|
||
</TableRow>
|
||
))}
|
||
{list.length === 0 && <TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No outstanding payables.</TableCell></TableRow>}
|
||
{list.length > 0 && (
|
||
<TableRow className="font-semibold bg-muted/30">
|
||
<TableCell>Total</TableCell>
|
||
<TableCell className="text-right tabular-nums">{money(totals.current, currency)}</TableCell>
|
||
<TableCell className="text-right tabular-nums">{money(totals.d30, currency)}</TableCell>
|
||
<TableCell className="text-right tabular-nums">{money(totals.d60, currency)}</TableCell>
|
||
<TableCell className="text-right tabular-nums">{money(totals.d90, currency)}</TableCell>
|
||
<TableCell className="text-right tabular-nums">{money(totals.d90p, currency)}</TableCell>
|
||
<TableCell className="text-right tabular-nums">{money(totals.total, currency)}</TableCell>
|
||
</TableRow>
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
);
|
||
}
|
||
|
||
// ── Homeowner Balance Summary ─────────────────────────────────────────────────
|
||
|
||
function HomeownerSummaryTable({ customers, invoices, currency }: { customers: any[]; invoices: any[]; currency: string }) {
|
||
const byCustomer = useMemo(() => {
|
||
const m = new Map<string, { open: number; count: number; lastPaid: string | null }>();
|
||
for (const inv of invoices) {
|
||
const cid = inv.customer_id;
|
||
if (!cid) continue;
|
||
const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0);
|
||
const isPaid = inv.status === "paid";
|
||
const cur = m.get(cid) ?? { open: 0, count: 0, lastPaid: null };
|
||
if (open > 0) { cur.open += open; cur.count++; }
|
||
if (isPaid && (!cur.lastPaid || inv.issue_date > cur.lastPaid)) cur.lastPaid = inv.issue_date;
|
||
m.set(cid, cur);
|
||
}
|
||
return m;
|
||
}, [invoices]);
|
||
|
||
const rows = customers
|
||
.map((c: any) => ({ ...c, ...(byCustomer.get(c.id) ?? { open: 0, count: 0, lastPaid: null }) }))
|
||
.sort((a, b) => b.open - a.open);
|
||
|
||
const totalBalance = rows.reduce((s, r) => s + Number(r.balance ?? 0), 0);
|
||
const totalOpen = rows.reduce((s, r) => s + r.open, 0);
|
||
|
||
return (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>Homeowner</TableHead>
|
||
<TableHead>Property</TableHead>
|
||
<TableHead className="text-right">Open Invoices</TableHead>
|
||
<TableHead className="text-right">Outstanding Balance</TableHead>
|
||
<TableHead className="text-right">Last Payment</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{rows.map((r: any) => (
|
||
<TableRow key={r.id} className="hover:bg-muted/40">
|
||
<TableCell>
|
||
<Link to={`/dashboard/accounting/customers/${r.id}`} className="font-medium text-primary hover:underline">{r.name}</Link>
|
||
</TableCell>
|
||
<TableCell className="text-sm text-muted-foreground">{r.property_address ?? "—"}{r.lot_number ? ` · Lot ${r.lot_number}` : ""}</TableCell>
|
||
<TableCell className="text-right tabular-nums">{r.count || "—"}</TableCell>
|
||
<TableCell className={`text-right tabular-nums font-medium ${r.open > 0 ? "text-red-600" : ""}`}>{money(r.open, currency)}</TableCell>
|
||
<TableCell className="text-right text-sm text-muted-foreground">{r.lastPaid ? fmtDate(r.lastPaid) : "—"}</TableCell>
|
||
</TableRow>
|
||
))}
|
||
{rows.length === 0 && <TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">No homeowners.</TableCell></TableRow>}
|
||
<TableRow className="font-semibold bg-muted/30">
|
||
<TableCell colSpan={3}>Total ({rows.length} homeowners)</TableCell>
|
||
<TableCell className="text-right tabular-nums">{money(totalOpen, currency)}</TableCell>
|
||
<TableCell />
|
||
</TableRow>
|
||
</TableBody>
|
||
</Table>
|
||
);
|
||
}
|
||
|
||
// ── Delinquency Report ────────────────────────────────────────────────────────
|
||
|
||
function DelinquencyTable({ customers, invoices, currency }: { customers: any[]; invoices: any[]; currency: string }) {
|
||
const now = new Date();
|
||
type DelRow = { id: string; name: string; email: string | null; phone: string | null; property: string | null; overdue: number; oldest: number; invoiceCount: number };
|
||
const byCustomer = new Map<string, DelRow>();
|
||
|
||
for (const inv of invoices) {
|
||
const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0);
|
||
if (open <= 0) continue;
|
||
const due = inv.due_date ? new Date(inv.due_date) : new Date(inv.issue_date ?? Date.now());
|
||
const days = Math.floor((now.getTime() - due.getTime()) / 86400000);
|
||
if (days <= 0) continue; // not overdue yet
|
||
const cid = inv.customer_id;
|
||
if (!cid) continue;
|
||
const cust = customers.find((c: any) => c.id === cid);
|
||
const r = byCustomer.get(cid) ?? {
|
||
id: cid, name: cust?.name ?? inv.customers?.name ?? "—",
|
||
email: cust?.email ?? null, phone: cust?.phone ?? null,
|
||
property: cust?.property_address ?? null,
|
||
overdue: 0, oldest: 0, invoiceCount: 0,
|
||
};
|
||
r.overdue += open;
|
||
r.oldest = Math.max(r.oldest, days);
|
||
r.invoiceCount++;
|
||
byCustomer.set(cid, r);
|
||
}
|
||
|
||
const list = Array.from(byCustomer.values()).sort((a, b) => b.overdue - a.overdue);
|
||
const totalOverdue = list.reduce((s, r) => s + r.overdue, 0);
|
||
|
||
const ageBucket = (days: number) => {
|
||
if (days <= 30) return { label: "1–30 days", cls: "bg-amber-100 text-amber-800" };
|
||
if (days <= 60) return { label: "31–60 days", cls: "bg-orange-100 text-orange-800" };
|
||
if (days <= 90) return { label: "61–90 days", cls: "bg-red-100 text-red-800" };
|
||
return { label: "90+ days", cls: "bg-red-200 text-red-900 font-semibold" };
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
{totalOverdue > 0 && (
|
||
<div className="rounded-md bg-red-50 border border-red-200 px-4 py-2 text-sm flex items-center justify-between">
|
||
<span className="font-medium text-red-800">{list.length} homeowners with overdue balances</span>
|
||
<span className="font-bold text-red-900">{money(totalOverdue, currency)} total overdue</span>
|
||
</div>
|
||
)}
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>Homeowner</TableHead>
|
||
<TableHead>Property</TableHead>
|
||
<TableHead>Contact</TableHead>
|
||
<TableHead className="text-right">Invoices</TableHead>
|
||
<TableHead>Age</TableHead>
|
||
<TableHead className="text-right">Amount Overdue</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{list.map((r) => {
|
||
const { label, cls } = ageBucket(r.oldest);
|
||
return (
|
||
<TableRow key={r.id} className="hover:bg-muted/40">
|
||
<TableCell>
|
||
<Link to={`/dashboard/accounting/customers/${r.id}`} className="font-medium text-primary hover:underline">{r.name}</Link>
|
||
</TableCell>
|
||
<TableCell className="text-sm text-muted-foreground">{r.property ?? "—"}</TableCell>
|
||
<TableCell className="text-sm">
|
||
{r.email && <div><a href={`mailto:${r.email}`} className="text-primary hover:underline">{r.email}</a></div>}
|
||
{r.phone && <div className="text-muted-foreground">{r.phone}</div>}
|
||
</TableCell>
|
||
<TableCell className="text-right tabular-nums">{r.invoiceCount}</TableCell>
|
||
<TableCell><span className={`rounded-full px-2 py-0.5 text-xs ${cls}`}>{label}</span></TableCell>
|
||
<TableCell className="text-right tabular-nums font-semibold text-red-700">{money(r.overdue, currency)}</TableCell>
|
||
</TableRow>
|
||
);
|
||
})}
|
||
{list.length === 0 && <TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">No overdue accounts. All homeowners are current.</TableCell></TableRow>}
|
||
{list.length > 0 && (
|
||
<TableRow className="font-semibold bg-muted/30">
|
||
<TableCell colSpan={5}>Total overdue</TableCell>
|
||
<TableCell className="text-right tabular-nums text-red-700">{money(totalOverdue, currency)}</TableCell>
|
||
</TableRow>
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── AR Aging ──────────────────────────────────────────────────────────────────
|
||
|
||
function ARAgingTable({ rows, currency, detailed = false }: { rows: any[]; currency: string; detailed?: boolean }) {
|
||
const now = new Date();
|
||
type AgingRow = { id: string; name: string; current: number; d30: number; d60: number; d90: number; d90p: number; total: number };
|
||
const byCustomer = new Map<string, AgingRow>();
|
||
for (const inv of rows) {
|
||
const open = Number(inv.total ?? 0) - Number(inv.paid_amount ?? 0);
|
||
if (open <= 0) continue;
|
||
const cid = inv.customer_id ?? inv.customers?.id;
|
||
if (!cid) continue;
|
||
const name = inv.customers?.name ?? "—";
|
||
const due = inv.due_date ? new Date(inv.due_date) : new Date(inv.issue_date);
|
||
const days = Math.floor((now.getTime() - due.getTime()) / 86400000);
|
||
const r = byCustomer.get(cid) ?? { id: cid, name, current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 };
|
||
if (days <= 0) r.current += open;
|
||
else if (days <= 30) r.d30 += open;
|
||
else if (days <= 60) r.d60 += open;
|
||
else if (days <= 90) r.d90 += open;
|
||
else r.d90p += open;
|
||
r.total += open;
|
||
byCustomer.set(cid, r);
|
||
}
|
||
const list = Array.from(byCustomer.values()).sort((a, b) => b.total - a.total);
|
||
const totals = list.reduce(
|
||
(s, r) => ({ current: s.current + r.current, d30: s.d30 + r.d30, d60: s.d60 + r.d60, d90: s.d90 + r.d90, d90p: s.d90p + r.d90p, total: s.total + r.total }),
|
||
{ current: 0, d30: 0, d60: 0, d90: 0, d90p: 0, total: 0 }
|
||
);
|
||
return (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>Homeowner</TableHead>
|
||
<TableHead className="text-right">Current</TableHead>
|
||
<TableHead className="text-right">1–30 days</TableHead>
|
||
<TableHead className="text-right">31–60 days</TableHead>
|
||
<TableHead className="text-right">61–90 days</TableHead>
|
||
<TableHead className="text-right">90+ days</TableHead>
|
||
<TableHead className="text-right font-semibold">Total</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{list.map((r) => (
|
||
<TableRow key={r.id} className="cursor-pointer hover:bg-muted/40">
|
||
<TableCell>
|
||
<Link to={`/dashboard/accounting/customers/${r.id}`} className="text-primary hover:underline">{r.name}</Link>
|
||
</TableCell>
|
||
<TableCell className="text-right">{money(r.current, currency)}</TableCell>
|
||
<TableCell className="text-right">{money(r.d30, currency)}</TableCell>
|
||
<TableCell className="text-right">{money(r.d60, currency)}</TableCell>
|
||
<TableCell className="text-right">{money(r.d90, currency)}</TableCell>
|
||
<TableCell className="text-right">{money(r.d90p, currency)}</TableCell>
|
||
<TableCell className="text-right font-medium">{money(r.total, currency)}</TableCell>
|
||
</TableRow>
|
||
))}
|
||
{list.length === 0 && <TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No outstanding receivables.</TableCell></TableRow>}
|
||
{list.length > 0 && (
|
||
<TableRow className="font-semibold bg-muted/30">
|
||
<TableCell>Total</TableCell>
|
||
<TableCell className="text-right">{money(totals.current, currency)}</TableCell>
|
||
<TableCell className="text-right">{money(totals.d30, currency)}</TableCell>
|
||
<TableCell className="text-right">{money(totals.d60, currency)}</TableCell>
|
||
<TableCell className="text-right">{money(totals.d90, currency)}</TableCell>
|
||
<TableCell className="text-right">{money(totals.d90p, currency)}</TableCell>
|
||
<TableCell className="text-right">{money(totals.total, currency)}</TableCell>
|
||
</TableRow>
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
);
|
||
}
|
||
|