diff --git a/.changeset/feat-ux-improvements.md b/.changeset/feat-ux-improvements.md new file mode 100644 index 0000000..9efee60 --- /dev/null +++ b/.changeset/feat-ux-improvements.md @@ -0,0 +1,10 @@ +--- +"mpesa2csv": minor +--- + +- Clickable drop zone +- Cancel button during processing +- Transaction preview before export +- Re-export without Start Again +- XLSX blob only on Export click +- Visible webhook result badge. diff --git a/src/App.tsx b/src/App.tsx index 15f311f..d074078 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,16 @@ import { useState, useEffect, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; import { platform } from "@tauri-apps/plugin-os"; -import { Download, RotateCcw, ExternalLink, MessageSquare } from "lucide-react"; +import { + Download, + RotateCcw, + ExternalLink, + MessageSquare, + ChevronDown, + ChevronUp, + Table2, + X, +} from "lucide-react"; import { openUrl } from "@tauri-apps/plugin-opener"; import { @@ -26,8 +35,11 @@ function App() { const [status, setStatus] = useState(FileStatus.IDLE); const [error, setError] = useState(undefined); const [statements, setStatements] = useState([]); - const [exportLink, setExportLink] = useState(""); const [exportFileName, setExportFileName] = useState(""); + // Ref to signal in-flight processFiles loop to stop (cancel button). + const cancelRequested = useRef(false); + // Controls the transaction-preview panel. + const [previewExpanded, setPreviewExpanded] = useState(false); const [exportFormat, setExportFormat] = useState( ExportFormat.XLSX ); @@ -73,19 +85,11 @@ function App() { format, formatDateForFilename() ); - - ExportService.createDownloadLink(combinedStatement, format, exportOptions) - .then(setExportLink) - .catch(() => setExportLink("")); setExportFileName(fileName); }; const handleOptionsChange = (options: ExportOptionsType) => { setExportOptions(options); - const combinedStatement = statements[0]; - ExportService.createDownloadLink(combinedStatement, exportFormat, options) - .then(setExportLink) - .catch(() => setExportLink("")); }; useEffect(() => { @@ -104,14 +108,17 @@ function App() { }, []); const handleFilesSelected = async (selectedFiles: File[]) => { + cancelRequested.current = false; setFiles(selectedFiles); setStatus(FileStatus.LOADING); setError(undefined); setStatements([]); setCurrentFileIndex(0); + setPreviewExpanded(false); try { const result = await processFiles(selectedFiles); + if (result?.cancelled) return; if (result?.needsPassword) { pendingStatements.current = result.processedStatements; } else if (result?.error) { @@ -134,6 +141,7 @@ function App() { const processedStatements: MPesaStatement[] = [...existingStatements]; for (let i = startIndex; i < filesToProcess.length; i++) { + if (cancelRequested.current) break; setCurrentFileIndex(i); const file = filesToProcess[i]; @@ -173,6 +181,9 @@ function App() { statement.fileName = file.name; processedStatements.push(statement); } catch (err: any) { + if (cancelRequested.current) { + return { cancelled: true, fileIndex: i, processedStatements }; + } if ( err.message?.includes("password") || err.message?.includes("encrypted") || @@ -193,6 +204,10 @@ function App() { } } + if (cancelRequested.current) { + return { cancelled: true, fileIndex: filesToProcess.length, processedStatements }; + } + if (processedStatements.length > 0) { const combinedStatement = combineStatements(processedStatements); setStatements([combinedStatement]); @@ -202,14 +217,6 @@ function App() { exportFormat, formatDateForFilename() ); - - ExportService.createDownloadLink( - combinedStatement, - exportFormat, - exportOptions - ) - .then(setExportLink) - .catch(() => setExportLink("")); setExportFileName(fileName); setStatus(FileStatus.SUCCESS); } @@ -282,14 +289,6 @@ function App() { exportFormat, formatDateForFilename() ); - - ExportService.createDownloadLink( - combinedStatement, - exportFormat, - exportOptions - ) - .then(setExportLink) - .catch(() => setExportLink("")); setExportFileName(fileName); setStatus(FileStatus.SUCCESS); } @@ -319,14 +318,6 @@ function App() { exportFormat, formatDateForFilename() ); - - ExportService.createDownloadLink( - combinedStatement, - exportFormat, - exportOptions - ) - .then(setExportLink) - .catch(() => setExportLink("")); setExportFileName(fileName); setStatus(FileStatus.SUCCESS); } else { @@ -338,6 +329,7 @@ function App() { }; const handleReset = () => { + cancelRequested.current = false; setFiles([]); setStatus(FileStatus.IDLE); setError(undefined); @@ -347,12 +339,23 @@ function App() { setIsDownloading(false); setDownloadSuccess(false); setSavedFilePath(""); + setExportFileName(""); + setPreviewExpanded(false); + }; - if (exportLink) { - URL.revokeObjectURL(exportLink); - setExportLink(""); - setExportFileName(""); + const handleCancel = async () => { + cancelRequested.current = true; + try { + await invoke("cancel_pdf_extraction"); + } catch { + // best-effort — ignore if no process is running } + setStatus(FileStatus.IDLE); + setFiles([]); + setStatements([]); + setCurrentFileIndex(0); + pendingStatements.current = []; + setError(undefined); }; const handleDownloadError = (error: any) => { @@ -479,13 +482,6 @@ function App() { } }; - useEffect(() => { - return () => { - if (exportLink) { - URL.revokeObjectURL(exportLink); - } - }; - }, [exportLink]); return (
@@ -554,6 +550,17 @@ function App() {

)}
+ + {/* Cancel button */} + ) : status === FileStatus.SUCCESS && statements.length > 0 ? (
@@ -575,6 +582,63 @@ function App() {

+ {/* ── Transaction Preview ────────────────────────── */} +
+ + + {previewExpanded && ( +
+ + + + + + + + + + + + {statements[0].transactions.slice(0, 10).map((tx, i) => ( + + + + + + + + ))} + +
DateDetailsPaid InWithdrawnBalance
{tx.completionTime}{tx.details} + {tx.paidIn ? tx.paidIn.toLocaleString() : ""} + + {tx.withdrawn ? tx.withdrawn.toLocaleString() : ""} + {tx.balance.toLocaleString()}
+ {statements[0].transactions.length > 10 && ( +

+ Showing 10 of {statements[0].transactions.length} — export to see all +

+ )} +
+ )} +
+ + {/* ── Export Options ──────────────────────────────── */}
+ {/* ── Action Buttons ──────────────────────────────── */}
- {!downloadSuccess ? ( + {/* Export is always visible so users can re-export after changing format/options */} + + + {/* Open File appears only after a successful save */} + {downloadSuccess && savedFilePath && ( - ) : ( -
- -
)}
{isWebhookOpen ? ( diff --git a/src/components/file-uploader.tsx b/src/components/file-uploader.tsx index 70f3c30..0a70786 100644 --- a/src/components/file-uploader.tsx +++ b/src/components/file-uploader.tsx @@ -148,6 +148,11 @@ const FileUploader: React.FC = ({ fileInputRef.current?.click(); }; + const handleDropZoneClick = (e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest("button")) return; + handleButtonClick(); + }; + return (
{/* Drop zone */} @@ -159,7 +164,10 @@ const FileUploader: React.FC = ({ : "border-2 border-dashed border-border hover:border-primary/60 hover:bg-muted/30" )} tabIndex={0} + role="button" aria-label="Drop PDF files here or click to select files" + onClick={handleDropZoneClick} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleButtonClick(); }} >
{/* Icon */}