diff --git a/frontend/package.json b/frontend/package.json index fe80757a..1d683e08 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,8 +39,10 @@ "idb": "^8.0.3", "idb-keyval": "^6.2.1", "jspdf": "^3.0.0", + "jszip": "^3.10.1", "lucide-react": "^0.471.1", "papaparse": "^5.5.2", + "pdf-lib": "^1.17.1", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", diff --git a/frontend/src/hooks/useInvoiceExport.js b/frontend/src/hooks/useInvoiceExport.js index 0ee6a0c8..62ed315b 100644 --- a/frontend/src/hooks/useInvoiceExport.js +++ b/frontend/src/hooks/useInvoiceExport.js @@ -2,19 +2,15 @@ import { useCallback } from "react"; import toast from "react-hot-toast"; import { downloadInvoiceCSV } from "@/utils/generateInvoiceCSV"; import { downloadInvoiceJSON } from "@/utils/generateInvoiceJSON"; +import { exportInvoiceBatch } from "@/utils/invoiceBulkExport"; -/** - * Shared hook for exporting a single invoice as CSV or JSON. - * @param {Object|null} selectedInvoice - The invoice currently open in the drawer - * @param {string|BigInt} fee - Network fee (wei) - * @param {Function} onExportDone - Optional callback invoked after a successful export (e.g. close menu) - */ export const useInvoiceExport = (selectedInvoice, fee, onExportDone) => { const handleExportCSV = useCallback(() => { if (!selectedInvoice) { toast.error("No invoice selected"); return; } + try { downloadInvoiceCSV(selectedInvoice, fee); toast.success("CSV downloaded successfully!"); @@ -22,6 +18,7 @@ export const useInvoiceExport = (selectedInvoice, fee, onExportDone) => { console.error("Error generating CSV:", error); toast.error("Failed to generate CSV. Please try again."); } + onExportDone?.(); }, [selectedInvoice, fee, onExportDone]); @@ -30,6 +27,7 @@ export const useInvoiceExport = (selectedInvoice, fee, onExportDone) => { toast.error("No invoice selected"); return; } + try { downloadInvoiceJSON(selectedInvoice, fee); toast.success("JSON downloaded successfully!"); @@ -37,8 +35,50 @@ export const useInvoiceExport = (selectedInvoice, fee, onExportDone) => { console.error("Error generating JSON:", error); toast.error("Failed to generate JSON. Please try again."); } + onExportDone?.(); }, [selectedInvoice, fee, onExportDone]); - return { handleExportCSV, handleExportJSON }; -}; + const handleBulkExport = useCallback( + async (invoices, format, mode = "single") => { + if (!invoices?.length) { + toast.error("Select at least one invoice"); + return; + } + + try { + toast.loading(`Generating ${format.toUpperCase()} export...`, { + id: "bulk-export", + }); + + await exportInvoiceBatch(invoices, { + format, + mode, + fee, + }); + + toast.success( + `${invoices.length} invoice${invoices.length > 1 ? "s" : "" + } exported successfully!`, + { id: "bulk-export" } + ); + + onExportDone?.(); + } catch (error) { + console.error("Bulk export failed:", error); + + toast.error( + error?.message || "Failed to export invoices. Please try again.", + { id: "bulk-export" } + ); + } + }, + [fee, onExportDone] + ); + + return { + handleExportCSV, + handleExportJSON, + handleBulkExport, + }; +}; \ No newline at end of file diff --git a/frontend/src/page/ReceivedInvoice.jsx b/frontend/src/page/ReceivedInvoice.jsx index 09f2da81..77e2f272 100644 --- a/frontend/src/page/ReceivedInvoice.jsx +++ b/frontend/src/page/ReceivedInvoice.jsx @@ -1,4 +1,8 @@ import Paper from "@mui/material/Paper"; +import Dialog from "@mui/material/Dialog"; +import DialogTitle from "@mui/material/DialogTitle"; +import DialogContent from "@mui/material/DialogContent"; +import DialogActions from "@mui/material/DialogActions"; import Table from "@mui/material/Table"; import TableBody from "@mui/material/TableBody"; import TableCell from "@mui/material/TableCell"; @@ -65,6 +69,7 @@ import WalletConnectionAlert from "@/components/WalletConnectionAlert"; const columns = [ { id: "select", label: "", minWidth: 50 }, + { id: "exportSelect", label: "", minWidth: 50 }, { id: "fname", label: "Client", minWidth: 120 }, { id: "to", label: "Sender", minWidth: 150 }, { id: "amountDue", label: "Amount", minWidth: 100, align: "right" }, @@ -113,6 +118,12 @@ function ReceivedInvoice() { const [batchLoading, setBatchLoading] = useState(false); const [batchSuggestions, setBatchSuggestions] = useState([]); + // Bulk export states (kept separate from batch-payment selection) + const [selectedExportInvoices, setSelectedExportInvoices] = useState(new Set()); + const [bulkExportOpen, setBulkExportOpen] = useState(false); + const [bulkExportFormat, setBulkExportFormat] = useState("csv"); + const [bulkExportMode, setBulkExportMode] = useState("single"); + // Drawer state const [drawerState, setDrawerState] = useState({ open: false, @@ -358,6 +369,50 @@ function ReceivedInvoice() { setSelectedInvoices(new Set()); }; + // Bulk export selection is intentionally separate from payment selection. + const handleExportSelect = (invoiceId) => { + const id = String(invoiceId); + setSelectedExportInvoices((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const handleSelectAllForExport = () => { + if (selectedExportInvoices.size === receivedInvoices.length) { + setSelectedExportInvoices(new Set()); + } else { + setSelectedExportInvoices( + new Set(receivedInvoices.map((invoice) => String(invoice.id))) + ); + } + }; + + const selectedExportInvoiceList = receivedInvoices.filter((invoice) => + selectedExportInvoices.has(String(invoice.id)) + ); + + const handleBulkExportSubmit = async () => { + if (!selectedExportInvoiceList.length) { + toast.error("Select at least one invoice"); + return; + } + + await handleBulkExport( + selectedExportInvoiceList, + bulkExportFormat, + bulkExportMode + ); + + setBulkExportOpen(false); + setSelectedExportInvoices(new Set()); + }; + const selectBatchSuggestion = (suggestion) => { const invoiceIds = suggestion.invoices.map((inv) => inv.id); setSelectedInvoices(new Set(invoiceIds)); @@ -831,7 +886,7 @@ function ReceivedInvoice() { }; fetchReceivedInvoices(); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [walletClient, address, tokens, chainId, refreshTrigger]); // Relay ingestion runs independently of the display fetch above. Keeping it @@ -863,8 +918,8 @@ function ReceivedInvoice() { * only unseen invoices should wake the UI. * * The envelope is checked against the on-chain commitment before it is - * stored. Anyone can encrypt to this recipient — the public key is in the - * registry — so without that check a stranger could post an envelope + * stored. Anyone can encrypt to this recipient — the public key is in the + * registry — so without that check a stranger could post an envelope * claiming any invoice id, have it stored first, and permanently shadow * the real payload: the record would exist, so the genuine delivery would * be skipped as a duplicate and the invoice would sit unverifiable for good. @@ -1015,7 +1070,11 @@ function ReceivedInvoice() { } }; - const { handleExportCSV, handleExportJSON } = useInvoiceExport( + const { + handleExportCSV, + handleExportJSON, + handleBulkExport, + } = useInvoiceExport( drawerState.selectedInvoice, fee, handleExportClose @@ -1056,6 +1115,15 @@ function ReceivedInvoice() { Manage and pay your incoming invoices

