From 310265f8e6fa6f34850165fb8297c011a996d51e Mon Sep 17 00:00:00 2001 From: darcszn Date: Wed, 21 Oct 2026 04:53:25 +0100 Subject: [PATCH] feat: improve invoice bulk and comparison workflows Add review and confirmation to bulk invoice actions, local undo audit history, filtered comparison exports and dashboard batch comparisons. Connect explainable heuristic risk assessment to invoice details. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/invoice/[id]/page.tsx | 16 ++ src/app/invoice/compare/page.tsx | 57 ++++-- src/components/BulkActionBar.tsx | 206 +++++++++++++++++++++- src/components/CompareInvoicesView.tsx | 82 ++++++++- src/components/DashboardClient.tsx | 16 +- src/components/invoice/FraudRiskPanel.tsx | 15 +- src/lib/bulkInvoiceOps.ts | 29 +++ 7 files changed, 388 insertions(+), 33 deletions(-) diff --git a/src/app/invoice/[id]/page.tsx b/src/app/invoice/[id]/page.tsx index 5116f484..fc29ab7c 100644 --- a/src/app/invoice/[id]/page.tsx +++ b/src/app/invoice/[id]/page.tsx @@ -79,8 +79,10 @@ import InvoiceTimeline from "@/components/InvoiceTimeline"; import HistoryLog from "@/components/invoice/HistoryLog"; import AdvancedStatusTimeline from "@/components/invoice/AdvancedStatusTimeline"; import InvoiceSentiment from "@/components/invoice/InvoiceSentiment"; +import FraudRiskPanel from "@/components/invoice/FraudRiskPanel"; import PayerWalletHealth from "@/components/invoice/PayerWalletHealth"; import ReconciliationPanel from "@/components/invoice/ReconciliationPanel"; +import { detectAnomalies } from "@/lib/anomalyDetector"; import InvoiceExportButton from "@/components/InvoiceExportButton"; // Only rendered once an invoice transitions to "released", and pulls in the // canvas-confetti animation library — load it on demand instead of in the @@ -503,6 +505,16 @@ export default function InvoiceDetailPage({ params }: Props) { : false; const canDownloadPDF = isCreator || isContributor; const canAct = isCreator || isRecipient; + const invoicePayerHistory = new Map( + invoice.payments.map((payment) => [payment.payer, 1] as const), + ); + const anomalyFlags = invoice.payments + .flatMap((payment) => detectAnomalies(payment, invoice, invoicePayerHistory)) + .filter((flag, index, flags) => + flags.findIndex((candidate) => + candidate.type === flag.type && candidate.payer === flag.payer, + ) === index, + ); const recipientShare = publicKey ? invoice.recipients.find((recipient) => recipient.address === publicKey) : undefined; @@ -1140,6 +1152,10 @@ export default function InvoiceDetailPage({ params }: Props) { + diff --git a/src/app/invoice/compare/page.tsx b/src/app/invoice/compare/page.tsx index df42ab9b..0620566f 100644 --- a/src/app/invoice/compare/page.tsx +++ b/src/app/invoice/compare/page.tsx @@ -12,13 +12,42 @@ function CompareInvoicesContent() { const router = useRouter(); const [invoiceA, setInvoiceA] = useState(null); const [invoiceB, setInvoiceB] = useState(null); + const [batchInvoices, setBatchInvoices] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const idA = searchParams.get("a"); const idB = searchParams.get("b"); + const batchIds = searchParams.get("ids"); useEffect(() => { + setError(null); + if (batchIds !== null) { + const ids = [...new Set(batchIds.split(",").map((id) => id.trim()).filter(Boolean))]; + if (ids.length < 2 || ids.length > 10) { + setError("Batch comparison requires between 2 and 10 distinct invoice IDs."); + setLoading(false); + return; + } + const fetchBatch = async () => { + try { + setLoading(true); + setError(null); + const invoices = await Promise.all(ids.map((id) => splitClient.getInvoice(id))); + setBatchInvoices(invoices); + setInvoiceA(invoices[0]); + setInvoiceB(invoices[1]); + } catch (e) { + setError(`Failed to load batch invoices: ${String(e)}`); + } finally { + setLoading(false); + } + }; + fetchBatch(); + return; + } + + setBatchInvoices([]); if (!idA || !idB) { setError("Missing invoice IDs. Use /compare?a=[id]&b=[id]"); setLoading(false); @@ -42,7 +71,7 @@ function CompareInvoicesContent() { }; fetchInvoices(); - }, [idA, idB]); + }, [idA, idB, batchIds]); if (loading) { return ( @@ -76,18 +105,26 @@ function CompareInvoicesContent() { router.push(`/invoice/${invoiceA!.id}`); }; - const handlePayB = () => { - router.push(`/invoice/${invoiceB!.id}`); - }; + const comparisons = batchInvoices.length > 2 + ? batchInvoices.slice(1).map((invoice) => [batchInvoices[0], invoice] as const) + : [[invoiceA, invoiceB] as const]; return (
- + {batchInvoices.length > 2 && ( +

+ Batch comparison: invoice #{batchInvoices[0].id} is compared with {batchInvoices.length - 1} other invoices. +

+ )} + {comparisons.map(([baseline, invoice]) => ( + router.push(`/invoice/${invoice.id}`)} + /> + ))}
); } diff --git a/src/components/BulkActionBar.tsx b/src/components/BulkActionBar.tsx index 77313932..dd715144 100644 --- a/src/components/BulkActionBar.tsx +++ b/src/components/BulkActionBar.tsx @@ -10,7 +10,10 @@ import { } from "@/lib/archiveInvoices"; import { exportInvoicesBulk, + getBulkInvoiceAudit, printInvoicesBulk, + recordBulkInvoiceOperation, + type BulkInvoiceOperation, type BulkExportFormat, } from "@/lib/bulkInvoiceOps"; @@ -54,6 +57,10 @@ export default function BulkActionBar({ } | null>(null); const [showUndoToast, setShowUndoToast] = useState(false); const [showExportMenu, setShowExportMenu] = useState(false); + const [pendingOperation, setPendingOperation] = useState(null); + const [operationError, setOperationError] = useState(null); + const [auditEntries, setAuditEntries] = useState>([]); + const [showAudit, setShowAudit] = useState(false); const pendingCount = selectedInvoices.filter( (inv) => inv.status === "Pending", @@ -79,9 +86,23 @@ export default function BulkActionBar({ setShowUndoToast(true); setTimeout(() => setShowUndoToast(false), getUndoTimeout()); onArchiveChange?.(); + setPendingOperation(null); + setOperationError(null); + } catch (error) { + setOperationError(`Could not archive invoices: ${String(error)}`); + return; } finally { setArchiveLoading(false); } + try { + recordBulkInvoiceOperation({ + operation: "archive", + invoiceIds: idsToArchive, + timestamp: Date.now(), + }); + } catch (error) { + setOperationError(`Invoices were archived, but the audit entry could not be saved: ${String(error)}`); + } }; const handleUnarchive = async () => { @@ -98,9 +119,23 @@ export default function BulkActionBar({ setShowUndoToast(true); setTimeout(() => setShowUndoToast(false), getUndoTimeout()); onArchiveChange?.(); + setPendingOperation(null); + setOperationError(null); + } catch (error) { + setOperationError(`Could not unarchive invoices: ${String(error)}`); + return; } finally { setArchiveLoading(false); } + try { + recordBulkInvoiceOperation({ + operation: "unarchive", + invoiceIds: idsToUnarchive, + timestamp: Date.now(), + }); + } catch (error) { + setOperationError(`Invoices were unarchived, but the audit entry could not be saved: ${String(error)}`); + } }; const handleUndo = () => { @@ -112,6 +147,62 @@ export default function BulkActionBar({ } setShowUndoToast(false); onArchiveChange?.(); + try { + recordBulkInvoiceOperation({ + operation: "undo", + invoiceIds: undoAction.ids, + timestamp: Date.now(), + details: undoAction.isArchive ? "Undo archive" : "Undo unarchive", + }); + setOperationError(null); + } catch (error) { + setOperationError(`Undo completed, but the audit entry could not be saved: ${String(error)}`); + } + }; + + const confirmOperation = async () => { + if (!pendingOperation) return; + if (pendingOperation === "archive") { + await handleArchive(); + } else if (pendingOperation === "unarchive") { + await handleUnarchive(); + } else { + setArchiveLoading(true); + try { + await onBulkCancel(); + setPendingOperation(null); + setOperationError(null); + try { + recordBulkInvoiceOperation({ + operation: "cancel", + invoiceIds: selectedInvoices + .filter((invoice) => invoice.status === "Pending") + .map((invoice) => invoice.id), + timestamp: Date.now(), + }); + } catch (error) { + setOperationError(`Invoices were cancelled, but the audit entry could not be saved: ${String(error)}`); + } + } catch (error) { + setOperationError(`Could not cancel invoices: ${String(error)}`); + } finally { + setArchiveLoading(false); + } + } + }; + + const toggleAudit = () => { + if (showAudit) { + setShowAudit(false); + return; + } + try { + setAuditEntries(getBulkInvoiceAudit()); + setOperationError(null); + setShowAudit(true); + } catch (error) { + setOperationError(`Could not load the local operation audit: ${String(error)}`); + } }; // ── Export ───────────────────────────────────────────────────────────────── @@ -166,8 +257,12 @@ export default function BulkActionBar({ {pendingCount > 0 && ( @@ -177,7 +272,10 @@ export default function BulkActionBar({ {!archivedOnly && archivedCount < selectedCount && ( + + + + + )} + {/* Undo toast */} {showUndoToast && undoAction && (
diff --git a/src/components/CompareInvoicesView.tsx b/src/components/CompareInvoicesView.tsx index 1d59f6be..301dbb16 100644 --- a/src/components/CompareInvoicesView.tsx +++ b/src/components/CompareInvoicesView.tsx @@ -15,6 +15,7 @@ interface Props { } type DiffMode = "all" | "diffs-only"; +type ComparisonFilter = "all" | "financial" | "status" | "participants"; /** Returns a highlight wrapper class when two values differ */ function diffClass(a: string | number, b: string | number): string { @@ -48,6 +49,7 @@ function formatDelta(a: bigint, b: bigint): { label: string; positive: boolean; */ export default function CompareInvoicesView({ invoiceA, invoiceB, onPayA, onPayB }: Props) { const [diffMode, setDiffMode] = useState("all"); + const [comparisonFilter, setComparisonFilter] = useState("all"); const [activeSection, setActiveSection] = useState<"overview" | "details">("overview"); const totalA = invoiceA.recipients.reduce((s, r) => s + r.amount, 0n); @@ -167,10 +169,16 @@ export default function CompareInvoicesView({ invoiceA, invoiceB, onPayA, onPayB }, ]; - const visibleRows = - diffMode === "diffs-only" - ? rows.filter((r) => String(r.rawA) !== String(r.rawB)) - : rows; + const visibleRows = rows.filter((row) => { + const isDifferent = String(row.rawA) !== String(row.rawB); + const matchesDiffMode = diffMode === "all" || isDifferent; + const matchesCategory = + comparisonFilter === "all" || + (comparisonFilter === "financial" && ["Total Amount", "Funded", "Progress", "Token"].includes(row.label)) || + (comparisonFilter === "status" && ["Status", "Deadline"].includes(row.label)) || + (comparisonFilter === "participants" && ["Recipients", "Payments"].includes(row.label)); + return matchesDiffMode && matchesCategory; + }); const diffCount = rows.filter((r) => String(r.rawA) !== String(r.rawB)).length; @@ -178,6 +186,43 @@ export default function CompareInvoicesView({ invoiceA, invoiceB, onPayA, onPayB const totalDelta = formatDelta(totalA, totalB); const fundedDelta = formatDelta(invoiceA.funded, invoiceB.funded); + const exportComparison = (format: "csv" | "json") => { + const rowsToExport = visibleRows.map((row) => ({ + field: row.label, + invoiceA: row.rawA, + invoiceB: row.rawB, + differs: String(row.rawA) !== String(row.rawB), + })); + const content = + format === "json" + ? JSON.stringify( + { + invoiceIds: [String(invoiceA.id), String(invoiceB.id)], + filters: { diffMode, category: comparisonFilter }, + differences: diffCount, + rows: rowsToExport, + }, + null, + 2, + ) + : [ + "Field,Invoice A,Invoice B,Different", + ...rowsToExport.map((row) => + [row.field, row.invoiceA, row.invoiceB, row.differs] + .map((value) => `"${String(value).replace(/"/g, '""')}"`) + .join(","), + ), + ].join("\n"); + const mimeType = format === "json" ? "application/json" : "text/csv"; + const blob = new Blob([content], { type: `${mimeType};charset=utf-8` }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `invoice-comparison-${invoiceA.id}-vs-${invoiceB.id}.${format}`; + anchor.click(); + URL.revokeObjectURL(url); + }; + return (
Highlighted rows differ
-
+
+ + + + )} @@ -781,7 +781,7 @@ export default function DashboardClient() { {/* Bulk reminder results */} {compareMode && (

- Select up to 2 invoices to compare side-by-side. + Select 2–10 invoices to compare against a baseline.

)} diff --git a/src/components/invoice/FraudRiskPanel.tsx b/src/components/invoice/FraudRiskPanel.tsx index 1bfd76a3..4560b6e0 100644 --- a/src/components/invoice/FraudRiskPanel.tsx +++ b/src/components/invoice/FraudRiskPanel.tsx @@ -20,7 +20,7 @@ interface Props { * FraudRiskPanel — displays fraud risk assessment for an invoice * * Shows a score gauge, tier classification, and expandable signal breakdown table. - * High-risk invoices (score > 70) can trigger a confirmation requirement. + * Scores are heuristic review signals and do not block invoice actions. */ export default function FraudRiskPanel({ invoice, @@ -46,7 +46,7 @@ export default function FraudRiskPanel({ {/* Header with score gauge */}

- Fraud Risk Assessment + Heuristic Risk Assessment

{/* Score gauge */} @@ -99,7 +99,7 @@ export default function FraudRiskPanel({

{isHighRisk && (

- ⚠️ Requires confirmation + ⚠️ Review recommended

)}
@@ -108,14 +108,17 @@ export default function FraudRiskPanel({ {/* High-risk warning banner */} {isHighRisk && ( -
+

- ⚠️ High fraud risk detected. Please review the signals below before - proceeding with payment. + ⚠️ Elevated heuristic risk signals detected. Review the breakdown; this score is not proof of fraud and may include false positives.

)} +

+ Heuristic estimate only; this is not a trained machine-learning model. +

+ {/* Signal breakdown */}