mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
8360363a15
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>
1030 lines
47 KiB
TypeScript
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(/ /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>
|
|
);
|
|
}
|