+ {/* Without a registered public key, senders have nothing to encrypt @@ -1088,7 +1156,7 @@ function ReceivedInvoice() { )} - {/* Registered on-chain, but this tab holds no private key — the key + {/* Registered on-chain, but this tab holds no private key — the key is derived from a signature and never stored beyond the session, so nothing can be decrypted until the user re-derives it. */} {isConnected && !isUnsupportedNetwork && isRegistered && !keys && ( @@ -1115,7 +1183,7 @@ function ReceivedInvoice() { } > Sign to unlock your inbox. Incoming invoice details stay encrypted - until you do — this signature is free and costs no gas. + until you do — this signature is free and costs no gas. )} @@ -1183,7 +1251,7 @@ function ReceivedInvoice() { }} > - 💡 Smart Batch Suggestions + 💡 Smart Batch Suggestions {batchSuggestions.map((suggestion) => ( + ) : column.id === "exportSelect" ? ( + 0 && + selectedExportInvoices.size < receivedInvoices.length + } + checked={ + selectedExportInvoices.size === receivedInvoices.length && + receivedInvoices.length > 0 + } + onChange={handleSelectAllForExport} + color="primary" + inputProps={{ "aria-label": "Select invoices for export" }} + /> ) : ( column.label )} @@ -1550,6 +1632,17 @@ function ReceivedInvoice() { /> + + handleExportSelect(invoice.id)} + color="primary" + inputProps={{ + "aria-label": `Select invoice ${invoice.id} for export`, + }} + /> + +
+ {/* Bulk Invoice Export Dialog */} + setBulkExportOpen(false)} + fullWidth + maxWidth="sm" + > + Export Selected Invoices + + + {selectedExportInvoices.size} invoice + {selectedExportInvoices.size !== 1 ? "s" : ""} selected. + + + + Export format + + + {[ + ["csv", "CSV", ], + ["json", "JSON", ], + ["pdf", "PDF", ], + ].map(([value, label, icon]) => ( + + ))} + + + + Export mode + + + + + + + + + + + + {/* Invoice Detail Drawer */} { if (!walletClient || !address) return; @@ -321,7 +327,7 @@ function SentInvoice() { }; fetchSentInvoices(); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [walletClient, address, tokens, chainId, refreshTrigger]); // Added tokens and chainId to dependency array /** @@ -498,8 +504,8 @@ function SentInvoice() { return () => { cancelled = true; }; - // Deliberately not depending on refreshTrigger: a successful sweep bumps it, - // and re-running on that would loop. + // Deliberately not depending on refreshTrigger: a successful sweep bumps it, + // and re-running on that would loop. }, [isConnected, address, walletClient, chainId, sentInvoices]); const [drawerState, setDrawerState] = useState({ @@ -548,12 +554,57 @@ function SentInvoice() { } }; - const { handleExportCSV, handleExportJSON } = useInvoiceExport( + const { + handleExportCSV, + handleExportJSON, + handleBulkExport, + } = useInvoiceExport( drawerState.selectedInvoice, fee, handleExportClose ); + const handleExportSelect = (invoiceId) => { + const id = String(invoiceId); + + setSelectedExportInvoices((prev) => { + const next = new Set(prev); + + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + + return next; + }); + }; + + const handleSelectAllForExport = () => { + if (selectedExportInvoices.size === sentInvoices.length) { + setSelectedExportInvoices(new Set()); + } else { + setSelectedExportInvoices( + new Set(sentInvoices.map((invoice) => String(invoice.id))) + ); + } + }; + + const selectedExportInvoiceList = sentInvoices.filter((invoice) => + selectedExportInvoices.has(String(invoice.id)) + ); + + const handleBulkExportSubmit = async () => { + await handleBulkExport( + selectedExportInvoiceList, + bulkExportFormat, + bulkExportMode + ); + + setBulkExportOpen(false); + setSelectedExportInvoices(new Set()); + }; + const handleCancelInvoice = async (invoiceId) => { try { const provider = new BrowserProvider(walletClient); @@ -606,6 +657,20 @@ function SentInvoice() {

