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
+ }
+ onClick={() => setBulkExportOpen(true)}
+ variant="contained"
+ disabled={selectedExportInvoices.size === 0}
+ sx={{ whiteSpace: "nowrap" }}
+ >
+ Export Selected ({selectedExportInvoices.size})
+
{/* 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 */}
+
+
{/* 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 && (
+ }
+ onClick={() => setBulkExportOpen(true)}
+ sx={{
+ textTransform: "none",
+ borderRadius: "8px",
+ }}
+ >
+ Export Selected ({selectedExportInvoices.size})
+
+ )}
- {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()] ? (
+
+ ) : (
+
+ )}
+
+
+
+ )}
+
+