-
-
Notifications
You must be signed in to change notification settings - Fork 45
feat: add bulk invoice export #210
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win Move new user-visible strings to i18n resources. The bulk-export toast, toolbar label, dialog labels, options, and actions are inline literals.
As per path instructions, “User-visible strings should be externalized to resource files (i18n).” 📍 Affects 1 file
🤖 Prompt for AI AgentsSource: Path instructions |
||
| return; | ||
| } | ||
|
|
||
| await handleBulkExport( | ||
| selectedExportInvoiceList, | ||
| bulkExportFormat, | ||
| bulkExportMode | ||
| ); | ||
|
|
||
| setBulkExportOpen(false); | ||
| setSelectedExportInvoices(new Set()); | ||
|
Comment on lines
+412
to
+413
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Keep the export selection when the export fails.
🤖 Prompt for AI Agents |
||
| }; | ||
|
|
||
| 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 | ||
|
Comment on lines
+921
to
+922
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Restore the corrupted UTF-8 text. The mojibake is visible in the inbox alert and the batch-suggestions heading, and it also corrupts source comments.
📍 Affects 1 file
🤖 Prompt for AI Agents |
||
| * 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 | ||
| </p> | ||
| </div> | ||
| <Button | ||
| startIcon={<DownloadIcon />} | ||
| onClick={() => setBulkExportOpen(true)} | ||
| variant="contained" | ||
| disabled={selectedExportInvoices.size === 0} | ||
| sx={{ whiteSpace: "nowrap" }} | ||
| > | ||
| Export Selected ({selectedExportInvoices.size}) | ||
| </Button> | ||
| </div> | ||
|
|
||
| {/* Without a registered public key, senders have nothing to encrypt | ||
|
|
@@ -1088,7 +1156,7 @@ function ReceivedInvoice() { | |
| </Alert> | ||
| )} | ||
|
|
||
| {/* 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. | ||
| </Alert> | ||
| )} | ||
|
|
||
|
|
@@ -1183,7 +1251,7 @@ function ReceivedInvoice() { | |
| }} | ||
| > | ||
| <LightbulbIcon sx={{ mr: 1, color: "#ff9800" }} /> | ||
| 💡 Smart Batch Suggestions | ||
| 💡 Smart Batch Suggestions | ||
| </Typography> | ||
| {batchSuggestions.map((suggestion) => ( | ||
| <Box | ||
|
|
@@ -1516,6 +1584,20 @@ function ReceivedInvoice() { | |
| } | ||
| label="" | ||
| /> | ||
| ) : column.id === "exportSelect" ? ( | ||
| <Checkbox | ||
| indeterminate={ | ||
| selectedExportInvoices.size > 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() { | |
| /> | ||
| </TableCell> | ||
|
|
||
| <TableCell> | ||
| <Checkbox | ||
| checked={selectedExportInvoices.has(String(invoice.id))} | ||
| onChange={() => handleExportSelect(invoice.id)} | ||
| color="primary" | ||
| inputProps={{ | ||
| "aria-label": `Select invoice ${invoice.id} for export`, | ||
| }} | ||
| /> | ||
| </TableCell> | ||
|
|
||
| <TableCell> | ||
| <div className="flex items-center"> | ||
| <Avatar | ||
|
|
@@ -1820,6 +1913,71 @@ function ReceivedInvoice() { | |
| </Paper> | ||
| </div> | ||
|
|
||
| {/* Bulk Invoice Export Dialog */} | ||
| <Dialog | ||
| open={bulkExportOpen} | ||
| onClose={() => setBulkExportOpen(false)} | ||
| fullWidth | ||
| maxWidth="sm" | ||
| > | ||
| <DialogTitle>Export Selected Invoices</DialogTitle> | ||
| <DialogContent dividers> | ||
| <Typography sx={{ mb: 2 }}> | ||
| {selectedExportInvoices.size} invoice | ||
| {selectedExportInvoices.size !== 1 ? "s" : ""} selected. | ||
| </Typography> | ||
|
|
||
| <Typography variant="subtitle2" sx={{ mb: 1 }}> | ||
| Export format | ||
| </Typography> | ||
| <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 3 }}> | ||
| {[ | ||
| ["csv", "CSV", <TableChartIcon key="csv-icon" />], | ||
| ["json", "JSON", <DataObjectIcon key="json-icon" />], | ||
| ["pdf", "PDF", <PictureAsPdfIcon key="pdf-icon" />], | ||
| ].map(([value, label, icon]) => ( | ||
| <Button | ||
| key={value} | ||
| variant={bulkExportFormat === value ? "contained" : "outlined"} | ||
| startIcon={icon} | ||
| onClick={() => setBulkExportFormat(value)} | ||
|
Comment on lines
+1941
to
+1943
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Expose the selected export options semantically. The selected format and mode are indicated only by button styling. Screen readers cannot determine the active options. Use a radio group or add Also applies to: 1955-1962 🧰 Tools🪛 ast-grep (0.45.2)[warning] 1942-1942: Avoid using the initial state variable in setState (setstate-same-var) 🤖 Prompt for AI Agents |
||
| > | ||
| {label} | ||
| </Button> | ||
| ))} | ||
| </Box> | ||
|
|
||
| <Typography variant="subtitle2" sx={{ mb: 1 }}> | ||
| Export mode | ||
| </Typography> | ||
| <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}> | ||
| <Button | ||
| variant={bulkExportMode === "single" ? "contained" : "outlined"} | ||
| onClick={() => setBulkExportMode("single")} | ||
| > | ||
| Single File | ||
| </Button> | ||
| <Button | ||
| variant={bulkExportMode === "separate" ? "contained" : "outlined"} | ||
| onClick={() => setBulkExportMode("separate")} | ||
| > | ||
| Separate Files (ZIP) | ||
| </Button> | ||
| </Box> | ||
| </DialogContent> | ||
| <DialogActions> | ||
| <Button onClick={() => setBulkExportOpen(false)}>Cancel</Button> | ||
| <Button | ||
| onClick={handleBulkExportSubmit} | ||
| variant="contained" | ||
| startIcon={<DownloadIcon />} | ||
| disabled={selectedExportInvoices.size === 0} | ||
|
Comment on lines
+1971
to
+1974
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Prevent concurrent bulk exports. The Export button remains enabled while 🤖 Prompt for AI Agents |
||
| > | ||
| Export | ||
| </Button> | ||
| </DialogActions> | ||
| </Dialog> | ||
|
|
||
| {/* Invoice Detail Drawer */} | ||
| <SwipeableDrawer | ||
| anchor="right" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Reset export selection when the invoice context changes. The fetch effect replaces
receivedInvoiceswhenwalletClient,address,chainId, orrefreshTriggerchanges, but it does not clear or pruneselectedExportInvoices. Invoice IDs are deployment-local (invoiceId = invoices.length), so a new chain can reuse an ID andselectedExportInvoiceListwill export that invoice without selecting it in the new context. Clear or prune the selection when replacing the list, derive the displayed count from current selected invoices, and determine “all selected” from current IDs rather thanselectedExportInvoices.size.🤖 Prompt for AI Agents