Added Save & PDF export
X-Lovable-Edit-ID: edt-2b9cdbd4-99f7-4c2a-bf23-59dd5bc3a3f3 Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -37,15 +37,19 @@ import {
|
||||
Calculator,
|
||||
ChevronRight,
|
||||
Download,
|
||||
FileDown,
|
||||
GitFork,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Trash2,
|
||||
Upload,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import jsPDF from "jspdf";
|
||||
import autoTable from "jspdf-autotable";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
BUCKETS,
|
||||
@@ -382,6 +386,7 @@ export function CollectionDetail({
|
||||
const [entries, setEntries] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Editable header fields
|
||||
const [ledgerName, setLedgerName] = useState(collection.name ?? "");
|
||||
@@ -581,12 +586,177 @@ export function CollectionDetail({
|
||||
a.click();
|
||||
};
|
||||
|
||||
const exportPdf = () => {
|
||||
const name = promptFilename(`Ledger_${ho?.last_name || "homeowner"}`, "pdf");
|
||||
if (!name) return;
|
||||
const doc = new jsPDF({ orientation: "landscape", unit: "pt", format: "letter" });
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
|
||||
// Header
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setFontSize(16);
|
||||
doc.text("Homeowner Account Ledger", 40, 40);
|
||||
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setFontSize(10);
|
||||
const headerLines = [
|
||||
`${ho?.first_name ?? ""} ${ho?.last_name ?? ""}`.trim(),
|
||||
ho?.unit_number ? `Unit ${ho.unit_number}` : null,
|
||||
ho?.address || null,
|
||||
ho?.email || null,
|
||||
ho?.phone || null,
|
||||
].filter(Boolean) as string[];
|
||||
headerLines.forEach((line, i) => doc.text(line, 40, 60 + i * 14));
|
||||
|
||||
const rightMeta = [
|
||||
`Ledger: ${collection.name || "—"}`,
|
||||
`Status: ${collection.status}`,
|
||||
`Opened: ${formatDate(collection.opened_at)}`,
|
||||
`Interest rate: ${effectiveRate || 0}% / yr`,
|
||||
`Generated: ${formatDate(new Date().toISOString().slice(0, 10))}`,
|
||||
];
|
||||
rightMeta.forEach((line, i) => doc.text(line, pageWidth - 40, 60 + i * 14, { align: "right" }));
|
||||
|
||||
const startY = 60 + Math.max(headerLines.length, rightMeta.length) * 14 + 10;
|
||||
|
||||
// Summary buckets
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setFontSize(11);
|
||||
doc.text(`Total Due: ${formatCurrency(Math.max(0, computed.total))}`, 40, startY);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setFontSize(9);
|
||||
const bucketLine = BUCKETS.map((b) => `${BUCKET_LABEL[b]}: ${formatCurrency(Math.max(0, computed[b]))}`).join(" ");
|
||||
doc.text(bucketLine, 40, startY + 14, { maxWidth: pageWidth - 80 });
|
||||
|
||||
// Table
|
||||
const body: any[] = [];
|
||||
if (opening !== 0) {
|
||||
body.push([
|
||||
formatDate(collection.opened_at),
|
||||
"Opening balance",
|
||||
"opening",
|
||||
opening > 0 ? opening.toFixed(2) : "0.00",
|
||||
"0.00", "0.00", "0.00", "0.00", "0.00", "0.00", "0.00",
|
||||
formatCurrency(opening),
|
||||
]);
|
||||
}
|
||||
withRunning.forEach((e: any) => {
|
||||
body.push([
|
||||
formatDate(e.entry_date),
|
||||
e.description || "—",
|
||||
e.account || e.transaction_type || "",
|
||||
num(e.assess) ? num(e.assess).toFixed(2) : "",
|
||||
num(e.late) ? num(e.late).toFixed(2) : "",
|
||||
num(e.admin) ? num(e.admin).toFixed(2) : "",
|
||||
num(e.legal) ? num(e.legal).toFixed(2) : "",
|
||||
num(e.viol) ? num(e.viol).toFixed(2) : "",
|
||||
num(e.interest) ? num(e.interest).toFixed(2) : "",
|
||||
num(e.bank) ? num(e.bank).toFixed(2) : "",
|
||||
num(e.payment) ? num(e.payment).toFixed(2) : "",
|
||||
formatCurrency(e.runningBalance ?? 0),
|
||||
]);
|
||||
});
|
||||
body.push([
|
||||
{ content: "Totals", colSpan: 3, styles: { halign: "right", fontStyle: "bold" } },
|
||||
totals.assess.toFixed(2),
|
||||
totals.late.toFixed(2),
|
||||
totals.admin.toFixed(2),
|
||||
totals.legal.toFixed(2),
|
||||
totals.viol.toFixed(2),
|
||||
totals.interest.toFixed(2),
|
||||
totals.bank.toFixed(2),
|
||||
totals.payment.toFixed(2),
|
||||
formatCurrency(computed.total),
|
||||
]);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: startY + 36,
|
||||
head: [["Date", "Description", "Account", "Assess", "Late", "Admin", "Legal", "Viol", "Int", "Bank", "Payment", "Balance"]],
|
||||
body,
|
||||
styles: { fontSize: 8, cellPadding: 3 },
|
||||
headStyles: { fillColor: [40, 40, 40], textColor: 255 },
|
||||
columnStyles: {
|
||||
0: { cellWidth: 60 },
|
||||
1: { cellWidth: "auto" },
|
||||
2: { cellWidth: 55 },
|
||||
3: { halign: "right", cellWidth: 50 },
|
||||
4: { halign: "right", cellWidth: 45 },
|
||||
5: { halign: "right", cellWidth: 50 },
|
||||
6: { halign: "right", cellWidth: 50 },
|
||||
7: { halign: "right", cellWidth: 45 },
|
||||
8: { halign: "right", cellWidth: 45 },
|
||||
9: { halign: "right", cellWidth: 50 },
|
||||
10: { halign: "right", cellWidth: 55 },
|
||||
11: { halign: "right", cellWidth: 65, fontStyle: "bold" },
|
||||
},
|
||||
didDrawPage: (data) => {
|
||||
const pageCount = doc.getNumberOfPages();
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(120);
|
||||
doc.text(
|
||||
`Page ${data.pageNumber} of ${pageCount}`,
|
||||
pageWidth - 40,
|
||||
doc.internal.pageSize.getHeight() - 20,
|
||||
{ align: "right" },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
doc.save(`${name}.pdf`);
|
||||
toast.success("PDF exported");
|
||||
};
|
||||
|
||||
// Force-save (re-persist all entries — useful if user wants a manual confirm)
|
||||
const saveAll = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
// Persist header fields
|
||||
await (supabase.from("collections") as any)
|
||||
.update({
|
||||
name: ledgerName || null,
|
||||
interest_rate_override: rateOverride === "" ? null : parseFloat(rateOverride),
|
||||
})
|
||||
.eq("id", collection.id);
|
||||
// Re-persist every row's current state
|
||||
const updates = entries.map((e: any) =>
|
||||
(supabase.from("collection_ledger_entries") as any)
|
||||
.update({
|
||||
entry_date: e.entry_date,
|
||||
description: e.description,
|
||||
account: e.account,
|
||||
transaction_type: e.transaction_type,
|
||||
assess: num(e.assess),
|
||||
late: num(e.late),
|
||||
admin: num(e.admin),
|
||||
legal: num(e.legal),
|
||||
viol: num(e.viol),
|
||||
interest: num(e.interest),
|
||||
bank: num(e.bank),
|
||||
payment: num(e.payment),
|
||||
sort_order: e.sort_order,
|
||||
})
|
||||
.eq("id", e.id),
|
||||
);
|
||||
const results = await Promise.all(updates);
|
||||
const failed = results.find((r: any) => r.error);
|
||||
if (failed) {
|
||||
toast.error("Some rows failed to save", { description: failed.error.message });
|
||||
} else {
|
||||
toast.success("Ledger saved");
|
||||
onChange();
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadFromUnitLedger = async () => {
|
||||
// Re-pull homeowner opening balance and any other future "saved ledger" data
|
||||
const { data, error } = await supabase
|
||||
.from("homeowners").select("opening_balance").eq("id", collection.homeowner_id).single();
|
||||
if (error) { toast.error(error.message); return; }
|
||||
toast.success(`Unit opening balance: ${formatCurrency(Number(data.opening_balance) || 0)}`);
|
||||
toast.success(`Reloaded — opening balance: ${formatCurrency(Number(data.opening_balance) || 0)}`);
|
||||
await load();
|
||||
onChange();
|
||||
};
|
||||
|
||||
@@ -689,13 +859,27 @@ export function CollectionDetail({
|
||||
<p className="text-[11px] text-muted-foreground mt-1">Interest is calculated on the Assessment balance.</p>
|
||||
</div>
|
||||
<div className="flex items-end justify-end gap-2 flex-wrap">
|
||||
<Button variant="outline" size="sm" onClick={exportStatement}><Download className="h-4 w-4 mr-1.5" /> Export Statement</Button>
|
||||
<Button variant="outline" size="sm" onClick={loadFromUnitLedger}><RefreshCw className="h-4 w-4 mr-1.5" /> Load from Unit</Button>
|
||||
<Button size="sm" onClick={saveAll} disabled={saving}>
|
||||
{saving ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : <Save className="h-4 w-4 mr-1.5" />}
|
||||
Save Ledger
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={exportPdf}>
|
||||
<FileDown className="h-4 w-4 mr-1.5" /> Export PDF
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={exportStatement}>
|
||||
<Download className="h-4 w-4 mr-1.5" /> Export CSV
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={loadFromUnitLedger}>
|
||||
<RefreshCw className="h-4 w-4 mr-1.5" /> Reload
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={syncAutomatedHistory}><RefreshCw className="h-4 w-4 mr-1.5" /> Sync Automated History</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}><Upload className="h-4 w-4 mr-1.5" /> Import CSV</Button>
|
||||
<p className="text-[11px] text-muted-foreground self-center ml-2">
|
||||
Edits auto-save when you click out of a cell. Use <span className="font-medium">Save Ledger</span> to force a full re-save.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user