Accounting statement matches main account-statement; fix payments cut off

- Homeowner "Export/Email Statement" now uses the same generateLedgerStatement
  layout as the main-app account statement (account holder, amounts-due
  breakdown, categorized columns incl. Pay (AR), no cover page) instead of the
  branded-cover table.
- Ledger view: default the "To" date to the latest entry when it's beyond
  today, so a payment dated when an invoice was marked paid (updated_at) is no
  longer filtered out of the rows / Total Paid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 22:17:04 -04:00
parent 0e34d18adf
commit 39829b7e1b
@@ -14,9 +14,7 @@ import { Badge } from "@/components/ui/badge";
import { ArrowLeft, Download, Mail, FileText, Pencil, Check, X } from "lucide-react"; import { ArrowLeft, Download, Mail, FileText, Pencil, Check, X } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { money, fmtDate } from "./lib/format"; import { money, fmtDate } from "./lib/format";
import jsPDF from "jspdf"; import { generateLedgerStatement } from "@/components/unit-profile/UnitLedgerStatementPDF";
import autoTable from "jspdf-autotable";
import { drawReportCoverPage } from "@/lib/reportCover";
function today() { function today() {
return new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }); return new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" });
@@ -47,6 +45,7 @@ export default function AccountingCustomerDetailPage() {
const [from, setFrom] = useState(""); const [from, setFrom] = useState("");
const [fromTouched, setFromTouched] = useState(false); const [fromTouched, setFromTouched] = useState(false);
const [to, setTo] = useState(today()); const [to, setTo] = useState(today());
const [toTouched, setToTouched] = useState(false);
const [drawer, setDrawer] = useState<LedgerRow | null>(null); const [drawer, setDrawer] = useState<LedgerRow | null>(null);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [editForm, setEditForm] = useState<any>(null); const [editForm, setEditForm] = useState<any>(null);
@@ -147,6 +146,14 @@ export default function AccountingCustomerDetailPage() {
if (!fromTouched && !from && allRows.length) setFrom(allRows[0].date); if (!fromTouched && !from && allRows.length) setFrom(allRows[0].date);
}, [allRows, fromTouched, from]); }, [allRows, fromTouched, from]);
// Extend "To" to the latest entry if it's beyond today, so recently-dated
// payments (e.g. an invoice marked paid today) aren't filtered out.
useEffect(() => {
if (toTouched || !allRows.length) return;
const latest = allRows[allRows.length - 1].date;
if (latest > to) setTo(latest);
}, [allRows, toTouched, to]);
const openingBalance = useMemo( const openingBalance = useMemo(
() => allRows.filter((r) => r.date < from).reduce((s, r) => s + r.debit - r.credit, 0), () => allRows.filter((r) => r.date < from).reduce((s, r) => s + r.debit - r.credit, 0),
[allRows, from] [allRows, from]
@@ -242,54 +249,33 @@ export default function AccountingCustomerDetailPage() {
qc.invalidateQueries({ queryKey: ["customers", cid] }); qc.invalidateQueries({ queryKey: ["customers", cid] });
}; };
const exportStatement = async () => { const exportStatement = () => {
if (!homeowner) return; if (!homeowner) return;
const doc = new jsPDF({ unit: "pt", format: "letter" }); if (!allRows.length) { toast.error("No ledger activity to export"); return; }
// Shared branded cover page (matches all other report exports), then detail. // Use the same account-statement layout as the main-app owner ledger
await drawReportCoverPage(doc, doc.internal.pageSize.getWidth(), doc.internal.pageSize.getHeight(), { // (account holder, amounts-due breakdown, categorized columns, no cover).
title: "Homeowner Statement", const entries = allRows.map((r) => ({
date: `${fmtDate(from)} ${fmtDate(to)}`, date: r.date,
companyName: associationName ?? "Association", debit: r.debit,
preparedBy: "Avria Community Management, LLC", credit: r.credit,
transaction_type: r.credit > 0 ? "payment" : (r.description || ""),
description: r.description,
}));
generateLedgerStatement({
unitData: {
unit_number: homeowner.unit_number,
address: homeowner.property_address,
account_number: homeowner.account_number,
},
owners: [{ is_primary: true, first_name: homeowner.name, last_name: "" }],
entries,
associationName: associationName ?? "Association",
}); });
doc.addPage();
doc.setFontSize(16);
doc.text(associationName ?? "Association", 40, 50);
doc.setFontSize(14);
doc.text("Homeowner Statement", 40, 100);
doc.setFontSize(10);
doc.text(homeowner.name, 40, 118);
if (homeowner.property_address) doc.text(`Property: ${homeowner.property_address}${homeowner.unit_number ? `, ${homeowner.unit_number}` : ""}`, 40, 132);
if (homeowner.lot_number) doc.text(`Lot: ${homeowner.lot_number}`, 40, 146);
doc.text(`Period: ${fmtDate(from)} ${fmtDate(to)}`, 40, homeowner.lot_number ? 162 : 146);
const startY = homeowner.lot_number ? 182 : 166;
autoTable(doc, {
startY,
head: [["Date", "Type", "Ref #", "Description", "Charges", "Payments", "Balance"]],
body: [
[fmtDate(from), "", "", "Opening Balance", "", "", money(openingBalance, cur)],
...withRunning.map((r) => [
fmtDate(r.date),
r.type,
r.ref,
r.description,
r.debit ? money(r.debit, cur) : "",
r.credit ? money(r.credit, cur) : "",
money(r.running, cur),
]),
],
foot: [["", "", "", "Current Balance Due", "", "", money(currentBalance, cur)]],
styles: { fontSize: 9 },
headStyles: { fillColor: [240, 240, 240], textColor: 20 },
footStyles: { fillColor: [240, 240, 240], textColor: 20, fontStyle: "bold" },
});
doc.save(`statement-${homeowner.name.replace(/\s+/g, "_")}.pdf`);
}; };
const emailStatement = async () => { const emailStatement = () => {
if (!homeowner) return; if (!homeowner) return;
await exportStatement(); exportStatement();
const subject = encodeURIComponent(`Statement from ${associationName ?? "us"}`); const subject = encodeURIComponent(`Statement from ${associationName ?? "us"}`);
const body = encodeURIComponent( const body = encodeURIComponent(
`Hello ${homeowner.name},\n\nPlease find attached your homeowner statement for the period ${fmtDate(from)} ${fmtDate(to)}.\nCurrent balance due: ${money(currentBalance, cur)}.\n\nThank you.` `Hello ${homeowner.name},\n\nPlease find attached your homeowner statement for the period ${fmtDate(from)} ${fmtDate(to)}.\nCurrent balance due: ${money(currentBalance, cur)}.\n\nThank you.`
@@ -494,7 +480,7 @@ export default function AccountingCustomerDetailPage() {
<Card> <Card>
<CardContent className="p-4 flex flex-wrap items-end gap-3"> <CardContent className="p-4 flex flex-wrap items-end gap-3">
<div><Label className="text-xs">From</Label><Input type="date" value={from} onChange={(e) => { setFromTouched(true); setFrom(e.target.value); }} className="w-40" /></div> <div><Label className="text-xs">From</Label><Input type="date" value={from} onChange={(e) => { setFromTouched(true); setFrom(e.target.value); }} className="w-40" /></div>
<div><Label className="text-xs">To</Label><Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="w-40" /></div> <div><Label className="text-xs">To</Label><Input type="date" value={to} onChange={(e) => { setToTouched(true); setTo(e.target.value); }} className="w-40" /></div>
<div className="ml-auto flex gap-2"> <div className="ml-auto flex gap-2">
<Button variant="outline" onClick={exportStatement}><Download className="h-4 w-4 mr-1" /> Export Statement</Button> <Button variant="outline" onClick={exportStatement}><Download className="h-4 w-4 mr-1" /> Export Statement</Button>
<Button variant="outline" onClick={emailStatement}><Mail className="h-4 w-4 mr-1" /> Email Statement</Button> <Button variant="outline" onClick={emailStatement}><Mail className="h-4 w-4 mr-1" /> Email Statement</Button>