Files
acmcc/src/pages/ReportGeneratorPage.tsx
T
admin 8360363a15 Unify financial report styling with branded cover page
Extract the general Report Generator's branded cover (cover image/band,
logo, title, prepared-for/by) into shared src/lib/reportCover.ts. Financial
reports now open with the same cover: platform AccountingReportsPage via new
renderReportPdfWithCover(), and the Zoho/Board financial reports
(zohoFinancialReportPdf generators). ReportGeneratorPage refactored to use
the shared module (removes duplicated cover code).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:49:34 -04:00

1030 lines
47 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from "react";
import { PDFDocument } from "pdf-lib";
import { supabase } from "@/integrations/supabase/client";
import { useToast } from "@/hooks/use-toast";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import {
ArrowRight, ArrowLeft, Download, FileText,
Layout, Eye, Printer, Loader2, CheckSquare, Plus, Trash2, GripVertical, Paperclip,
} from "lucide-react";
import { jsPDF } from "jspdf";
import autoTable from "jspdf-autotable";
import { drawReportCoverPage } from "@/lib/reportCover";
import { format, subMonths } from "date-fns";
import type { Tables } from "@/integrations/supabase/types";
import { ensureFontsLoaded } from "@/lib/googleFontsManager";
import { loadFontForPdf, registerFontWithJsPdf } from "@/lib/fontPdfLoader";
import {
DragDropContext,
Droppable,
Draggable,
type DropResult,
} from "@hello-pangea/dnd";
// ─── Strict-mode-safe Droppable wrapper ───
function StrictModeDroppable({ children, ...props }: React.ComponentProps<typeof Droppable>) {
const [enabled, setEnabled] = useState(false);
useEffect(() => {
const id = requestAnimationFrame(() => setEnabled(true));
return () => { cancelAnimationFrame(id); setEnabled(false); };
}, []);
if (!enabled) return null;
return <Droppable {...props}>{children}</Droppable>;
}
interface CoverData {
title: string;
date: string;
companyName: string;
preparedBy: string;
logoUrl: string;
bgUrl: string;
}
/** A single entry in the unified report order list */
interface ReportEntry {
id: string;
type: "module" | "attachment";
/** For module type: the DATA_MODULES key. For attachment: undefined */
moduleKey?: string;
label: string;
/** For attachment type: how many pages this attachment occupies */
pageCount: number;
enabled: boolean;
/** For attachment type: the uploaded PDF file */
file?: File;
/** AI-generated summary for attachment PDFs */
aiSummary?: string;
/** Whether AI summary is being generated */
summarizing?: boolean;
}
const DATA_MODULES = [
{ key: "statusUpdates", label: "Status Updates", table: "status_updates", dateCol: "created_at",
columns: ["Date", "Title", "Content"], fields: ["created_at", "title", "content"] },
{ key: "ownerUpdates", label: "Owner Updates", table: "owner_updates", dateCol: "posted_at",
columns: ["Date", "Property", "Title", "Content"], fields: ["posted_at", "_property", "title", "content"],
customSelect: "*, units(unit_number, address)" },
{ key: "violations", label: "Violations", table: "violations", dateCol: "created_at",
columns: ["Date", "Address", "Type", "Status", "Stage"], fields: ["created_at", "address", "violation_type", "status", "stage"] },
{ key: "collections", label: "Collections", table: "collections", dateCol: "updated_at",
columns: ["Property", "Owner", "Amount Owed", "Status", "Last Notice"], fields: ["_property", "_owner_name", "amount_owed", "status", "last_notice_date"],
noDateFilter: true, customSelect: "*, units(unit_number, address), owners(first_name, last_name)" },
{ key: "legalMatters", label: "Legal Cases", table: "legal_matters", dateCol: "created_at",
columns: ["Case #", "Title", "Category", "Stage", "Status"], fields: ["case_number", "title", "category", "stage", "status"], noDateFilter: true },
{ key: "arcApplications", label: "ARC Applications", table: "arc_applications", dateCol: "created_at",
columns: ["Date", "Title", "Type", "Status"], fields: ["created_at", "title", "project_type", "status"] },
{ key: "boardVotes", label: "Board Votes", table: "board_votes", dateCol: "created_at",
columns: ["Date", "Title", "Status"], fields: ["created_at", "title", "status"] },
{ key: "estoppels", label: "Estoppels", table: "estoppels", dateCol: "created_at",
columns: ["Date", "Address", "Status", "Notes"], fields: ["created_at", "address", "status", "notes"] },
{ key: "homeownerRequests", label: "Homeowner Requests", table: "homeowner_requests", dateCol: "created_at",
columns: ["Date", "Subject", "Status"], fields: ["created_at", "subject", "status"], noDateFilter: true },
{ key: "bidsQuotes", label: "Bids & Quotes", table: "bids_quotes", dateCol: "created_at",
columns: ["Date", "Vendor", "Amount", "Status"], fields: ["created_at", "vendor_name", "amount", "status"] },
{ key: "callLogs", label: "Call Logs", table: "call_logs", dateCol: "created_at",
columns: ["Date", "Caller", "Type", "Notes"], fields: ["created_at", "caller_name", "call_type", "notes"] },
] as const;
type ModuleKey = typeof DATA_MODULES[number]["key"];
export default function ReportGeneratorPage() {
const { toast } = useToast();
const [associations, setAssociations] = useState<Tables<"associations">[]>([]);
const [selectedAssociationId, setSelectedAssociationId] = useState("");
const [startDate, setStartDate] = useState(format(subMonths(new Date(), 1), "yyyy-MM-dd"));
const [endDate, setEndDate] = useState(format(new Date(), "yyyy-MM-dd"));
const [step, setStep] = useState(1);
const [isExporting, setIsExporting] = useState(false);
const [dataLoading, setDataLoading] = useState(false);
const [reportData, setReportData] = useState<Record<string, any[]>>({});
const previewRef = useRef<HTMLDivElement>(null);
const [coverData, setCoverData] = useState<CoverData>({
title: "MANAGEMENT\nREPORT",
date: format(new Date(), "MMMM yyyy"),
companyName: "",
preparedBy: "Avria Community Management, LLC",
logoUrl: "",
bgUrl: "",
});
// Unified ordered list of report entries
const [reportEntries, setReportEntries] = useState<ReportEntry[]>(() =>
DATA_MODULES.map((m) => ({
id: m.key,
type: "module" as const,
moduleKey: m.key,
label: m.label,
pageCount: 1,
enabled: true,
}))
);
// Attachment add form
const [newAttLabel, setNewAttLabel] = useState("");
const [newAttPages, setNewAttPages] = useState("1");
const [newAttFile, setNewAttFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
supabase.from("associations").select("*").eq("status", "active").order("name").then(({ data }) => {
setAssociations(data || []);
});
}, []);
useEffect(() => {
const assoc = associations.find((a) => a.id === selectedAssociationId);
if (assoc) {
setCoverData((prev) => ({ ...prev, companyName: assoc.name, logoUrl: assoc.logo_url || "" }));
}
}, [selectedAssociationId, associations]);
const fetchReportData = useCallback(async () => {
if (!selectedAssociationId) return;
setDataLoading(true);
const result: Record<string, any[]> = {};
const sd = startDate;
const ed = endDate + "T23:59:59";
const enabledModuleKeys = reportEntries.filter((e) => e.type === "module" && e.enabled).map((e) => e.moduleKey!);
const enabledList = DATA_MODULES.filter((m) => enabledModuleKeys.includes(m.key));
await Promise.all(
enabledList.map(async (mod) => {
try {
const selectStr = (mod as any).customSelect || "*";
let query = supabase
.from(mod.table as any)
.select(selectStr)
.eq("association_id", selectedAssociationId)
.order(mod.dateCol, { ascending: false });
if (!("noDateFilter" in mod && mod.noDateFilter)) {
query = query.gte(mod.dateCol, sd).lte(mod.dateCol, ed);
}
const { data } = await query;
if (mod.key === "collections" && data) {
result[mod.key] = data.map((row: any) => ({
...row,
_property: row.units?.address || row.units?.unit_number || "-",
_owner_name: row.owners ? `${row.owners.first_name || ""} ${row.owners.last_name || ""}`.trim() : "-",
}));
} else if (mod.key === "ownerUpdates" && data) {
result[mod.key] = data.map((row: any) => ({
...row,
_property: row.units?.address || row.units?.unit_number || "-",
}));
} else {
result[mod.key] = data || [];
}
} catch {
result[mod.key] = [];
}
})
);
setReportData(result);
setDataLoading(false);
}, [selectedAssociationId, startDate, endDate, reportEntries]);
const handleCoverChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setCoverData((prev) => ({ ...prev, [e.target.name]: e.target.value }));
};
const toggleEntry = (id: string) => {
setReportEntries((prev) => prev.map((e) => e.id === id ? { ...e, enabled: !e.enabled } : e));
};
const removeEntry = (id: string) => {
setReportEntries((prev) => prev.filter((e) => e.id !== id));
};
const addAttachment = () => {
if (!newAttLabel.trim()) return;
const pc = Math.max(1, parseInt(newAttPages) || 1);
const entryId = crypto.randomUUID();
const file = newAttFile || undefined;
setReportEntries((prev) => [
...prev,
{ id: entryId, type: "attachment", label: newAttLabel.trim(), pageCount: pc, enabled: true, file, summarizing: !!file },
]);
setNewAttLabel("");
setNewAttPages("1");
setNewAttFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
// Trigger AI summary generation in the background
if (file) {
generateAttachmentSummary(entryId, file, newAttLabel.trim());
}
};
const generateAttachmentSummary = async (entryId: string, file: File, label: string) => {
try {
// Upload temporarily to get a URL for the summarizer
const tempPath = `temp-report-summaries/${entryId}.pdf`;
const { error: uploadError } = await supabase.storage
.from("files")
.upload(tempPath, file, { upsert: true });
if (uploadError) throw uploadError;
const { data: urlData } = await supabase.storage
.from("files")
.createSignedUrl(tempPath, 300); // 5 min expiry
if (!urlData?.signedUrl) throw new Error("Failed to get signed URL");
const { data, error } = await supabase.functions.invoke("summarize-document", {
body: { documentId: entryId, fileUrl: urlData.signedUrl, title: label },
});
if (error) throw error;
setReportEntries((prev) =>
prev.map((e) => e.id === entryId ? { ...e, aiSummary: data?.summary || undefined, summarizing: false } : e)
);
// Clean up temp file
await supabase.storage.from("files").remove([tempPath]);
if (data?.summary) {
toast({ title: "AI Summary Generated", description: `Summary ready for "${label}"` });
}
} catch (err) {
console.error("Attachment summary failed:", err);
setReportEntries((prev) =>
prev.map((e) => e.id === entryId ? { ...e, summarizing: false } : e)
);
}
};
const handleAttachmentFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.type !== "application/pdf") {
toast({ variant: "destructive", title: "Invalid File", description: "Only PDF files are supported." });
e.target.value = "";
return;
}
setNewAttFile(file);
// Auto-detect page count
try {
const buffer = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(buffer);
setNewAttPages(String(pdfDoc.getPageCount()));
} catch {
// Keep manual page count if detection fails
}
// Auto-fill label from filename if empty
if (!newAttLabel.trim()) {
setNewAttLabel(file.name.replace(/\.pdf$/i, ""));
}
};
const onDragEnd = (result: DropResult) => {
if (!result.destination) return;
const items = Array.from(reportEntries);
const [moved] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, moved);
setReportEntries(items);
};
/** Compute page number for each enabled entry. Cover=1, TOC=2, content starts at 3. */
const computePageNumbers = useCallback((): Map<string, number> => {
const map = new Map<string, number>();
let page = 3; // cover=1, TOC=2
for (const entry of reportEntries) {
if (!entry.enabled) continue;
map.set(entry.id, page);
if (entry.type === "attachment") {
page += entry.pageCount;
} else {
const rows = reportData[entry.moduleKey!]?.length || 0;
page += Math.max(1, Math.ceil(rows / 30));
}
}
return map;
}, [reportEntries, reportData]);
const pageNumbers = computePageNumbers();
const handlePrint = () => {
if (!previewRef.current) return;
const printWindow = window.open("", "_blank");
if (!printWindow) return;
printWindow.document.write(`<html><head><title>${coverData.title.replace(/\n/g, " ")}</title><style>body{margin:0;font-family:sans-serif}table{border-collapse:collapse;width:100%}th,td{border:1px solid #e2e8f0;padding:6px 8px;font-size:10px;text-align:left}th{background:#1e293b;color:#fff}.print-page{page-break-after:always}</style></head><body>${previewRef.current.innerHTML}</body></html>`);
printWindow.document.close();
printWindow.focus();
printWindow.print();
printWindow.close();
};
const cleanText = (text: string) => {
if (!text) return "-";
return text.replace(/<[^>]*>/g, "").replace(/&nbsp;/g, " ").trim() || "-";
};
const formatDate = (d: string) => {
if (!d) return "-";
try { return format(new Date(d), "MM/dd/yyyy"); } catch { return "-"; }
};
const formatCurrency = (v: any) => {
const n = parseFloat(v);
if (isNaN(n)) return "-";
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n);
};
const getCellValue = (row: any, field: string) => {
const val = row[field];
if (val === null || val === undefined) return "-";
if (field.includes("date") || field === "created_at" || field === "updated_at" || field === "posted_at") return formatDate(val);
if (field === "amount" || field === "amount_due" || field === "amount_owed") return formatCurrency(val);
if (field === "content") return cleanText(String(val)).slice(0, 120);
return cleanText(String(val));
};
/** Only enabled entries that are modules with data, in order */
const getOrderedActiveModules = () => {
return reportEntries
.filter((e) => e.enabled && e.type === "module" && reportData[e.moduleKey!]?.length > 0)
.map((e) => ({ ...DATA_MODULES.find((m) => m.key === e.moduleKey)!, entryId: e.id }));
};
/** All enabled entries (modules with data + attachments) in order */
const getOrderedEnabledEntries = () => {
return reportEntries.filter((e) => {
if (!e.enabled) return false;
if (e.type === "attachment") return true;
return reportData[e.moduleKey!]?.length > 0;
});
};
// ── PDF Generation ──
const generatePDF = async () => {
setIsExporting(true);
toast({ title: "Generating PDF…", description: "Please wait." });
try {
await ensureFontsLoaded(["Open Sans"]);
const doc = new jsPDF({ orientation: "portrait", unit: "pt", format: "letter" });
const openSansRegular = await loadFontForPdf("Open Sans", "Regular");
const openSansBold = await loadFontForPdf("Open Sans", "Bold");
registerFontWithJsPdf(doc, "Open Sans", openSansRegular, "normal");
registerFontWithJsPdf(doc, "Open Sans", openSansBold, "bold");
const W = doc.internal.pageSize.getWidth();
const H = doc.internal.pageSize.getHeight();
const M = 40;
const cx = W / 2;
const pdfFont = openSansRegular ? "Open Sans" : "helvetica";
const drawPageFooter = (pageNum: number) => {
doc.setFont(pdfFont, "normal");
doc.setFontSize(8);
doc.setTextColor(148, 163, 184);
doc.text(`${coverData.companyName}${coverData.date}`, M, H - 20);
doc.text(`Page ${pageNum}`, W - M, H - 20, { align: "right" });
doc.setDrawColor(226, 232, 240);
doc.setLineWidth(0.5);
doc.line(M, H - 30, W - M, H - 30);
};
// ═══ COVER PAGE ═══
await drawReportCoverPage(doc, W, H, coverData);
const orderedEntries = getOrderedEnabledEntries();
// ═══ TABLE OF CONTENTS ═══
if (orderedEntries.length > 0) {
doc.addPage();
let pageNum = 2;
drawPageFooter(pageNum);
let y = 60;
doc.setFillColor(30, 41, 59);
doc.rect(M, y - 20, 4, 28, "F");
doc.setFont("helvetica", "bold");
doc.setFontSize(24);
doc.setTextColor(15, 23, 42);
doc.text("TABLE OF CONTENTS", M + 16, y);
y += 12;
doc.setDrawColor(30, 41, 59);
doc.setLineWidth(2);
doc.line(M, y, W - M, y);
y += 8;
doc.setDrawColor(226, 232, 240);
doc.setLineWidth(0.5);
doc.line(M, y, W - M, y);
y += 30;
orderedEntries.forEach((entry, idx) => {
if (y > H - 80) {
doc.addPage(); pageNum++; drawPageFooter(pageNum); y = 60;
}
const sectionNum = String(idx + 1).padStart(2, "0");
const pg = String(pageNumbers.get(entry.id) || "");
const isAtt = entry.type === "attachment";
doc.setFont("helvetica", "bold");
doc.setFontSize(11);
doc.setTextColor(30, 41, 59);
doc.text(sectionNum, M + 4, y);
doc.setFont("helvetica", "normal");
doc.setFontSize(12);
doc.setTextColor(51, 65, 85);
doc.text(entry.label, M + 36, y);
const titleEndX = M + 36 + doc.getTextWidth(entry.label) + 8;
const pageNumX = W - M - doc.getTextWidth(pg) - 4;
doc.setFontSize(10);
doc.setTextColor(203, 213, 225);
let dotX = titleEndX;
while (dotX < pageNumX - 4) { doc.text(".", dotX, y); dotX += 5; }
doc.setFont("helvetica", "bold");
doc.setFontSize(12);
doc.setTextColor(30, 41, 59);
doc.text(pg, W - M, y, { align: "right" });
if (!isAtt && entry.moduleKey) {
doc.setFont("helvetica", "normal");
doc.setFontSize(8);
doc.setTextColor(148, 163, 184);
doc.text(`${reportData[entry.moduleKey]?.length || 0} records`, M + 36, y + 12);
} else {
doc.setFont("helvetica", "normal");
doc.setFontSize(8);
doc.setTextColor(148, 163, 184);
doc.text(`${entry.pageCount} page${entry.pageCount > 1 ? "s" : ""} (attachment)`, M + 36, y + 12);
}
y += 32;
doc.setDrawColor(241, 245, 249);
doc.setLineWidth(0.5);
doc.line(M + 36, y - 10, W - M, y - 10);
});
}
// ═══ DATA SECTION PAGES ═══
const tableTheme = {
headStyles: { fillColor: [30, 41, 59] as [number, number, number], textColor: 255, fontStyle: "bold" as const, fontSize: 9, cellPadding: 6 },
bodyStyles: { textColor: [51, 65, 85] as [number, number, number], fontSize: 8, cellPadding: 5 },
alternateRowStyles: { fillColor: [248, 250, 252] as [number, number, number] },
styles: { lineColor: [226, 232, 240] as [number, number, number], lineWidth: 0.5, overflow: "linebreak" as const },
margin: { top: 70, right: M, bottom: 50, left: M },
};
// Track which attachment files need to be merged and at which page position
const attachmentMergeQueue: { file: File; afterPage: number }[] = [];
let sectionIdx = 0;
for (const entry of orderedEntries) {
sectionIdx++;
if (entry.type === "attachment") {
if (entry.file) {
// Add a section title page, then we'll merge actual PDF pages after jsPDF generation
doc.addPage();
let y = 50;
doc.setFillColor(30, 41, 59);
doc.roundedRect(M, y - 16, 36, 36, 4, 4, "F");
doc.setFont("helvetica", "bold");
doc.setFontSize(18);
doc.setTextColor(255, 255, 255);
doc.text(`${sectionIdx}`, M + 18, y + 6, { align: "center" });
doc.setFontSize(20);
doc.setTextColor(15, 23, 42);
doc.text(entry.label, M + 48, y + 4);
doc.setFontSize(10);
doc.setTextColor(148, 163, 184);
doc.text(`${entry.pageCount} page${entry.pageCount > 1 ? "s" : ""}`, W - M, y + 4, { align: "right" });
y += 20;
doc.setDrawColor(226, 232, 240);
doc.setLineWidth(1);
doc.line(M, y, W - M, y);
// Render AI summary if available
if (entry.aiSummary) {
y += 24;
doc.setFont("helvetica", "bold");
doc.setFontSize(11);
doc.setTextColor(30, 41, 59);
doc.text("✦ Executive Summary", M, y);
y += 16;
doc.setFont("helvetica", "normal");
doc.setFontSize(10);
doc.setTextColor(71, 85, 105);
const summaryLines = doc.splitTextToSize(entry.aiSummary, W - M * 2);
doc.text(summaryLines, M, y);
y += summaryLines.length * 14 + 8;
doc.setDrawColor(226, 232, 240);
doc.setLineWidth(0.5);
doc.line(M, y, W - M, y);
}
drawPageFooter((doc as any).internal.getNumberOfPages());
attachmentMergeQueue.push({
file: entry.file,
afterPage: (doc as any).internal.getNumberOfPages(),
});
} else {
// No file — add blank placeholder pages
for (let p = 0; p < entry.pageCount; p++) {
doc.addPage();
const pn = (doc as any).internal.getNumberOfPages();
if (p === 0) {
let y = 50;
doc.setFillColor(30, 41, 59);
doc.roundedRect(M, y - 16, 36, 36, 4, 4, "F");
doc.setFont("helvetica", "bold");
doc.setFontSize(18);
doc.setTextColor(255, 255, 255);
doc.text(`${sectionIdx}`, M + 18, y + 6, { align: "center" });
doc.setFontSize(20);
doc.setTextColor(15, 23, 42);
doc.text(entry.label, M + 48, y + 4);
doc.setFontSize(10);
doc.setTextColor(148, 163, 184);
doc.text("(attachment placeholder)", W - M, y + 4, { align: "right" });
y += 20;
doc.setDrawColor(226, 232, 240);
doc.setLineWidth(1);
doc.line(M, y, W - M, y);
}
drawPageFooter(pn);
}
}
continue;
}
const mod = DATA_MODULES.find((m) => m.key === entry.moduleKey)!;
doc.addPage();
let y = 50;
doc.setFillColor(30, 41, 59);
doc.roundedRect(M, y - 16, 36, 36, 4, 4, "F");
doc.setFont("helvetica", "bold");
doc.setFontSize(18);
doc.setTextColor(255, 255, 255);
doc.text(`${sectionIdx}`, M + 18, y + 6, { align: "center" });
doc.setFontSize(20);
doc.setTextColor(15, 23, 42);
doc.text(mod.label, M + 48, y + 4);
doc.setFontSize(10);
doc.setTextColor(148, 163, 184);
doc.text(`${reportData[mod.key].length} records`, W - M, y + 4, { align: "right" });
y += 20;
doc.setDrawColor(226, 232, 240);
doc.setLineWidth(1);
doc.line(M, y, W - M, y);
const rows = reportData[mod.key].map((row: any) => mod.fields.map((f) => getCellValue(row, f)));
autoTable(doc, {
startY: y + 10,
head: [mod.columns as unknown as string[]],
body: rows,
...tableTheme,
didDrawPage: () => {
const currentPage = (doc as any).internal.getNumberOfPages();
drawPageFooter(currentPage);
},
});
}
// ═══ MERGE ATTACHMENT PDFs ═══
if (attachmentMergeQueue.length > 0) {
const jsPdfBytes = doc.output("arraybuffer");
const jsPdfDoc = await PDFDocument.load(jsPdfBytes);
// Create a brand new document and copy pages to avoid font corruption
const mergedPdf = await PDFDocument.create();
// Copy ALL jsPDF pages first
const allJsPdfPages = await mergedPdf.copyPages(jsPdfDoc, jsPdfDoc.getPageIndices());
allJsPdfPages.forEach((page) => mergedPdf.addPage(page));
// Now insert attachment file pages at the correct positions
// Process in reverse order so page indices stay correct
for (let i = attachmentMergeQueue.length - 1; i >= 0; i--) {
const { file, afterPage } = attachmentMergeQueue[i];
try {
const fileBuffer = await file.arrayBuffer();
const attachPdf = await PDFDocument.load(fileBuffer);
const copiedPages = await mergedPdf.copyPages(attachPdf, attachPdf.getPageIndices());
// afterPage is 1-indexed, pdf-lib insertPage is 0-indexed
copiedPages.forEach((page, idx) => {
mergedPdf.insertPage(afterPage + idx, page);
});
} catch (e) {
console.error(`Failed to merge attachment: ${file.name}`, e);
}
}
const finalBytes = await mergedPdf.save();
const blob = new Blob([new Uint8Array(finalBytes)], { type: "application/pdf" });
const { saveFile } = await import("@/lib/saveFile");
await saveFile(blob, {
suggestedName: `Management_Report_${format(new Date(), "yyyy-MM-dd")}.pdf`,
mimeType: "application/pdf",
description: "PDF Document",
});
} else {
doc.save(`Management_Report_${format(new Date(), "yyyy-MM-dd")}.pdf`);
}
toast({ title: "Success", description: "Report downloaded." });
} catch (err) {
console.error(err);
toast({ variant: "destructive", title: "Export Failed", description: String(err) });
} finally {
setIsExporting(false);
}
};
const goToStep = (newStep: number) => {
if (newStep === 2) fetchReportData();
setStep(newStep);
};
const PREVIEW_SCALE = 0.26;
const ReportCoverFrame = ({ rootRef }: { rootRef?: React.RefObject<HTMLDivElement> }) => (
<div
ref={rootRef}
className="relative flex flex-col overflow-hidden print-page shadow-2xl"
style={{ width: "210mm", height: "297mm", background: "#ffffff", fontFamily: "'Open Sans', sans-serif" }}
>
<div className="w-full relative overflow-hidden shrink-0" style={{ height: "48%" }}>
{coverData.bgUrl ? (
<>
<img src={coverData.bgUrl} alt="Cover" className="w-full h-full object-cover object-center" crossOrigin="anonymous" />
</>
) : (
<div className="w-full h-full" style={{ background: "#1e293b" }} />
)}
</div>
<div className="w-full shrink-0" style={{ height: "4px", background: "#2563eb", marginTop: "0px" }} />
<div className="flex-1 flex flex-col items-center pb-12 px-16 text-center" style={{ paddingTop: "20px" }}>
{coverData.logoUrl && (
<div className="mb-6 h-20 flex items-center justify-center">
<img src={coverData.logoUrl} alt="Logo" className="max-h-16 object-contain" crossOrigin="anonymous" />
</div>
)}
<h1 className="text-[44px] leading-tight font-black uppercase tracking-wide whitespace-pre-wrap" style={{ color: "#0f172a", marginTop: "20px", marginBottom: "16px" }}>{coverData.title}</h1>
<div className="h-1 w-20 rounded-full mb-6" style={{ background: "#2563eb" }} />
<div className="flex flex-col items-center mb-6 flex-1 justify-center">
<span className="text-xs font-bold uppercase mb-3" style={{ color: "#64748b", letterSpacing: "0.25em" }}>Prepared For</span>
<h2 className="text-[28px] leading-snug font-bold max-w-2xl" style={{ color: "#1e293b" }}>{coverData.companyName || "Association Name"}</h2>
</div>
<div className="mb-8">
<div className="inline-flex items-center justify-center px-8 py-3 rounded" style={{ background: "#f8fafc", border: "1px solid #e2e8f0" }}>
<p className="text-[14px] font-bold uppercase" style={{ color: "#0f172a", letterSpacing: "0.15em" }}>{coverData.date}</p>
</div>
</div>
<div className="mt-auto w-full pt-6" style={{ borderTop: "1px solid #e2e8f0" }}>
<p className="text-[10px] font-bold uppercase mb-1" style={{ color: "#64748b", letterSpacing: "0.2em" }}>Prepared By</p>
<p className="text-[14px] font-bold" style={{ color: "#1e293b" }}>{coverData.preparedBy}</p>
</div>
</div>
</div>
);
const ReportCover = ({ isPreview = false, rootRef }: { isPreview?: boolean; rootRef?: React.RefObject<HTMLDivElement> }) => {
if (!isPreview) {
return <ReportCoverFrame rootRef={rootRef} />;
}
return (
<div className="w-full flex justify-center">
<div
className="relative"
style={{
width: `calc(210mm * ${PREVIEW_SCALE})`,
height: `calc(297mm * ${PREVIEW_SCALE})`,
}}
>
<div className="absolute left-0 top-0 origin-top-left" style={{ transform: `scale(${PREVIEW_SCALE})` }}>
<ReportCoverFrame />
</div>
</div>
</div>
);
};
const steps2UI = [
{ num: 1, label: "Cover & Modules", icon: Layout },
{ num: 2, label: "Preview & Export", icon: Eye },
];
const enabledCount = reportEntries.filter((e) => e.enabled).length;
return (
<div className="p-4 md:p-8 max-w-6xl mx-auto space-y-6 pb-20">
<Card>
<CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 items-end">
<div className="space-y-2">
<Label>Association</Label>
<Select value={selectedAssociationId} onValueChange={setSelectedAssociationId}>
<SelectTrigger><SelectValue placeholder="Select Association" /></SelectTrigger>
<SelectContent>
{associations.map((a) => (
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Start Date</Label>
<Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
</div>
<div className="space-y-2">
<Label>End Date</Label>
<Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
</div>
</div>
</CardContent>
</Card>
<Card className="p-6">
<h1 className="text-2xl font-bold text-foreground mb-2">Report Builder</h1>
<p className="text-muted-foreground mb-8">Drag to reorder sections. Page numbers auto-update. Add attachments for external documents.</p>
<div className="flex items-center justify-between relative max-w-xs mx-auto">
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-full h-1 bg-muted rounded-full z-0" />
<div className="absolute left-0 top-1/2 -translate-y-1/2 h-1 bg-primary rounded-full z-0 transition-all duration-500" style={{ width: `${((step - 1) / (steps2UI.length - 1)) * 100}%` }} />
{steps2UI.map((s) => (
<div key={s.num} className="relative z-10 flex flex-col items-center gap-2">
<button
onClick={() => goToStep(s.num)}
className={`w-12 h-12 rounded-full flex items-center justify-center font-bold text-sm transition-all shadow-sm ${
step >= s.num ? "bg-primary text-primary-foreground ring-4 ring-primary/10" : "bg-background text-muted-foreground border-2 hover:border-primary/50"
}`}
>
<s.icon className="w-5 h-5" />
</button>
<span className={`text-xs font-semibold ${step >= s.num ? "text-primary" : "text-muted-foreground"}`}>{s.label}</span>
</div>
))}
</div>
</Card>
<div className="min-h-[500px]">
{step === 1 && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="space-y-6">
{/* Cover Page */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Layout className="w-5 h-5 text-primary" />Cover Page</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<Label>Report Title</Label>
<Textarea name="title" value={coverData.title} onChange={handleCoverChange} rows={2} className="font-semibold" />
</div>
{([["companyName", "Association Name"], ["date", "Report Date"], ["preparedBy", "Prepared By"], ["logoUrl", "Logo URL"], ["bgUrl", "Background Image URL"]] as const).map(([name, label]) => (
<div key={name} className="space-y-1">
<Label>{label}</Label>
<Input name={name} value={coverData[name]} onChange={handleCoverChange} />
</div>
))}
</CardContent>
</Card>
{/* Unified Draggable Report Sections */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="flex items-center gap-2"><CheckSquare className="w-5 h-5 text-primary" />Report Sections</CardTitle>
<span className="text-xs text-muted-foreground">{enabledCount} enabled</span>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">Drag to reorder. Page numbers auto-calculate (Cover=1, TOC=2, content starts at 3).</p>
<DragDropContext onDragEnd={onDragEnd}>
<StrictModeDroppable droppableId="report-entries">
{(provided) => (
<div ref={provided.innerRef} {...provided.droppableProps} className="space-y-1">
{reportEntries.map((entry, index) => (
<Draggable key={entry.id} draggableId={entry.id} index={index}>
{(dragProvided, snapshot) => (
<div
ref={dragProvided.innerRef}
{...dragProvided.draggableProps}
className={`flex items-center gap-2 p-2.5 rounded-lg border transition-all ${
snapshot.isDragging ? "shadow-lg border-primary/50 bg-background" :
entry.enabled ? "bg-primary/5 border-primary/20" : "bg-muted/30 border-transparent"
}`}
>
<div {...dragProvided.dragHandleProps} className="cursor-grab active:cursor-grabbing shrink-0">
<GripVertical className="w-4 h-4 text-muted-foreground" />
</div>
<Checkbox checked={entry.enabled} onCheckedChange={() => toggleEntry(entry.id)} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
{entry.type === "attachment" && <Paperclip className="w-3 h-3 text-muted-foreground shrink-0" />}
<span className="text-sm font-medium text-foreground truncate">{entry.label}</span>
</div>
{entry.type === "attachment" && (
<div>
<span className="text-[10px] text-muted-foreground">
{entry.file ? `📎 ${entry.file.name}` : `${entry.pageCount} page${entry.pageCount > 1 ? "s" : ""} (no file)`}
</span>
{entry.summarizing && (
<span className="text-[10px] text-primary flex items-center gap-1 mt-0.5">
<Loader2 className="w-3 h-3 animate-spin" /> Generating AI summary
</span>
)}
{entry.aiSummary && !entry.summarizing && (
<p className="text-[10px] text-muted-foreground mt-0.5 line-clamp-2 italic"> {entry.aiSummary}</p>
)}
</div>
)}
</div>
<span className="text-xs font-mono text-muted-foreground w-8 text-right shrink-0">
{entry.enabled ? `p.${pageNumbers.get(entry.id) || "?"}` : "—"}
</span>
{entry.type === "attachment" && (
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" onClick={() => removeEntry(entry.id)}>
<Trash2 className="w-3 h-3 text-destructive" />
</Button>
)}
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</StrictModeDroppable>
</DragDropContext>
{/* Add Attachment */}
<div className="border-t pt-4">
<Label className="text-xs font-semibold text-muted-foreground mb-2 flex items-center gap-1.5">
<Paperclip className="w-3.5 h-3.5" /> Add Attachment Section
</Label>
<div className="space-y-2 mt-2">
<div className="space-y-1">
<Label className="text-xs">PDF File</Label>
<Input ref={fileInputRef} type="file" accept=".pdf,application/pdf" onChange={handleAttachmentFileChange} className="text-xs" />
</div>
<div className="flex gap-2 items-end">
<div className="flex-1 space-y-1">
<Label className="text-xs">Title</Label>
<Input placeholder="e.g. Financial Summary" value={newAttLabel} onChange={(e) => setNewAttLabel(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addAttachment()} />
</div>
<div className="w-20 space-y-1">
<Label className="text-xs">Pages</Label>
<Input type="number" min="1" value={newAttPages} onChange={(e) => setNewAttPages(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addAttachment()} />
</div>
<Button size="icon" variant="outline" onClick={addAttachment} className="shrink-0"><Plus className="w-4 h-4" /></Button>
</div>
{newAttFile && <p className="text-[10px] text-muted-foreground">📎 {newAttFile.name}</p>}
</div>
</div>
</CardContent>
</Card>
</div>
<div className="hidden lg:flex items-start justify-center bg-muted/30 rounded-xl border shadow-inner overflow-hidden h-[800px] p-8">
<div className="w-full flex justify-center pt-2">
<ReportCover isPreview />
</div>
</div>
</div>
)}
{step === 2 && (
<div className="flex flex-col items-center space-y-4">
<div className="w-full flex justify-between items-center">
<div className="text-sm text-muted-foreground">
{dataLoading ? (
<span className="flex items-center gap-2"><Loader2 className="w-4 h-4 animate-spin" /> Loading data</span>
) : (
<span>{getOrderedEnabledEntries().length} sections</span>
)}
</div>
<div className="flex gap-3">
<Button variant="outline" onClick={handlePrint}><Printer className="w-4 h-4 mr-2" /> Print</Button>
<Button onClick={generatePDF} disabled={isExporting || dataLoading}>
{isExporting ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" />Generating</> : <><Download className="w-4 h-4 mr-2" /> Export PDF</>}
</Button>
</div>
</div>
<div className="bg-muted/30 p-6 w-full overflow-x-auto rounded-xl shadow-inner">
<div ref={previewRef} className="max-w-4xl mx-auto space-y-8">
{dataLoading ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
) : getOrderedEnabledEntries().length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-center">
<FileText className="w-12 h-12 text-muted-foreground/40 mb-3" />
<h3 className="text-lg font-semibold text-foreground">No Data Found</h3>
<p className="text-sm text-muted-foreground">No records found for the selected modules and date range.</p>
</div>
) : (
(() => {
let sectionNum = 0;
return getOrderedEnabledEntries().map((entry) => {
sectionNum++;
if (entry.type === "attachment") {
return (
<Card key={entry.id}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-3 text-lg">
<span className="w-8 h-8 bg-muted text-muted-foreground rounded-lg flex items-center justify-center text-sm font-bold">{sectionNum}</span>
<Paperclip className="w-4 h-4 text-muted-foreground" />
{entry.label}
<span className="ml-auto text-sm font-normal text-muted-foreground">{entry.pageCount} page{entry.pageCount > 1 ? "s" : ""} (attachment)</span>
</CardTitle>
</CardHeader>
<CardContent>
{entry.aiSummary && (
<div className="mb-3 p-3 bg-primary/5 rounded-lg border border-primary/10">
<p className="text-xs font-semibold text-foreground mb-1"> Executive Summary</p>
<p className="text-sm text-muted-foreground italic">{entry.aiSummary}</p>
</div>
)}
{entry.summarizing && (
<div className="mb-3 flex items-center gap-2 text-sm text-primary">
<Loader2 className="w-4 h-4 animate-spin" /> Generating AI summary
</div>
)}
<p className="text-sm text-muted-foreground italic">This section represents an external attachment. Placeholder pages will be included in the exported PDF at page {pageNumbers.get(entry.id) || "?"}.</p>
</CardContent>
</Card>
);
}
const mod = DATA_MODULES.find((m) => m.key === entry.moduleKey)!;
return (
<Card key={entry.id}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-3 text-lg">
<span className="w-8 h-8 bg-primary text-primary-foreground rounded-lg flex items-center justify-center text-sm font-bold">{sectionNum}</span>
{mod.label}
<span className="ml-auto text-sm font-normal text-muted-foreground">{reportData[mod.key].length} records</span>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50">
{mod.columns.map((col) => (
<th key={col} className="text-left px-4 py-2 font-semibold text-muted-foreground text-xs uppercase tracking-wide border-b">{col}</th>
))}
</tr>
</thead>
<tbody>
{reportData[mod.key].map((row: any, ri: number) => (
<tr key={ri} className={ri % 2 === 0 ? "" : "bg-muted/20"}>
{mod.fields.map((f) => (
<td key={f} className="px-4 py-2 text-foreground/80 border-b border-muted/50 text-xs">{getCellValue(row, f)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
});
})()
)}
</div>
</div>
</div>
)}
</div>
<div className="flex items-center justify-between pt-6 border-t">
<Button variant="outline" onClick={() => goToStep(step - 1)} disabled={step === 1} className="h-12 px-6">
<ArrowLeft className="w-4 h-4 mr-2" /> Back
</Button>
{step === 1 ? (
<Button onClick={() => goToStep(2)} disabled={!selectedAssociationId} className="h-12 px-8">
Preview & Export <ArrowRight className="w-4 h-4 ml-2" />
</Button>
) : null}
</div>
</div>
);
}