mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
Accounting reports: AR Aging (Property), Pre-Paid Homeowners, Cash Disbursement
Buildium-style reports built on the owner ledger and GL: - AR Aging (Property): FIFO-aged buckets (0-30/over 30/60/90) per unit with charge-type breakdown, collection status, summary + distribution bar - Pre-Paid Homeowners: units with net credit balances as of a date - Cash Disbursement: bank-credit GL entries grouped by bank account with check#/vendor/invoice enrichment from the banking register and GL line detail All with branded PDF/CSV exports; shared owner-ledger helpers in lib/ownerLedger.ts Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,9 @@ import { Lock } from "lucide-react";
|
|||||||
import { TrialBalanceReport } from "./components/TrialBalanceReport";
|
import { TrialBalanceReport } from "./components/TrialBalanceReport";
|
||||||
import { GeneralLedgerReport } from "./components/GeneralLedgerReport";
|
import { GeneralLedgerReport } from "./components/GeneralLedgerReport";
|
||||||
import { ReserveFundReport } from "./components/ReserveFundReport";
|
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 { ReportSheet } from "./components/ReportSheet";
|
||||||
import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter, type BrandedLogo } from "./lib/reportHeader";
|
import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter, type BrandedLogo } from "./lib/reportHeader";
|
||||||
import { generateBudgetVsActualPdf } from "@/lib/budgetVsActualPdf";
|
import { generateBudgetVsActualPdf } from "@/lib/budgetVsActualPdf";
|
||||||
@@ -38,8 +41,8 @@ import { generateBudgetVsActualPdf } from "@/lib/budgetVsActualPdf";
|
|||||||
type ReportId =
|
type ReportId =
|
||||||
| "pnl" | "income-statement" | "balance-sheet" | "cash-flow" | "movement-of-equity" | "budget-vs-actuals"
|
| "pnl" | "income-statement" | "balance-sheet" | "cash-flow" | "movement-of-equity" | "budget-vs-actuals"
|
||||||
| "trial-balance" | "general-ledger"
|
| "trial-balance" | "general-ledger"
|
||||||
| "invoice-summary" | "customer-balances" | "ar-aging" | "homeowner-summary" | "delinquency"
|
| "invoice-summary" | "customer-balances" | "ar-aging" | "ar-aging-property" | "prepaid-homeowners" | "homeowner-summary" | "delinquency"
|
||||||
| "expense-summary" | "vendor-balances" | "ap-aging" | "reconciliation"
|
| "expense-summary" | "vendor-balances" | "ap-aging" | "cash-disbursement" | "reconciliation"
|
||||||
| "reserve-fund";
|
| "reserve-fund";
|
||||||
|
|
||||||
const APP_NAME = "Cozy Books";
|
const APP_NAME = "Cozy Books";
|
||||||
@@ -57,6 +60,8 @@ const GROUPS = [
|
|||||||
{ id: "budget-vs-actuals" as ReportId, name: "Budget vs Actuals" },
|
{ id: "budget-vs-actuals" as ReportId, name: "Budget vs Actuals" },
|
||||||
]},
|
]},
|
||||||
{ name: "Receivables", reports: [
|
{ 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: "ar-aging" as ReportId, name: "AR Aging Details" },
|
||||||
{ id: "homeowner-summary" as ReportId, name: "Homeowner Balance Summary" },
|
{ id: "homeowner-summary" as ReportId, name: "Homeowner Balance Summary" },
|
||||||
{ id: "customer-balances" as ReportId, name: "Invoice Summary by Customer" },
|
{ id: "customer-balances" as ReportId, name: "Invoice Summary by Customer" },
|
||||||
@@ -64,6 +69,7 @@ const GROUPS = [
|
|||||||
{ id: "delinquency" as ReportId, name: "Delinquency Report" },
|
{ id: "delinquency" as ReportId, name: "Delinquency Report" },
|
||||||
]},
|
]},
|
||||||
{ name: "Payables", reports: [
|
{ name: "Payables", reports: [
|
||||||
|
{ id: "cash-disbursement" as ReportId, name: "Cash Disbursement" },
|
||||||
{ id: "ap-aging" as ReportId, name: "AP Aging Details" },
|
{ id: "ap-aging" as ReportId, name: "AP Aging Details" },
|
||||||
{ id: "expense-summary" as ReportId, name: "Expense Summary" },
|
{ id: "expense-summary" as ReportId, name: "Expense Summary" },
|
||||||
{ id: "vendor-balances" as ReportId, name: "Vendor Balance Summary" },
|
{ id: "vendor-balances" as ReportId, name: "Vendor Balance Summary" },
|
||||||
@@ -396,7 +402,8 @@ export default function AccountingReportsPage({ association }: { association?: {
|
|||||||
}, [active, arOpen, data, flat, structured, cur, activeMeta.name]);
|
}, [active, arOpen, data, flat, structured, cur, activeMeta.name]);
|
||||||
|
|
||||||
// Reports whose export is handled internally (own PDF/CSV buttons inside the component)
|
// 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 === "income-statement";
|
const hasOwnExport = active === "trial-balance" || active === "general-ledger" || active === "budget-vs-actuals" || active === "income-statement"
|
||||||
|
|| active === "ar-aging-property" || active === "prepaid-homeowners" || active === "cash-disbursement";
|
||||||
const anyExportable = !!(structured || flat || exportFlat);
|
const anyExportable = !!(structured || flat || exportFlat);
|
||||||
|
|
||||||
const doExportPDF = async () => {
|
const doExportPDF = async () => {
|
||||||
@@ -588,6 +595,15 @@ export default function AccountingReportsPage({ association }: { association?: {
|
|||||||
{active === "reserve-fund" && (
|
{active === "reserve-fund" && (
|
||||||
<ReserveFundReport companyId={cid} companyName={associationName ?? ""} fiscalYearStart={fiscalYearStart} logoUrl={logoUrl} />
|
<ReserveFundReport companyId={cid} companyName={associationName ?? ""} fiscalYearStart={fiscalYearStart} logoUrl={logoUrl} />
|
||||||
)}
|
)}
|
||||||
|
{active === "ar-aging-property" && (
|
||||||
|
<ARAgingPropertyReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} />
|
||||||
|
)}
|
||||||
|
{active === "prepaid-homeowners" && (
|
||||||
|
<PrepaidHomeownersReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} />
|
||||||
|
)}
|
||||||
|
{active === "cash-disbursement" && (
|
||||||
|
<CashDisbursementReport companyId={cid} companyName={associationName ?? ""} logoUrl={logoUrl} />
|
||||||
|
)}
|
||||||
{active === "reconciliation" && (
|
{active === "reconciliation" && (
|
||||||
<ReportSheet title="Reconciliation Checks" companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}>
|
<ReportSheet title="Reconciliation Checks" companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}>
|
||||||
<ReconciliationReport d={data} currency={cur} />
|
<ReconciliationReport d={data} currency={cur} />
|
||||||
@@ -604,7 +620,7 @@ export default function AccountingReportsPage({ association }: { association?: {
|
|||||||
<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>
|
<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" && (
|
{!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}>
|
<ReportSheet title={activeMeta.name} companyName={associationName ?? "Company"} period={rangeLabel} logoUrl={logoUrl}>
|
||||||
{!data ? (
|
{!data ? (
|
||||||
<div className="text-sm text-muted-foreground">Loading…</div>
|
<div className="text-sm text-muted-foreground">Loading…</div>
|
||||||
|
|||||||
@@ -0,0 +1,432 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { FileDown, Download } from "lucide-react";
|
||||||
|
import jsPDF from "jspdf";
|
||||||
|
import autoTable from "jspdf-autotable";
|
||||||
|
import { ReportSheet } from "./ReportSheet";
|
||||||
|
import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter } from "../lib/reportHeader";
|
||||||
|
import {
|
||||||
|
fetchAssociationId, fetchOwnerLedger, fetchUnitsAndOwners,
|
||||||
|
unitLabel, chargeTypeLabel, money,
|
||||||
|
type OwnerLedgerEntry, type UnitInfo, type OwnerInfo,
|
||||||
|
} from "../lib/ownerLedger";
|
||||||
|
|
||||||
|
const TEAL: [number, number, number] = [0, 137, 123];
|
||||||
|
const BUCKETS = ["0-30", "Over 30", "Over 60", "Over 90"] as const;
|
||||||
|
const BUCKET_COLORS: [number, number, number][] = [
|
||||||
|
[141, 178, 85], // green (0-30)
|
||||||
|
[234, 179, 8], // amber (over 30)
|
||||||
|
[234, 124, 8], // orange (over 60)
|
||||||
|
[220, 68, 68], // red (over 90)
|
||||||
|
];
|
||||||
|
|
||||||
|
type Buckets = [number, number, number, number];
|
||||||
|
|
||||||
|
type UnitAging = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
collStatus: string | null;
|
||||||
|
buckets: Buckets;
|
||||||
|
total: number;
|
||||||
|
byType: Map<string, { buckets: Buckets; total: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function emptyBuckets(): Buckets { return [0, 0, 0, 0]; }
|
||||||
|
function bucketIndex(asOf: string, chargeDate: string): number {
|
||||||
|
const days = Math.floor((new Date(asOf + "T00:00:00").getTime() - new Date(chargeDate + "T00:00:00").getTime()) / 86400000);
|
||||||
|
if (days <= 30) return 0;
|
||||||
|
if (days <= 60) return 1;
|
||||||
|
if (days <= 90) return 2;
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prettyStatus = (s: string | null | undefined) =>
|
||||||
|
s ? s.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) : null;
|
||||||
|
|
||||||
|
const dash = (n: number) => (n ? money(n) : "-");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buildium-style AR Aging: per-property open charge balances aged into
|
||||||
|
* 0-30 / Over 30 / Over 60 / Over 90 buckets, with charge-type breakdown,
|
||||||
|
* collection status, summary and distribution. Payments and credits apply to
|
||||||
|
* charges oldest-first (FIFO), so only genuinely open charge amounts age.
|
||||||
|
*/
|
||||||
|
export function ARAgingPropertyReport({ companyId, companyName, logoUrl }: { companyId: string; companyName: string; logoUrl?: string | null }) {
|
||||||
|
const [asOf, setAsOf] = useState(() => new Date().toISOString().slice(0, 10));
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["ar-aging-property", companyId, asOf],
|
||||||
|
enabled: !!companyId,
|
||||||
|
queryFn: async () => {
|
||||||
|
const associationId = await fetchAssociationId(companyId);
|
||||||
|
if (!associationId) return null;
|
||||||
|
const [entries, { units, owners }, collectionsRes] = await Promise.all([
|
||||||
|
fetchOwnerLedger(associationId, asOf),
|
||||||
|
fetchUnitsAndOwners(associationId),
|
||||||
|
supabase.from("collections").select("unit_id, owner_id, status, updated_at").eq("association_id", associationId).order("updated_at", { ascending: false }),
|
||||||
|
]);
|
||||||
|
return { entries, units, owners, collections: (collectionsRes.data ?? []) as any[] };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = useMemo(() => {
|
||||||
|
if (!data) return null;
|
||||||
|
const { entries, units, owners, collections } = data;
|
||||||
|
|
||||||
|
const unitById = new Map<string, UnitInfo>();
|
||||||
|
for (const u of units) unitById.set(u.id, u);
|
||||||
|
const ownerById = new Map<string, OwnerInfo>();
|
||||||
|
for (const o of owners) ownerById.set(o.id, o);
|
||||||
|
const ownerByUnit = new Map<string, OwnerInfo>();
|
||||||
|
for (const o of owners) if (o.unit_id && !ownerByUnit.has(o.unit_id)) ownerByUnit.set(o.unit_id, o);
|
||||||
|
|
||||||
|
// Latest collection status per unit (rows came back newest-first)
|
||||||
|
const collByUnit = new Map<string, string>();
|
||||||
|
const collByOwner = new Map<string, string>();
|
||||||
|
for (const c of collections) {
|
||||||
|
if (c.unit_id && !collByUnit.has(c.unit_id)) collByUnit.set(c.unit_id, c.status);
|
||||||
|
if (c.owner_id && !collByOwner.has(c.owner_id)) collByOwner.set(c.owner_id, c.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group ledger entries per unit (fall back to owner when the entry has no unit)
|
||||||
|
const byUnit = new Map<string, OwnerLedgerEntry[]>();
|
||||||
|
for (const e of entries) {
|
||||||
|
const key = e.unit_id ? `u:${e.unit_id}` : e.owner_id ? `o:${e.owner_id}` : null;
|
||||||
|
if (!key) continue;
|
||||||
|
const list = byUnit.get(key) ?? [];
|
||||||
|
list.push(e);
|
||||||
|
byUnit.set(key, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: UnitAging[] = [];
|
||||||
|
for (const [key, list] of byUnit) {
|
||||||
|
list.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
// FIFO: total credits pay down the oldest charges first
|
||||||
|
let creditPool = list.reduce((s, e) => s + e.credit, 0);
|
||||||
|
const buckets = emptyBuckets();
|
||||||
|
const byType = new Map<string, { buckets: Buckets; total: number }>();
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
for (const e of list) {
|
||||||
|
if (e.debit <= 0) continue;
|
||||||
|
let open = e.debit;
|
||||||
|
if (creditPool > 0) {
|
||||||
|
const applied = Math.min(creditPool, open);
|
||||||
|
creditPool -= applied;
|
||||||
|
open -= applied;
|
||||||
|
}
|
||||||
|
if (open <= 0.004) continue;
|
||||||
|
const bi = bucketIndex(asOf, e.date);
|
||||||
|
buckets[bi] += open;
|
||||||
|
total += open;
|
||||||
|
const label = chargeTypeLabel(e.transaction_type);
|
||||||
|
const t = byType.get(label) ?? { buckets: emptyBuckets(), total: 0 };
|
||||||
|
t.buckets[bi] += open;
|
||||||
|
t.total += open;
|
||||||
|
byType.set(label, t);
|
||||||
|
}
|
||||||
|
if (total <= 0.004) continue;
|
||||||
|
|
||||||
|
const unitId = key.startsWith("u:") ? key.slice(2) : null;
|
||||||
|
const ownerId = key.startsWith("o:") ? key.slice(2) : null;
|
||||||
|
const unit = unitId ? unitById.get(unitId) : undefined;
|
||||||
|
const owner = (unitId ? ownerByUnit.get(unitId) : null) ?? (ownerId ? ownerById.get(ownerId) : null) ?? null;
|
||||||
|
const collStatus = prettyStatus((unitId && collByUnit.get(unitId)) || (owner && collByOwner.get(owner.id)) || null);
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
key,
|
||||||
|
label: unitLabel(unit, owner?.last_name ?? null),
|
||||||
|
collStatus,
|
||||||
|
buckets,
|
||||||
|
total,
|
||||||
|
byType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
|
||||||
|
// Charge-type summary across all properties
|
||||||
|
const summary = new Map<string, { count: number; balance: number }>();
|
||||||
|
for (const r of rows) {
|
||||||
|
for (const [label, t] of r.byType) {
|
||||||
|
const s = summary.get(label) ?? { count: 0, balance: 0 };
|
||||||
|
s.count += 1;
|
||||||
|
s.balance += t.total;
|
||||||
|
summary.set(label, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const summaryRows = [...summary.entries()].sort((a, b) => b[1].balance - a[1].balance);
|
||||||
|
|
||||||
|
const totals = rows.reduce<Buckets>((t, r) => [t[0] + r.buckets[0], t[1] + r.buckets[1], t[2] + r.buckets[2], t[3] + r.buckets[3]], emptyBuckets());
|
||||||
|
const counts = rows.reduce<[number, number, number, number]>(
|
||||||
|
(t, r) => [t[0] + (r.buckets[0] > 0.004 ? 1 : 0), t[1] + (r.buckets[1] > 0.004 ? 1 : 0), t[2] + (r.buckets[2] > 0.004 ? 1 : 0), t[3] + (r.buckets[3] > 0.004 ? 1 : 0)],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
);
|
||||||
|
const grandTotal = totals.reduce((s, n) => s + n, 0);
|
||||||
|
const distribution = totals.map((n) => (grandTotal > 0 ? (n / grandTotal) * 100 : 0));
|
||||||
|
|
||||||
|
return { rows, summaryRows, totals, counts, grandTotal, distribution };
|
||||||
|
}, [data, asOf]);
|
||||||
|
|
||||||
|
const asOfLabel = new Date(asOf + "T00:00:00").toLocaleDateString("en-US", { month: "numeric", day: "numeric", year: "numeric" });
|
||||||
|
|
||||||
|
const exportPDF = async () => {
|
||||||
|
if (!report) return;
|
||||||
|
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
||||||
|
const ML = 40;
|
||||||
|
const pageW = doc.internal.pageSize.getWidth();
|
||||||
|
const logo = await loadBrandedLogo(logoUrl);
|
||||||
|
let y = drawBrandedHeader(doc, {
|
||||||
|
logo, title: "AR Aging", subtitle: `As of ${asOfLabel}`,
|
||||||
|
metaLines: [{ label: "Properties:", value: companyName || "" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Summary (left) — charge types
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: y,
|
||||||
|
head: [["Charge", "Balance"]],
|
||||||
|
body: [
|
||||||
|
...report.summaryRows.map(([label, s]) => [`${label} (${s.count})`, money(s.balance)]),
|
||||||
|
[{ content: "Total", styles: { fontStyle: "bold" } } as any, { content: money(report.grandTotal), styles: { fontStyle: "bold", halign: "right" } } as any],
|
||||||
|
],
|
||||||
|
styles: { fontSize: 8, cellPadding: 4 },
|
||||||
|
headStyles: { fillColor: TEAL, textColor: 255 },
|
||||||
|
columnStyles: { 1: { halign: "right" } },
|
||||||
|
margin: { left: ML, right: pageW / 2 + 20 },
|
||||||
|
tableWidth: pageW / 2 - ML - 30,
|
||||||
|
});
|
||||||
|
const summaryEndY = (doc as any).lastAutoTable.finalY;
|
||||||
|
|
||||||
|
// Distribution (right) — stacked bucket bar with % legend
|
||||||
|
const barX = pageW / 2 + 20;
|
||||||
|
const barW = pageW - ML - barX;
|
||||||
|
let dy = y + 4;
|
||||||
|
doc.setFont("helvetica", "bold"); doc.setFontSize(8); doc.setTextColor(100);
|
||||||
|
doc.text("DISTRIBUTION", barX, dy);
|
||||||
|
dy += 8;
|
||||||
|
if (report.grandTotal > 0) {
|
||||||
|
let x = barX;
|
||||||
|
report.distribution.forEach((pct, i) => {
|
||||||
|
const w = (pct / 100) * barW;
|
||||||
|
if (w <= 0) return;
|
||||||
|
const c = BUCKET_COLORS[i];
|
||||||
|
doc.setFillColor(c[0], c[1], c[2]);
|
||||||
|
doc.rect(x, dy, w, 14, "F");
|
||||||
|
x += w;
|
||||||
|
});
|
||||||
|
dy += 24;
|
||||||
|
doc.setFont("helvetica", "normal"); doc.setFontSize(7.5); doc.setTextColor(60);
|
||||||
|
report.distribution.forEach((pct, i) => {
|
||||||
|
if (pct <= 0) return;
|
||||||
|
const c = BUCKET_COLORS[i];
|
||||||
|
doc.setFillColor(c[0], c[1], c[2]);
|
||||||
|
doc.rect(barX, dy - 6, 7, 7, "F");
|
||||||
|
doc.text(`${BUCKETS[i]}: ${pct.toFixed(2)} %`, barX + 11, dy);
|
||||||
|
dy += 12;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
y = Math.max(summaryEndY, dy) + 16;
|
||||||
|
|
||||||
|
// Property detail
|
||||||
|
const body: any[] = [];
|
||||||
|
for (const r of report.rows) {
|
||||||
|
body.push([
|
||||||
|
{ content: r.label + (r.collStatus ? `\nColl Status: ${r.collStatus}` : ""), styles: { fontStyle: "bold" } },
|
||||||
|
{ content: dash(r.buckets[0]), styles: { fontStyle: "bold", halign: "right" } },
|
||||||
|
{ content: dash(r.buckets[1]), styles: { fontStyle: "bold", halign: "right" } },
|
||||||
|
{ content: dash(r.buckets[2]), styles: { fontStyle: "bold", halign: "right" } },
|
||||||
|
{ content: dash(r.buckets[3]), styles: { fontStyle: "bold", halign: "right" } },
|
||||||
|
{ content: money(r.total), styles: { fontStyle: "bold", halign: "right" } },
|
||||||
|
]);
|
||||||
|
for (const [label, t] of r.byType) {
|
||||||
|
body.push([
|
||||||
|
{ content: ` ${label}`, styles: {} },
|
||||||
|
{ content: dash(t.buckets[0]), styles: { halign: "right" } },
|
||||||
|
{ content: dash(t.buckets[1]), styles: { halign: "right" } },
|
||||||
|
{ content: dash(t.buckets[2]), styles: { halign: "right" } },
|
||||||
|
{ content: dash(t.buckets[3]), styles: { halign: "right" } },
|
||||||
|
{ content: money(t.total), styles: { halign: "right" } },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body.push([
|
||||||
|
{ content: "Total:", styles: { fontStyle: "bold" } },
|
||||||
|
...report.totals.map((n) => ({ content: money(n), styles: { fontStyle: "bold", halign: "right" } })),
|
||||||
|
{ content: money(report.grandTotal), styles: { fontStyle: "bold", halign: "right" } },
|
||||||
|
]);
|
||||||
|
body.push([
|
||||||
|
{ content: "Property Count:", styles: { fontStyle: "bold" } },
|
||||||
|
...report.counts.map((n) => ({ content: String(n), styles: { fontStyle: "bold", halign: "right" } })),
|
||||||
|
{ content: "", styles: {} },
|
||||||
|
]);
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: y,
|
||||||
|
head: [["Property", "0-30", "Over 30", "Over 60", "Over 90", "Balance"]],
|
||||||
|
body,
|
||||||
|
styles: { fontSize: 8, cellPadding: 3 },
|
||||||
|
headStyles: { fillColor: TEAL, textColor: 255 },
|
||||||
|
columnStyles: { 0: { cellWidth: 220 }, 1: { halign: "right" }, 2: { halign: "right" }, 3: { halign: "right" }, 4: { halign: "right" }, 5: { halign: "right" } },
|
||||||
|
margin: { left: ML, right: ML },
|
||||||
|
});
|
||||||
|
|
||||||
|
drawBrandedFooter(doc);
|
||||||
|
doc.save(`ar-aging-${asOf}.pdf`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportCSV = () => {
|
||||||
|
if (!report) return;
|
||||||
|
const f = (n: number) => (Math.round((n + Number.EPSILON) * 100) / 100 || 0).toFixed(2);
|
||||||
|
const lines = [["Property", "Coll Status", "Charge Type", "0-30", "Over 30", "Over 60", "Over 90", "Balance"].join(",")];
|
||||||
|
const q = (s: string) => `"${s.replace(/"/g, '""')}"`;
|
||||||
|
for (const r of report.rows) {
|
||||||
|
lines.push([q(r.label), q(r.collStatus ?? ""), "", f(r.buckets[0]), f(r.buckets[1]), f(r.buckets[2]), f(r.buckets[3]), f(r.total)].join(","));
|
||||||
|
for (const [label, t] of r.byType) {
|
||||||
|
lines.push([q(r.label), "", q(label), f(t.buckets[0]), f(t.buckets[1]), f(t.buckets[2]), f(t.buckets[3]), f(t.total)].join(","));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push(["TOTAL", "", "", ...report.totals.map(f), f(report.grandTotal)].join(","));
|
||||||
|
const blob = new Blob([lines.join("\n")], { type: "text/csv" });
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = `ar-aging-${asOf}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
};
|
||||||
|
|
||||||
|
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">As of</Label>
|
||||||
|
<Input type="date" value={asOf} onChange={(e) => setAsOf(e.target.value || asOf)} className="w-44 mt-1" />
|
||||||
|
</div>
|
||||||
|
{report && report.rows.length > 0 && (
|
||||||
|
<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>
|
||||||
|
) : !report || report.rows.length === 0 ? (
|
||||||
|
<Card><CardContent className="p-8 text-center text-sm text-muted-foreground">
|
||||||
|
No open balances as of {asOfLabel}. 🎉
|
||||||
|
</CardContent></Card>
|
||||||
|
) : (
|
||||||
|
<ReportSheet title="AR Aging" subtitle={`As of ${asOfLabel}`} companyName={companyName} logoUrl={logoUrl}>
|
||||||
|
{/* Summary + Distribution */}
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2 mb-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-wide font-semibold text-muted-foreground mb-2 text-center">Summary</p>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-y text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
<th className="px-3 py-1.5 text-left font-semibold">Charge</th>
|
||||||
|
<th className="px-3 py-1.5 text-right font-semibold">Balance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{report.summaryRows.map(([label, s]) => (
|
||||||
|
<tr key={label} className="border-b">
|
||||||
|
<td className="px-3 py-1.5">{label} ({s.count})</td>
|
||||||
|
<td className="px-3 py-1.5 text-right tabular-nums">{money(s.balance)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr className="font-bold border-b-2">
|
||||||
|
<td className="px-3 py-1.5 text-right">Total</td>
|
||||||
|
<td className="px-3 py-1.5 text-right tabular-nums">{money(report.grandTotal)}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-wide font-semibold text-muted-foreground mb-2 text-center">Distribution</p>
|
||||||
|
<div className="flex h-5 w-full overflow-hidden rounded">
|
||||||
|
{report.distribution.map((pct, i) => pct > 0 && (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{ width: `${pct}%`, backgroundColor: `rgb(${BUCKET_COLORS[i].join(",")})` }}
|
||||||
|
title={`${BUCKETS[i]}: ${pct.toFixed(2)}%`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-1">
|
||||||
|
{report.distribution.map((pct, i) => pct > 0 && (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="inline-block h-3 w-3 rounded-sm" style={{ backgroundColor: `rgb(${BUCKET_COLORS[i].join(",")})` }} />
|
||||||
|
{BUCKETS[i]}: {pct.toFixed(2)} %
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Property detail */}
|
||||||
|
<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">Property</th>
|
||||||
|
<th className="px-3 py-2 text-right font-semibold">0-30</th>
|
||||||
|
<th className="px-3 py-2 text-right font-semibold">Over 30</th>
|
||||||
|
<th className="px-3 py-2 text-right font-semibold">Over 60</th>
|
||||||
|
<th className="px-3 py-2 text-right font-semibold">Over 90</th>
|
||||||
|
<th className="px-3 py-2 text-right font-semibold">Balance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{report.rows.map((r) => (
|
||||||
|
<>
|
||||||
|
<tr key={r.key} className="border-b bg-muted/30">
|
||||||
|
<td className="px-3 py-1.5 font-semibold">
|
||||||
|
{r.label}
|
||||||
|
{r.collStatus && <div className="text-[11px] font-medium text-amber-700">Coll Status: {r.collStatus}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right tabular-nums font-semibold">{dash(r.buckets[0])}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right tabular-nums font-semibold">{dash(r.buckets[1])}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right tabular-nums font-semibold">{dash(r.buckets[2])}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right tabular-nums font-semibold">{dash(r.buckets[3])}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right tabular-nums font-semibold">{money(r.total)}</td>
|
||||||
|
</tr>
|
||||||
|
{[...r.byType.entries()].map(([label, t]) => (
|
||||||
|
<tr key={`${r.key}-${label}`} className="border-b">
|
||||||
|
<td className="px-3 py-1 pl-8 text-muted-foreground">{label}</td>
|
||||||
|
<td className="px-3 py-1 text-right tabular-nums">{dash(t.buckets[0])}</td>
|
||||||
|
<td className="px-3 py-1 text-right tabular-nums">{dash(t.buckets[1])}</td>
|
||||||
|
<td className="px-3 py-1 text-right tabular-nums">{dash(t.buckets[2])}</td>
|
||||||
|
<td className="px-3 py-1 text-right tabular-nums">{dash(t.buckets[3])}</td>
|
||||||
|
<td className="px-3 py-1 text-right tabular-nums">{money(t.total)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t-2 border-b font-bold">
|
||||||
|
<td className="px-3 py-2">Total:</td>
|
||||||
|
{report.totals.map((n, i) => <td key={i} className="px-3 py-2 text-right tabular-nums">{money(n)}</td>)}
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums">{money(report.grandTotal)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b-2 font-semibold text-muted-foreground">
|
||||||
|
<td className="px-3 py-2">Property Count:</td>
|
||||||
|
{report.counts.map((n, i) => <td key={i} className="px-3 py-2 text-right tabular-nums">{n}</td>)}
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</ReportSheet>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { accounting } from "@/lib/accountingClient";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { FileDown, Download } from "lucide-react";
|
||||||
|
import jsPDF from "jspdf";
|
||||||
|
import autoTable from "jspdf-autotable";
|
||||||
|
import { ReportSheet } from "./ReportSheet";
|
||||||
|
import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter } from "../lib/reportHeader";
|
||||||
|
import { num } from "../lib/ownerLedger";
|
||||||
|
import { fmtDate } from "../lib/format";
|
||||||
|
|
||||||
|
const TEAL: [number, number, number] = [0, 137, 123];
|
||||||
|
|
||||||
|
type GLAccount = { id: string; code: string | null; name: string; is_bank: boolean };
|
||||||
|
|
||||||
|
type GLLineRow = {
|
||||||
|
code: string | null;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
amount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Disbursement = {
|
||||||
|
jeId: string;
|
||||||
|
date: string;
|
||||||
|
checkNo: string;
|
||||||
|
description: string;
|
||||||
|
invoiceDate: string | null;
|
||||||
|
amount: number;
|
||||||
|
lines: GLLineRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type BankGroup = { bankLabel: string; entries: Disbursement[]; subtotal: number };
|
||||||
|
|
||||||
|
function monthStart() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-01`; }
|
||||||
|
function today() { return new Date().toISOString().slice(0, 10); }
|
||||||
|
|
||||||
|
const acctLabel = (a: GLAccount | undefined) => (a ? `${a.code ? a.code + " - " : ""}${a.name}` : "Unknown account");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buildium-style Cash Disbursement: every payment out of a bank account in the
|
||||||
|
* period, grouped by bank account, with the GL expense breakdown under each.
|
||||||
|
* Built from the GL (bank-account credit lines), so it works for both
|
||||||
|
* platform-managed and Buildium-imported companies. Platform entries are
|
||||||
|
* enriched with check #, vendor and bill info from the banking register.
|
||||||
|
*/
|
||||||
|
export function CashDisbursementReport({ companyId, companyName, logoUrl }: { companyId: string; companyName: string; logoUrl?: string | null }) {
|
||||||
|
const [from, setFrom] = useState(monthStart());
|
||||||
|
const [to, setTo] = useState(today());
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["cash-disbursement", companyId, from, to],
|
||||||
|
enabled: !!companyId,
|
||||||
|
queryFn: async () => {
|
||||||
|
const [accountsRes, linesRes] = await Promise.all([
|
||||||
|
accounting.from("accounts").select("id,code,name,is_bank").eq("company_id", companyId),
|
||||||
|
accounting
|
||||||
|
.from("journal_entry_lines")
|
||||||
|
.select("id,debit,credit,description,account_id,journal_entries!inner(id,company_id,date,description,reference,external_source,external_id)")
|
||||||
|
.eq("journal_entries.company_id", companyId)
|
||||||
|
.gte("journal_entries.date", from)
|
||||||
|
.lte("journal_entries.date", to)
|
||||||
|
.limit(50000),
|
||||||
|
]);
|
||||||
|
const accounts = (accountsRes.data ?? []) as GLAccount[];
|
||||||
|
const lines = (linesRes.data ?? []) as any[];
|
||||||
|
|
||||||
|
// Enrich register-posted entries with check # / vendor / bill from banking
|
||||||
|
const txnIds = [...new Set(lines
|
||||||
|
.filter((l) => l.journal_entries?.external_source === "acmacc_txn" && l.journal_entries?.external_id)
|
||||||
|
.map((l) => String(l.journal_entries.external_id)))];
|
||||||
|
let txns: any[] = [];
|
||||||
|
if (txnIds.length > 0) {
|
||||||
|
const { data: t } = await accounting
|
||||||
|
.from("transactions")
|
||||||
|
.select("id,reference,description,vendor_id,bill_id,vendors(name),bills(number,issue_date,vendors(name))")
|
||||||
|
.in("id", txnIds);
|
||||||
|
txns = t ?? [];
|
||||||
|
}
|
||||||
|
return { accounts, lines, txns };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = useMemo(() => {
|
||||||
|
if (!data) return null;
|
||||||
|
const acctById = new Map<string, GLAccount>();
|
||||||
|
for (const a of data.accounts) acctById.set(a.id, a);
|
||||||
|
const txnById = new Map<string, any>();
|
||||||
|
for (const t of data.txns) txnById.set(String(t.id), t);
|
||||||
|
|
||||||
|
// Group lines per journal entry
|
||||||
|
const byJe = new Map<string, { je: any; lines: any[] }>();
|
||||||
|
for (const l of data.lines) {
|
||||||
|
const je = l.journal_entries;
|
||||||
|
if (!je?.id) continue;
|
||||||
|
const g = byJe.get(je.id) ?? { je, lines: [] };
|
||||||
|
g.lines.push(l);
|
||||||
|
byJe.set(je.id, g);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = new Map<string, BankGroup>();
|
||||||
|
let grandTotal = 0;
|
||||||
|
|
||||||
|
for (const { je, lines } of byJe.values()) {
|
||||||
|
if (je.external_source === "acmacc_xfer") continue; // bank-to-bank transfers aren't disbursements
|
||||||
|
|
||||||
|
const bankCredits = lines.filter((l) => Number(l.credit || 0) > 0 && acctById.get(l.account_id)?.is_bank);
|
||||||
|
if (bankCredits.length === 0) continue;
|
||||||
|
|
||||||
|
const nonBankDebits = lines.filter((l) => Number(l.debit || 0) > 0 && !acctById.get(l.account_id)?.is_bank);
|
||||||
|
if (nonBankDebits.length === 0) continue; // pure transfer between banks
|
||||||
|
|
||||||
|
const amount = bankCredits.reduce((s, l) => s + Number(l.credit || 0), 0);
|
||||||
|
// Attribute the disbursement to the (largest) credited bank account
|
||||||
|
const mainBank = bankCredits.reduce((a, b) => (Number(b.credit) > Number(a.credit) ? b : a));
|
||||||
|
const bankLabel = acctLabel(acctById.get(mainBank.account_id));
|
||||||
|
|
||||||
|
const txn = je.external_source === "acmacc_txn" && je.external_id ? txnById.get(String(je.external_id)) : null;
|
||||||
|
const bill = txn?.bills ?? null;
|
||||||
|
const vendorName = bill?.vendors?.name || txn?.vendors?.name || null;
|
||||||
|
|
||||||
|
let checkNo = (txn?.reference || je.reference || "").toString().trim();
|
||||||
|
const descSource = `${txn?.description ?? ""} ${je.description ?? ""}`.toLowerCase();
|
||||||
|
if (!checkNo && /\bach\b|autopay|auto-pay|eft/.test(descSource)) checkNo = "ACH";
|
||||||
|
|
||||||
|
const description = vendorName
|
||||||
|
? `${vendorName}${bill?.number ? ` Inv # ${bill.number}` : ""}`
|
||||||
|
: (txn?.description || je.description || "—");
|
||||||
|
|
||||||
|
const entry: Disbursement = {
|
||||||
|
jeId: je.id,
|
||||||
|
date: String(je.date).slice(0, 10),
|
||||||
|
checkNo: checkNo || "—",
|
||||||
|
description,
|
||||||
|
invoiceDate: bill?.issue_date ? String(bill.issue_date).slice(0, 10) : null,
|
||||||
|
amount,
|
||||||
|
lines: nonBankDebits.map((l) => ({
|
||||||
|
code: acctById.get(l.account_id)?.code ?? null,
|
||||||
|
name: acctById.get(l.account_id)?.name ?? "Unknown account",
|
||||||
|
description: l.description ?? null,
|
||||||
|
amount: Number(l.debit || 0),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const g = groups.get(bankLabel) ?? { bankLabel, entries: [], subtotal: 0 };
|
||||||
|
g.entries.push(entry);
|
||||||
|
g.subtotal += amount;
|
||||||
|
groups.set(bankLabel, g);
|
||||||
|
grandTotal += amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = [...groups.values()].sort((a, b) => a.bankLabel.localeCompare(b.bankLabel));
|
||||||
|
for (const g of out) g.entries.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
return { groups: out, grandTotal };
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const rangeLabel = `${fmtDate(from)} – ${fmtDate(to)}`;
|
||||||
|
|
||||||
|
const exportPDF = async () => {
|
||||||
|
if (!report) return;
|
||||||
|
const doc = new jsPDF({ unit: "pt", format: "letter", orientation: "landscape" });
|
||||||
|
const ML = 40;
|
||||||
|
const logo = await loadBrandedLogo(logoUrl);
|
||||||
|
const startY = drawBrandedHeader(doc, {
|
||||||
|
logo, title: "Cash Disbursement", subtitle: rangeLabel,
|
||||||
|
metaLines: [{ label: "Properties:", value: companyName || "" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const body: any[] = [];
|
||||||
|
for (const g of report.groups) {
|
||||||
|
body.push([{
|
||||||
|
content: g.bankLabel,
|
||||||
|
colSpan: 5,
|
||||||
|
styles: { fontStyle: "bold", textColor: TEAL, fontSize: 10, fillColor: [255, 255, 255] },
|
||||||
|
}]);
|
||||||
|
for (const e of g.entries) {
|
||||||
|
body.push([
|
||||||
|
{ content: fmtDate(e.date), styles: { fontStyle: "bold" } },
|
||||||
|
{ content: e.checkNo, styles: { fontStyle: "bold" } },
|
||||||
|
{ content: e.description, styles: { fontStyle: "bold" } },
|
||||||
|
{ content: e.invoiceDate ? fmtDate(e.invoiceDate) : "", styles: { fontStyle: "bold", halign: "right" } },
|
||||||
|
{ content: num(e.amount), styles: { fontStyle: "bold", halign: "right" } },
|
||||||
|
]);
|
||||||
|
for (const l of e.lines) {
|
||||||
|
body.push([
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
{ content: ` ${l.code ? l.code + " - " : ""}${l.name}${l.description ? " - " + l.description : ""}`, styles: { textColor: [90, 90, 90] } },
|
||||||
|
"",
|
||||||
|
{ content: num(l.amount), styles: { halign: "right", textColor: [90, 90, 90] } },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body.push([
|
||||||
|
{ content: `Total ${g.bankLabel}`, colSpan: 4, styles: { fontStyle: "bold", halign: "right" } },
|
||||||
|
{ content: num(g.subtotal), styles: { fontStyle: "bold", halign: "right" } },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
body.push([
|
||||||
|
{ content: "Total Disbursements", colSpan: 4, styles: { fontStyle: "bold", halign: "right", fillColor: [237, 239, 242] } },
|
||||||
|
{ content: num(report.grandTotal), styles: { fontStyle: "bold", halign: "right", fillColor: [237, 239, 242] } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY,
|
||||||
|
head: [["Paid Date", "CheckNo", "Description", "Invoice Date", "Amount"]],
|
||||||
|
body,
|
||||||
|
styles: { fontSize: 8, cellPadding: 3 },
|
||||||
|
headStyles: { fillColor: TEAL, textColor: 255 },
|
||||||
|
columnStyles: { 0: { cellWidth: 70 }, 1: { cellWidth: 80 }, 3: { cellWidth: 80, halign: "right" }, 4: { cellWidth: 80, halign: "right" } },
|
||||||
|
margin: { left: ML, right: ML },
|
||||||
|
});
|
||||||
|
|
||||||
|
drawBrandedFooter(doc);
|
||||||
|
doc.save(`cash-disbursement-${from}-to-${to}.pdf`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportCSV = () => {
|
||||||
|
if (!report) return;
|
||||||
|
const q = (s: string) => `"${String(s).replace(/"/g, '""')}"`;
|
||||||
|
const f = (n: number) => n.toFixed(2);
|
||||||
|
const lines = [["Bank Account", "Paid Date", "CheckNo", "Description", "GL Account", "Invoice Date", "Amount"].join(",")];
|
||||||
|
for (const g of report.groups) {
|
||||||
|
for (const e of g.entries) {
|
||||||
|
lines.push([q(g.bankLabel), e.date, q(e.checkNo), q(e.description), "", e.invoiceDate ?? "", f(e.amount)].join(","));
|
||||||
|
for (const l of e.lines) {
|
||||||
|
lines.push([q(g.bankLabel), "", "", "", q(`${l.code ? l.code + " - " : ""}${l.name}${l.description ? " - " + l.description : ""}`), "", f(l.amount)].join(","));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push([q(g.bankLabel), "", "", q(`Total ${g.bankLabel}`), "", "", f(g.subtotal)].join(","));
|
||||||
|
}
|
||||||
|
lines.push(["", "", "", "Total Disbursements", "", "", f(report.grandTotal)].join(","));
|
||||||
|
const blob = new Blob([lines.join("\n")], { type: "text/csv" });
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = `cash-disbursement-${from}-to-${to}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasData = !!report && report.groups.length > 0;
|
||||||
|
|
||||||
|
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">From</Label>
|
||||||
|
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value || from)} className="w-44 mt-1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground">To</Label>
|
||||||
|
<Input type="date" value={to} onChange={(e) => setTo(e.target.value || to)} className="w-44 mt-1" />
|
||||||
|
</div>
|
||||||
|
{hasData && (
|
||||||
|
<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>
|
||||||
|
) : !hasData ? (
|
||||||
|
<Card><CardContent className="p-8 text-center text-sm text-muted-foreground">No disbursements in this period.</CardContent></Card>
|
||||||
|
) : (
|
||||||
|
<ReportSheet title="Cash Disbursement" subtitle={rangeLabel} 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 w-24">Paid Date</th>
|
||||||
|
<th className="px-3 py-2 text-left font-semibold w-28">CheckNo</th>
|
||||||
|
<th className="px-3 py-2 text-left font-semibold">Description</th>
|
||||||
|
<th className="px-3 py-2 text-right font-semibold w-28">Invoice Date</th>
|
||||||
|
<th className="px-3 py-2 text-right font-semibold w-28">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{report!.groups.map((g) => (
|
||||||
|
<>
|
||||||
|
<tr key={g.bankLabel}>
|
||||||
|
<td colSpan={5} className="px-3 pt-5 pb-2 text-[15px] font-semibold" style={{ color: `rgb(${TEAL.join(",")})` }}>
|
||||||
|
{g.bankLabel}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{g.entries.map((e) => (
|
||||||
|
<>
|
||||||
|
<tr key={e.jeId} className="border-t">
|
||||||
|
<td className="px-3 py-1.5 font-medium whitespace-nowrap">{fmtDate(e.date)}</td>
|
||||||
|
<td className="px-3 py-1.5 font-medium">{e.checkNo}</td>
|
||||||
|
<td className="px-3 py-1.5 font-medium">{e.description}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right whitespace-nowrap">{e.invoiceDate ? fmtDate(e.invoiceDate) : ""}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right tabular-nums font-medium">{num(e.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
{e.lines.map((l, li) => (
|
||||||
|
<tr key={`${e.jeId}-${li}`}>
|
||||||
|
<td />
|
||||||
|
<td />
|
||||||
|
<td className="px-3 py-0.5 pl-8 text-muted-foreground text-[13px]">
|
||||||
|
{l.code ? `${l.code} - ` : ""}{l.name}{l.description ? ` - ${l.description}` : ""}
|
||||||
|
</td>
|
||||||
|
<td />
|
||||||
|
<td className="px-3 py-0.5 text-right tabular-nums text-muted-foreground text-[13px]">{num(l.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
<tr className="border-t font-semibold">
|
||||||
|
<td colSpan={4} className="px-3 py-1.5 text-right">Total {g.bankLabel}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right tabular-nums">{num(g.subtotal)}</td>
|
||||||
|
</tr>
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t-2 border-b-2 font-bold">
|
||||||
|
<td colSpan={4} className="px-3 py-2 text-right">Total Disbursements</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums">{num(report!.grandTotal)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</ReportSheet>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { FileDown, Download } from "lucide-react";
|
||||||
|
import jsPDF from "jspdf";
|
||||||
|
import autoTable from "jspdf-autotable";
|
||||||
|
import { ReportSheet } from "./ReportSheet";
|
||||||
|
import { loadBrandedLogo, drawBrandedHeader, drawBrandedFooter } from "../lib/reportHeader";
|
||||||
|
import {
|
||||||
|
fetchAssociationId, fetchOwnerLedger, fetchUnitsAndOwners, num,
|
||||||
|
type UnitInfo, type OwnerInfo,
|
||||||
|
} from "../lib/ownerLedger";
|
||||||
|
|
||||||
|
const TEAL: [number, number, number] = [0, 137, 123];
|
||||||
|
|
||||||
|
type Row = { account: string; property: string; ownerName: string; credit: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buildium-style "Pre Paid Homeowners": every unit whose owner ledger nets to
|
||||||
|
* a credit (payments exceed charges) as of the chosen date, with the credit amount.
|
||||||
|
*/
|
||||||
|
export function PrepaidHomeownersReport({ companyId, companyName, logoUrl }: { companyId: string; companyName: string; logoUrl?: string | null }) {
|
||||||
|
const [asOf, setAsOf] = useState(() => new Date().toISOString().slice(0, 10));
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["prepaid-homeowners", companyId, asOf],
|
||||||
|
enabled: !!companyId,
|
||||||
|
queryFn: async () => {
|
||||||
|
const associationId = await fetchAssociationId(companyId);
|
||||||
|
if (!associationId) return null;
|
||||||
|
const [entries, { units, owners }] = await Promise.all([
|
||||||
|
fetchOwnerLedger(associationId, asOf),
|
||||||
|
fetchUnitsAndOwners(associationId),
|
||||||
|
]);
|
||||||
|
return { entries, units, owners };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { rows, total } = useMemo(() => {
|
||||||
|
if (!data) return { rows: [] as Row[], total: 0 };
|
||||||
|
const unitById = new Map<string, UnitInfo>();
|
||||||
|
for (const u of data.units) unitById.set(u.id, u);
|
||||||
|
const ownerById = new Map<string, OwnerInfo>();
|
||||||
|
for (const o of data.owners) ownerById.set(o.id, o);
|
||||||
|
const ownerByUnit = new Map<string, OwnerInfo>();
|
||||||
|
for (const o of data.owners) if (o.unit_id && !ownerByUnit.has(o.unit_id)) ownerByUnit.set(o.unit_id, o);
|
||||||
|
|
||||||
|
const bal = new Map<string, number>();
|
||||||
|
for (const e of data.entries) {
|
||||||
|
const key = e.unit_id ? `u:${e.unit_id}` : e.owner_id ? `o:${e.owner_id}` : null;
|
||||||
|
if (!key) continue;
|
||||||
|
bal.set(key, (bal.get(key) ?? 0) + e.debit - e.credit);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: Row[] = [];
|
||||||
|
for (const [key, b] of bal) {
|
||||||
|
if (b >= -0.004) continue; // only credit balances
|
||||||
|
const unitId = key.startsWith("u:") ? key.slice(2) : null;
|
||||||
|
const ownerId = key.startsWith("o:") ? key.slice(2) : null;
|
||||||
|
const unit = unitId ? unitById.get(unitId) : undefined;
|
||||||
|
const owner = (unitId ? ownerByUnit.get(unitId) : null) ?? (ownerId ? ownerById.get(ownerId) : null) ?? null;
|
||||||
|
const property = [unit?.address, unit?.unit_number && !unit?.address?.includes(unit.unit_number) ? unit.unit_number : null]
|
||||||
|
.filter(Boolean).join(" ") || (unit?.unit_number ? `Unit ${unit.unit_number}` : "—");
|
||||||
|
out.push({
|
||||||
|
account: unit?.account_number || unit?.unit_number || "—",
|
||||||
|
property,
|
||||||
|
ownerName: owner ? `${owner.first_name ?? ""} ${owner.last_name ?? ""}`.trim() || "—" : "—",
|
||||||
|
credit: -b,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.sort((a, b) => b.credit - a.credit);
|
||||||
|
return { rows: out, total: out.reduce((s, r) => s + r.credit, 0) };
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const asOfLabel = new Date(asOf + "T00:00:00").toLocaleDateString("en-US", { month: "numeric", day: "numeric", year: "numeric" });
|
||||||
|
|
||||||
|
const exportPDF = async () => {
|
||||||
|
const doc = new jsPDF({ unit: "pt", format: "letter" });
|
||||||
|
const ML = 40;
|
||||||
|
const logo = await loadBrandedLogo(logoUrl);
|
||||||
|
const startY = drawBrandedHeader(doc, {
|
||||||
|
logo, title: "Pre Paid Homeowners", subtitle: `For ${asOfLabel}`,
|
||||||
|
metaLines: [{ label: "Properties:", value: companyName || "" }],
|
||||||
|
});
|
||||||
|
autoTable(doc, {
|
||||||
|
startY,
|
||||||
|
head: [["Account", "Property", "Owner Name", "Credit Amount"]],
|
||||||
|
body: [
|
||||||
|
...rows.map((r) => [r.account, r.property, r.ownerName, num(r.credit)]),
|
||||||
|
[
|
||||||
|
{ content: "", styles: {} } as any,
|
||||||
|
{ content: "", styles: {} } as any,
|
||||||
|
{ content: "Total", styles: { fontStyle: "bold", halign: "right" } } as any,
|
||||||
|
{ content: num(total), styles: { fontStyle: "bold", halign: "right" } } as any,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
styles: { fontSize: 8.5, cellPadding: 4 },
|
||||||
|
headStyles: { fillColor: TEAL, textColor: 255 },
|
||||||
|
columnStyles: { 3: { halign: "right" } },
|
||||||
|
margin: { left: ML, right: ML },
|
||||||
|
});
|
||||||
|
drawBrandedFooter(doc);
|
||||||
|
doc.save(`prepaid-homeowners-${asOf}.pdf`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportCSV = () => {
|
||||||
|
const q = (s: string) => `"${s.replace(/"/g, '""')}"`;
|
||||||
|
const f = (n: number) => n.toFixed(2);
|
||||||
|
const lines = [["Account", "Property", "Owner Name", "Credit Amount"].join(",")];
|
||||||
|
for (const r of rows) lines.push([q(r.account), q(r.property), q(r.ownerName), f(r.credit)].join(","));
|
||||||
|
lines.push(["", "", "Total", f(total)].join(","));
|
||||||
|
const blob = new Blob([lines.join("\n")], { type: "text/csv" });
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = `prepaid-homeowners-${asOf}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
};
|
||||||
|
|
||||||
|
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">As of</Label>
|
||||||
|
<Input type="date" value={asOf} onChange={(e) => setAsOf(e.target.value || asOf)} className="w-44 mt-1" />
|
||||||
|
</div>
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<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>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<Card><CardContent className="p-8 text-center text-sm text-muted-foreground">No prepaid balances as of {asOfLabel}.</CardContent></Card>
|
||||||
|
) : (
|
||||||
|
<ReportSheet title="Pre Paid Homeowners" subtitle={`For ${asOfLabel}`} 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>
|
||||||
|
<th className="px-3 py-2 text-left font-semibold">Property</th>
|
||||||
|
<th className="px-3 py-2 text-left font-semibold">Owner Name</th>
|
||||||
|
<th className="px-3 py-2 text-right font-semibold">Credit Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<tr key={i} className={`border-b ${i % 2 ? "" : "bg-muted/20"}`}>
|
||||||
|
<td className="px-3 py-1.5 font-mono text-xs">{r.account}</td>
|
||||||
|
<td className="px-3 py-1.5">{r.property}</td>
|
||||||
|
<td className="px-3 py-1.5">{r.ownerName}</td>
|
||||||
|
<td className="px-3 py-1.5 text-right tabular-nums">{num(r.credit)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t-2 border-b-2 font-bold">
|
||||||
|
<td className="px-3 py-2" colSpan={3}>
|
||||||
|
<span className="float-right">Total</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums">{num(total)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</ReportSheet>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
import { accounting } from "@/lib/accountingClient";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared data access for the owner-ledger-based accounting reports
|
||||||
|
* (AR Aging by property, Pre-Paid Homeowners). The accounting dashboard is
|
||||||
|
* keyed by company; the owner ledger lives in the public schema keyed by
|
||||||
|
* association — companies.association_id bridges the two.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type OwnerLedgerEntry = {
|
||||||
|
unit_id: string | null;
|
||||||
|
owner_id: string | null;
|
||||||
|
date: string;
|
||||||
|
transaction_type: string | null;
|
||||||
|
debit: number;
|
||||||
|
credit: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UnitInfo = {
|
||||||
|
id: string;
|
||||||
|
unit_number: string | null;
|
||||||
|
address: string | null;
|
||||||
|
account_number: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OwnerInfo = {
|
||||||
|
id: string;
|
||||||
|
first_name: string | null;
|
||||||
|
last_name: string | null;
|
||||||
|
unit_id: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchAssociationId(companyId: string): Promise<string | null> {
|
||||||
|
const { data } = await accounting
|
||||||
|
.from("companies")
|
||||||
|
.select("association_id")
|
||||||
|
.eq("id", companyId)
|
||||||
|
.maybeSingle();
|
||||||
|
return (data as any)?.association_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All owner-ledger entries for an association up to (and including) asOf, paged past the 1000-row cap. */
|
||||||
|
export async function fetchOwnerLedger(associationId: string, asOf: string): Promise<OwnerLedgerEntry[]> {
|
||||||
|
const PAGE = 1000;
|
||||||
|
const out: OwnerLedgerEntry[] = [];
|
||||||
|
for (let offset = 0; ; offset += PAGE) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("owner_ledger_entries")
|
||||||
|
.select("unit_id, owner_id, date, transaction_type, debit, credit")
|
||||||
|
.eq("association_id", associationId)
|
||||||
|
.lte("date", asOf)
|
||||||
|
.order("id", { ascending: true })
|
||||||
|
.range(offset, offset + PAGE - 1);
|
||||||
|
if (error) throw error;
|
||||||
|
const rows = (data ?? []) as any[];
|
||||||
|
for (const r of rows) {
|
||||||
|
out.push({
|
||||||
|
unit_id: r.unit_id ?? null,
|
||||||
|
owner_id: r.owner_id ?? null,
|
||||||
|
date: String(r.date ?? "").slice(0, 10),
|
||||||
|
transaction_type: r.transaction_type ?? null,
|
||||||
|
debit: Number(r.debit || 0),
|
||||||
|
credit: Number(r.credit || 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (rows.length < PAGE) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUnitsAndOwners(associationId: string): Promise<{ units: UnitInfo[]; owners: OwnerInfo[] }> {
|
||||||
|
const [unitsRes, ownersRes] = await Promise.all([
|
||||||
|
supabase.from("units").select("id, unit_number, address, account_number").eq("association_id", associationId),
|
||||||
|
supabase.from("owners").select("id, first_name, last_name, unit_id").eq("association_id", associationId),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
units: (unitsRes.data ?? []) as UnitInfo[],
|
||||||
|
owners: (ownersRes.data ?? []) as OwnerInfo[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Buildium-style label for a unit row: "account# - address unit# - Owner last name". */
|
||||||
|
export function unitLabel(unit: UnitInfo | undefined, ownerLastName: string | null): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (unit?.account_number) parts.push(unit.account_number);
|
||||||
|
const addr = [unit?.address, unit?.unit_number && !unit?.address?.includes(unit.unit_number) ? unit.unit_number : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
if (addr) parts.push(addr);
|
||||||
|
else if (unit?.unit_number) parts.push(`Unit ${unit.unit_number}`);
|
||||||
|
if (ownerLastName) parts.push(ownerLastName);
|
||||||
|
return parts.join(" - ") || "Unknown property";
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHARGE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
assessment: "Assessments",
|
||||||
|
special_assessment: "Special Assessments",
|
||||||
|
interest: "Interest",
|
||||||
|
late_fee: "Late Fees",
|
||||||
|
admin_fee: "Admin Fees",
|
||||||
|
legal_fee: "Legal Fees",
|
||||||
|
violation: "Violation Fines",
|
||||||
|
bank_fee: "Bank Fees",
|
||||||
|
prepayment: "Prepayments",
|
||||||
|
charge: "Charges",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function chargeTypeLabel(t: string | null | undefined): string {
|
||||||
|
const key = String(t || "charge").toLowerCase();
|
||||||
|
if (CHARGE_TYPE_LABELS[key]) return CHARGE_TYPE_LABELS[key];
|
||||||
|
return key.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Always two decimals, thousands-separated, negatives in parentheses. */
|
||||||
|
export function num(n: number): string {
|
||||||
|
const v = Math.round((n + Number.EPSILON) * 100) / 100 || 0;
|
||||||
|
const abs = Math.abs(v).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
return v < 0 ? `(${abs})` : abs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function money(n: number): string {
|
||||||
|
const v = Math.round((n + Number.EPSILON) * 100) / 100 || 0;
|
||||||
|
const abs = Math.abs(v).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
return v < 0 ? `($${abs})` : `$${abs}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user