Sent Invoices

+ + {selectedExportInvoices.size > 0 && ( + + )} - {column.label} + {column.id === "select" ? ( + 0 && + selectedExportInvoices.size === sentInvoices.length + } + indeterminate={ + selectedExportInvoices.size > 0 && + selectedExportInvoices.size < sentInvoices.length + } + onChange={handleSelectAllForExport} + /> + ) : ( + column.label + )}
))} @@ -690,6 +770,14 @@ function SentInvoice() { "&:hover": { backgroundColor: "#f8fafc" }, }} > + + handleExportSelect(invoice.id)} + /> + + {/* Client Column */}
@@ -846,47 +934,47 @@ function SentInvoice() { their storage, or the relay may have dropped the message before they polled for it. */} {!invoice._onChainOnly && ( - - - handleResend(invoice)} - sx={{ + + + handleResend(invoice)} + sx={{ + backgroundColor: invoice.relayDelivered + ? "#f1f5f9" + : "#fef3c7", + "&:hover": { backgroundColor: invoice.relayDelivered - ? "#f1f5f9" - : "#fef3c7", - "&:hover": { - backgroundColor: invoice.relayDelivered - ? "#e2e8f0" - : "#fde68a", - }, - }} - > - {resending[invoice.id.toString()] ? ( - - ) : ( - - )} - - - - )} + ? "#e2e8f0" + : "#fde68a", + }, + }} + > + {resending[invoice.id.toString()] ? ( + + ) : ( + + )} + + + + )} + setBulkExportOpen(false)} + maxWidth="sm" + fullWidth + > + + Export {selectedExportInvoices.size} Invoice + {selectedExportInvoices.size !== 1 ? "s" : ""} + + + +
+ + Format + + +
+ {[ + ["csv", "CSV"], + ["json", "JSON"], + ["pdf", "PDF"], + ].map(([value, label]) => ( + + ))} +
+ + + File option + + +
+ + + +
+
+
+ + + + + + +
+ setCancelConfirmOpen(false)} diff --git a/frontend/src/utils/invoiceBulkExport.js b/frontend/src/utils/invoiceBulkExport.js new file mode 100644 index 00000000..12bfdb42 --- /dev/null +++ b/frontend/src/utils/invoiceBulkExport.js @@ -0,0 +1,176 @@ +import JSZip from "jszip"; +import { PDFDocument } from "pdf-lib"; +import { downloadInvoiceCSV } from "./generateInvoiceCSV"; +import { downloadInvoiceJSON } from "./generateInvoiceJSON"; +import { generateInvoicePDF } from "./generateInvoicePDF"; + +const downloadBlob = (blob, filename) => { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = url; + link.download = filename; + + document.body.appendChild(link); + link.click(); + link.remove(); + + URL.revokeObjectURL(url); +}; + +const getInvoiceId = (invoice) => + String(invoice?.id ?? "unknown").padStart(6, "0"); + +const getInvoiceFilename = (invoice, extension) => + `invoice-${getInvoiceId(invoice)}.${extension}`; + +const escapeCSVValue = (value) => { + let text = String(value ?? ""); + + // Prevent spreadsheet formula injection. + if (/^[=+\-@]/.test(text)) { + text = `'${text}`; + } + + if ( + text.includes(",") || + text.includes('"') || + text.includes("\n") || + text.includes("\r") + ) { + return `"${text.replace(/"/g, '""')}"`; + } + + return text; +}; + +const createSimpleCSV = (invoice) => { + const headers = [ + "Invoice ID", + "Client", + "Receiver", + "Amount", + "Status", + "Date", + ]; + + const row = [ + invoice?.id, + invoice?.fname ?? invoice?.senderName ?? "", + invoice?.to ?? invoice?.receiverName ?? "", + invoice?.amountDue ?? invoice?.amount ?? "", + invoice?.status ?? "", + invoice?.date ?? invoice?.createdAt ?? "", + ]; + + return [ + headers.map(escapeCSVValue).join(","), + row.map(escapeCSVValue).join(","), + ].join("\n"); +}; + +const createMergedPDF = async (invoices, fee) => { + const mergedPdf = await PDFDocument.create(); + + for (const invoice of invoices) { + const pdf = await generateInvoicePDF(invoice, fee); + const pdfBytes = pdf.output("arraybuffer"); + + const sourcePdf = await PDFDocument.load(pdfBytes); + const pages = await mergedPdf.copyPages( + sourcePdf, + sourcePdf.getPageIndices() + ); + + pages.forEach((page) => { + mergedPdf.addPage(page); + }); + } + + const mergedBytes = await mergedPdf.save(); + + downloadBlob( + new Blob([mergedBytes], { type: "application/pdf" }), + `invoices-export-${Date.now()}.pdf` + ); +}; + +const createZipExport = async (invoices, format, fee) => { + const zip = new JSZip(); + + for (const invoice of invoices) { + const filename = getInvoiceFilename(invoice, format); + + if (format === "pdf") { + const pdf = await generateInvoicePDF(invoice, fee); + zip.file(filename, pdf.output("arraybuffer")); + continue; + } + + if (format === "json") { + zip.file(filename, JSON.stringify(invoice, null, 2)); + continue; + } + + if (format === "csv") { + zip.file(filename, createSimpleCSV(invoice)); + continue; + } + + throw new Error(`Unsupported export format: ${format}`); + } + + const zipBlob = await zip.generateAsync({ + type: "blob", + }); + + downloadBlob( + zipBlob, + `invoices-export-${Date.now()}.zip` + ); +}; + +export const exportInvoiceBatch = async ( + invoices, + { + format = "csv", + mode = "single", + fee = 0, + } = {} +) => { + if (!Array.isArray(invoices) || invoices.length === 0) { + throw new Error("No invoices selected"); + } + + const normalizedFormat = String(format).toLowerCase(); + const normalizedMode = String(mode).toLowerCase(); + + if (!["csv", "json", "pdf"].includes(normalizedFormat)) { + throw new Error(`Unsupported export format: ${format}`); + } + + if (!["single", "separate"].includes(normalizedMode)) { + throw new Error(`Unsupported export mode: ${mode}`); + } + + // Separate Files -> ZIP containing one file per invoice. + if (normalizedMode === "separate") { + await createZipExport(invoices, normalizedFormat, fee); + return; + } + + // Single File exports. + if (normalizedFormat === "csv") { + downloadInvoiceCSV(invoices, fee); + return; + } + + if (normalizedFormat === "json") { + downloadInvoiceJSON(invoices, fee); + return; + } + + if (normalizedFormat === "pdf") { + await createMergedPDF(invoices, fee); + } +}; \ No newline at end of file