From 8be147b2766ab81f3ed82b6c05c7273f987df66d Mon Sep 17 00:00:00 2001 From: yokuisiti Date: Wed, 27 May 2026 09:13:19 +0000 Subject: [PATCH] feat: add CSV/PDF export feature (#60) - Add src/lib/export.ts with exportToCSV() and exportToPDF() utilities - CSV export: contribution history with round, member, amount, date, status - PDF export: full group summary report via browser print-to-PDF - Add Export CSV and Export PDF buttons in group detail Actions panel - Includes dates, amounts, members, round status per acceptance criteria Closes #60 --- src/app/groups/[id]/page.tsx | 48 ++++++++++ src/lib/export.ts | 169 +++++++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 src/lib/export.ts diff --git a/src/app/groups/[id]/page.tsx b/src/app/groups/[id]/page.tsx index 02ab880..b4766c1 100644 --- a/src/app/groups/[id]/page.tsx +++ b/src/app/groups/[id]/page.tsx @@ -6,6 +6,7 @@ import { RoundProgress } from "@/components/RoundProgress"; import { ContributeModal } from "@/components/ContributeModal"; import { useState } from "react"; import { formatAmount, GroupStatus } from "@sorosave/sdk"; +import { exportToCSV, exportToPDF, type GroupExportData } from "@/lib/export"; // TODO: Fetch real data from contract const MOCK_GROUP = { @@ -34,8 +35,37 @@ const MOCK_GROUP = { export default function GroupDetailPage() { const [showContributeModal, setShowContributeModal] = useState(false); + const [exporting, setExporting] = useState<"csv" | "pdf" | null>(null); const group = MOCK_GROUP; + const buildExportData = (): GroupExportData => ({ + groupName: group.name, + admin: group.admin, + totalRounds: group.totalRounds, + currentRound: group.currentRound, + members: group.members, + payoutOrder: group.payoutOrder, + cycleLength: group.cycleLength, + contributionAmount: formatAmount(group.contributionAmount), + contributions: group.members.map((m, i) => ({ + round: group.currentRound, + member: m, + amount: formatAmount(group.contributionAmount), + date: new Date(group.createdAt * 1000).toLocaleDateString(), + status: i === 0 ? "paid" : "pending", + })), + }); + + const handleExportCSV = () => { + setExporting("csv"); + try { exportToCSV(buildExportData()); } finally { setExporting(null); } + }; + + const handleExportPDF = async () => { + setExporting("pdf"); + try { await exportToPDF(buildExportData()); } finally { setExporting(null); } + }; + return ( <> @@ -83,6 +113,24 @@ export default function GroupDetailPage() { Join Group )} + +
+

Export

+ + +
diff --git a/src/lib/export.ts b/src/lib/export.ts new file mode 100644 index 0000000..6f130a2 --- /dev/null +++ b/src/lib/export.ts @@ -0,0 +1,169 @@ +/** + * Export utilities for SoroSave + * Issue #60 โ€” Add data export feature (CSV/PDF) + */ + +export interface ContributionRecord { + round: number; + member: string; + amount: string; + date: string; + status: "paid" | "pending" | "missed"; +} + +export interface GroupExportData { + groupName: string; + admin: string; + totalRounds: number; + currentRound: number; + members: string[]; + payoutOrder: string[]; + cycleLength: number; + contributionAmount: string; + contributions: ContributionRecord[]; +} + +// โ”€โ”€ CSV Export โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export function exportToCSV(data: GroupExportData): void { + const headers = ["Round", "Member", "Amount", "Date", "Status"]; + + const rows = data.contributions.map((c) => [ + String(c.round), + c.member, + c.amount, + c.date, + c.status, + ]); + + const csvContent = [headers, ...rows] + .map((row) => row.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(",")) + .join("\n"); + + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.setAttribute("href", url); + link.setAttribute( + "download", + `${data.groupName.replace(/\s+/g, "_")}_contributions.csv` + ); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +// โ”€โ”€ PDF Export โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export async function exportToPDF(data: GroupExportData): Promise { + // Build an HTML string and use the browser's print-to-PDF + const html = buildPDFHTML(data); + + const printWindow = window.open("", "_blank"); + if (!printWindow) { + alert("Please allow pop-ups to export PDF."); + return; + } + + printWindow.document.write(html); + printWindow.document.close(); + + // Wait for resources then trigger print + printWindow.onload = () => { + printWindow.focus(); + printWindow.print(); + printWindow.close(); + }; +} + +function buildPDFHTML(data: GroupExportData): string { + const memberRows = data.members + .map( + (m, i) => + ` + ${i + 1} + ${m} + ${data.payoutOrder.indexOf(m) + 1} + ` + ) + .join(""); + + const contributionRows = data.contributions + .map( + (c) => + ` + ${c.round} + ${c.member.slice(0, 12)}...${c.member.slice(-4)} + ${c.amount} + ${c.date} + ${c.status.toUpperCase()} + ` + ) + .join(""); + + return ` + + + + SoroSave โ€” ${data.groupName} Report + + + +

SoroSave Group Report

+
Generated on ${new Date().toLocaleDateString("en-US", { dateStyle: "long" })}
+ +
+
+
Group Name
+
${data.groupName}
+
+
+
Round Progress
+
${data.currentRound} / ${data.totalRounds}
+
+
+
Members
+
${data.members.length}
+
+
+
Contribution / Cycle
+
${data.contributionAmount}
+
+
+ +
+

Members & Payout Order

+ + + ${memberRows} +
#Wallet AddressPayout Round
+
+ +
+

Contribution History

+ + + ${contributionRows || ''} +
RoundMemberAmountDateStatus
No contribution records yet
+
+ + + +`; +}