import { Cell, ColumnFiltersState, Row, SortingState, VisibilityState, flexRender, getCoreRowModel, getFilteredRowModel, getSortedRowModel, useReactTable, } from '@tanstack/react-table'; import { VirtualItem, Virtualizer } from '@tanstack/react-virtual'; import { format, getYear, isWithinInterval, parseISO } from 'date-fns'; import { useLiveQuery } from 'dexie-react-hooks'; import { type ReactNode, useCallback, useEffect, useMemo, useState, } from 'react'; import { toast } from 'sonner'; import { BulkActionsBar } from '@/components/transactions/bulk-actions-bar'; import { EditTransactionDialog } from '@/components/transactions/edit-transaction-dialog'; import { TransactionActionsMenu } from '@/components/transactions/transaction-actions-menu'; import { createTransactionColumns } from '@/components/transactions/transaction-columns'; import { TransactionFilters as TransactionFiltersComponent } from '@/components/transactions/transaction-filters'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuLabel, ContextMenuTrigger, } from '@/components/ui/context-menu'; import { DataTable } from '@/components/ui/data-table'; import { DataTablePagination } from '@/components/ui/data-table-pagination'; import { DataTableViewOptions } from '@/components/ui/data-table-view-options'; import { Skeleton } from '@/components/ui/skeleton'; import { Spinner } from '@/components/ui/spinner'; import { TableCell, TableRow } from '@/components/ui/table'; import { useEncryptionKey } from '@/contexts/encryption-key-context'; import { decrypt, encrypt, importKey } from '@/lib/crypto'; import { consoleDebug } from '@/lib/debug'; import { db } from '@/lib/dexie-db'; import { getStoredKey } from '@/lib/key-storage'; import { evaluateRules } from '@/lib/rule-engine'; import { appendNoteIfNotPresent } from '@/lib/utils'; import { transactionSyncService } from '@/services/transaction-sync'; import { type Account, type Bank } from '@/types/account'; import { type AutomationRule } from '@/types/automation-rule'; import { type Category } from '@/types/category'; import { type Label } from '@/types/label'; import { type DecryptedTransaction, type TransactionFilters as Filters, } from '@/types/transaction'; import { UUID } from '@/types/uuid'; const COLUMN_VISIBILITY_KEY = 'transactions-column-visibility'; interface TransactionRowProps { row: Row; virtualRow: VirtualItem; rowVirtualizer: Virtualizer; onEdit: (transaction: DecryptedTransaction) => void; onReEvaluateRules: (transaction: DecryptedTransaction) => void; onDelete: (transaction: DecryptedTransaction) => void; } function TransactionRowComponent({ row, virtualRow, rowVirtualizer, onEdit, onReEvaluateRules, onDelete, }: TransactionRowProps) { const transaction = row.original; const [contextMenuOpen, setContextMenuOpen] = useState(false); const handleRowClick = (event: React.MouseEvent) => { const target = event.target as HTMLElement; const isInteractive = target.closest( 'button, [role="checkbox"], [role="combobox"], [role="menuitem"], [role="option"], [cmdk-item], a, input, select, textarea, [data-radix-popper-content-wrapper]', ); if (isInteractive) { return; } onEdit(transaction); }; return ( {row .getVisibleCells() .filter((cell: Cell) => { const meta = cell.column.columnDef.meta as | { isVirtual?: boolean; cellClassName?: string; cellStyle?: React.CSSProperties; } | undefined; return !meta?.isVirtual; }) .map((cell: Cell) => { const meta = cell.column.columnDef.meta as | { cellClassName?: string; cellStyle?: React.CSSProperties; } | undefined; return ( {flexRender( cell.column.columnDef.cell, cell.getContext(), )} ); })} Actions onEdit(transaction)}> Edit onReEvaluateRules(transaction)}> Re-evaluate rules onDelete(transaction)} variant="destructive" > Delete ); } function getInitialColumnVisibility(): VisibilityState { const defaultVisibility = { transaction_date: true, account: true, labels: true, notes: false, }; if (typeof window === 'undefined') { return defaultVisibility; } try { const stored = localStorage.getItem(COLUMN_VISIBILITY_KEY); if (stored) { return { ...defaultVisibility, ...JSON.parse(stored) }; } } catch (error) { console.error( 'Failed to load column visibility from localStorage:', error, ); } return defaultVisibility; } function DateHeader({ date, colSpan }: { date: string; colSpan: number }) { const parsedDate = parseISO(date); const currentYear = getYear(new Date()); const transactionYear = getYear(parsedDate); const formatString = transactionYear === currentYear ? 'MMM d' : 'MMM d, yy'; return ( {format(parsedDate, formatString)} ); } export interface TransactionListProps { categories: Category[]; accounts: Account[]; banks: Bank[]; labels?: Label[]; automationRules?: AutomationRule[]; accountId?: UUID; pageSize?: number; hideAccountFilter?: boolean; showActionsMenu?: boolean; headerActions?: ReactNode; maxHeight?: number; hideColumns?: string[]; } export function TransactionList({ categories, accounts, banks, labels: initialLabels = [], automationRules = [], accountId, pageSize = 25, hideAccountFilter = false, showActionsMenu = true, headerActions, maxHeight, hideColumns = [], }: TransactionListProps) { const { isKeySet } = useEncryptionKey(); const transactionIds = useLiveQuery( async () => { const txs = await db.transactions.toArray(); return txs .map((t) => t.id) .sort() .join(','); }, [], '', ); const labels = initialLabels; const [transactions, setTransactions] = useState( [], ); const [isLoading, setIsLoading] = useState(true); const [sorting, setSorting] = useState([ { id: 'transaction_date', desc: true }, ]); const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState( getInitialColumnVisibility(), ); const [rowSelection, setRowSelection] = useState({}); const [filters, setFilters] = useState({ dateFrom: null, dateTo: null, amountMin: null, amountMax: null, categoryIds: [], accountIds: accountId ? [accountId] : [], labelIds: [], searchText: '', }); const [editTransaction, setEditTransaction] = useState(null); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [deleteTransaction, setDeleteTransaction] = useState(null); const [isBulkDeleteMode, setIsBulkDeleteMode] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [isBulkDeleting, setIsBulkDeleting] = useState(false); const [isBulkUpdating, setIsBulkUpdating] = useState(false); const [isReEvaluating, setIsReEvaluating] = useState(false); const [displayedCount, setDisplayedCount] = useState(pageSize); const [isLoadingMore, setIsLoadingMore] = useState(false); const updateTransaction = useCallback( (updatedTransaction: DecryptedTransaction) => { setTransactions((previous) => previous.map((transaction) => { if (transaction.id !== updatedTransaction.id) { return transaction; } return { ...transaction, ...updatedTransaction, account: updatedTransaction.account === undefined ? transaction.account : updatedTransaction.account, bank: updatedTransaction.bank === undefined ? transaction.bank : updatedTransaction.bank, category: updatedTransaction.category === undefined ? (transaction.category ?? null) : updatedTransaction.category, }; }), ); }, [setTransactions], ); useEffect(() => { async function processTransactions() { if (transactionIds === undefined) { setIsLoading(true); return; } setIsLoading(true); try { let rawTransactions = await db.transactions.toArray(); if (accountId) { rawTransactions = rawTransactions.filter( (t) => t.account_id === accountId, ); } const accountsMap = new Map( accounts.map((account) => [account.id, account]), ); const categoriesMap = new Map( categories.map((category) => [category.id, category]), ); const banksMap = new Map(banks.map((bank) => [bank.id, bank])); const keyString = getStoredKey(); let key: CryptoKey | null = null; if (keyString && isKeySet) { try { key = await importKey(keyString); } catch (error) { console.error( 'Failed to import encryption key:', error, ); } } const decrypted = await Promise.all( rawTransactions.map(async (transaction) => { try { let decryptedDescription = ''; let decryptedNotes: string | null = null; if (key) { try { decryptedDescription = await decrypt( transaction.description, key, transaction.description_iv, ); if ( transaction.notes && transaction.notes_iv ) { decryptedNotes = await decrypt( transaction.notes, key, transaction.notes_iv, ); } } catch (error) { console.error( 'Failed to decrypt transaction:', transaction.id, error, ); } } const account = accountsMap.get( transaction.account_id, ); const category = transaction.category_id ? categoriesMap.get(transaction.category_id) : null; const bank = account?.bank?.id ? banksMap.get(account.bank.id) : undefined; return { ...transaction, decryptedDescription, decryptedNotes, account, category: category || null, bank, } as DecryptedTransaction; } catch (error) { console.error( 'Failed to process transaction:', transaction.id, error, ); return null; } }), ); const validTransactions = decrypted.filter( (transaction): transaction is DecryptedTransaction => transaction !== null, ); validTransactions.sort((a, b) => { const dateA = parseISO(a.transaction_date).getTime(); const dateB = parseISO(b.transaction_date).getTime(); return dateB - dateA; }); setTransactions(validTransactions); } catch (error) { console.error('Failed to load transactions:', error); } finally { setIsLoading(false); } } processTransactions(); }, [transactionIds, accounts, banks, categories, isKeySet, accountId]); useEffect(() => { try { localStorage.setItem( COLUMN_VISIBILITY_KEY, JSON.stringify(columnVisibility), ); } catch (error) { console.error( 'Failed to save column visibility to localStorage:', error, ); } }, [columnVisibility]); useEffect(() => { async function reDecryptTransactions() { if (transactions.length === 0) { return; } const keyString = getStoredKey(); let key: CryptoKey | null = null; if (keyString && isKeySet) { try { key = await importKey(keyString); } catch (error) { console.error('Failed to import encryption key:', error); } } const reDecrypted = await Promise.all( transactions.map(async (transaction) => { try { let decryptedDescription = ''; let decryptedNotes: string | null = null; if (key) { try { decryptedDescription = await decrypt( transaction.description, key, transaction.description_iv, ); if (transaction.notes && transaction.notes_iv) { decryptedNotes = await decrypt( transaction.notes, key, transaction.notes_iv, ); } } catch (error) { console.error( 'Failed to decrypt transaction:', transaction.id, error, ); } } return { ...transaction, decryptedDescription, decryptedNotes, } as DecryptedTransaction; } catch (error) { console.error( 'Failed to process transaction:', transaction.id, error, ); return transaction; } }), ); setTransactions(reDecrypted); } reDecryptTransactions(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isKeySet]); const filteredTransactions = useMemo(() => { return transactions.filter((transaction) => { if (filters.dateFrom || filters.dateTo) { const transactionDate = parseISO(transaction.transaction_date); if ( filters.dateFrom && filters.dateTo && !isWithinInterval(transactionDate, { start: filters.dateFrom, end: filters.dateTo, }) ) { return false; } if (filters.dateFrom && transactionDate < filters.dateFrom) { return false; } if (filters.dateTo && transactionDate > filters.dateTo) { return false; } } if ( filters.amountMin !== null && transaction.amount / 100 < filters.amountMin ) { return false; } if ( filters.amountMax !== null && transaction.amount / 100 > filters.amountMax ) { return false; } if ( filters.categoryIds.length > 0 && !filters.categoryIds.includes(transaction.category_id || -1) ) { return false; } if ( !accountId && filters.accountIds.length > 0 && !filters.accountIds.includes(transaction.account_id) ) { return false; } if (filters.searchText && isKeySet) { const searchLower = filters.searchText.toLowerCase(); const matchesDescription = transaction.decryptedDescription .toLowerCase() .includes(searchLower); const matchesNotes = transaction.decryptedNotes ?.toLowerCase() .includes(searchLower) || false; if (!matchesDescription && !matchesNotes) { return false; } } return true; }); }, [transactions, filters, isKeySet, accountId]); const sortedTransactions = useMemo(() => { if (sorting.length === 0) { return filteredTransactions; } const sorted = [...filteredTransactions]; sorted.sort((a, b) => { for (const sort of sorting) { const { id, desc } = sort; let comparison = 0; if (id === 'transaction_date') { const dateA = parseISO(a.transaction_date).getTime(); const dateB = parseISO(b.transaction_date).getTime(); comparison = dateA - dateB; } else if (id === 'amount') { comparison = parseFloat(a.amount) - parseFloat(b.amount); } else if (id === 'description') { comparison = a.decryptedDescription.localeCompare( b.decryptedDescription, ); } else if (id === 'account') { const accountA = a.account?.name || ''; const accountB = b.account?.name || ''; comparison = accountA.localeCompare(accountB); } else if (id === 'category') { const categoryA = a.category?.name || ''; const categoryB = b.category?.name || ''; comparison = categoryA.localeCompare(categoryB); } if (comparison !== 0) { return desc ? -comparison : comparison; } } return 0; }); return sorted; }, [filteredTransactions, sorting]); const displayedTransactions = useMemo(() => { return sortedTransactions.slice(0, displayedCount); }, [sortedTransactions, displayedCount]); const handleReEvaluateRules = useCallback( async (transaction: DecryptedTransaction) => { consoleDebug('=== Re-evaluating rules for single transaction ==='); consoleDebug('Transaction:', { id: transaction.id, description: transaction.decryptedDescription, amount: transaction.amount, currentCategory: transaction.category?.name || 'None', }); setIsReEvaluating(true); try { const keyString = getStoredKey(); if (!keyString || !isKeySet) { consoleDebug('❌ Encryption key not set'); console.error('Encryption key not set'); toast.error( 'Please unlock your encryption key to re-evaluate rules', ); return; } consoleDebug('✓ Encryption key found'); const key = await importKey(keyString); consoleDebug( `Found ${automationRules.length} automation rules`, ); if (automationRules.length === 0) { consoleDebug('❌ No rules to evaluate'); return; } consoleDebug('Evaluating rules against transaction...'); const result = await evaluateRules( transaction, automationRules, categories, accounts, banks, key, ); consoleDebug('Rule evaluation result:', result); if (result) { consoleDebug('✓ Rule matched! Applying changes...'); let finalNotes = transaction.notes; let finalNotesIv = transaction.notes_iv; if (result.note && result.noteIv) { consoleDebug('Adding note from rule'); const decryptedRuleNote = await decrypt( result.note, key, result.noteIv, ); const combinedNote = appendNoteIfNotPresent( transaction.decryptedNotes, decryptedRuleNote, ); if (combinedNote !== transaction.decryptedNotes) { const encrypted = await encrypt(combinedNote, key); finalNotes = encrypted.encrypted; finalNotesIv = encrypted.iv; consoleDebug('Combined notes with rule note'); } else { consoleDebug('Rule note already present, skipping'); } } const updateData = { category_id: result.categoryId, notes: finalNotes, notes_iv: finalNotesIv, }; consoleDebug('Updating transaction with:', updateData); await transactionSyncService.update( transaction.id, updateData, ); consoleDebug('✓ Transaction updated in IndexedDB'); const selectedCategory = result.categoryId ? categories.find((c) => c.id === result.categoryId) || null : null; let decryptedNotes = transaction.decryptedNotes; if (finalNotes && finalNotesIv) { decryptedNotes = await decrypt( finalNotes, key, finalNotesIv, ); } const updatedTransaction = { ...transaction, category_id: result.categoryId, category: selectedCategory, notes: finalNotes, notes_iv: finalNotesIv, decryptedNotes, }; consoleDebug('Updating UI state with:', { id: updatedTransaction.id, newCategory: selectedCategory?.name || 'None', hasNotes: !!decryptedNotes, }); updateTransaction(updatedTransaction); consoleDebug('✓ UI state updated successfully'); } else { consoleDebug('❌ No rules matched this transaction'); } } catch (error) { consoleDebug('❌ Error during re-evaluation:', error); console.error('Failed to re-evaluate rules:', error); } finally { setIsReEvaluating(false); consoleDebug('=== Re-evaluation complete ==='); } }, [ isKeySet, categories, accounts, banks, updateTransaction, automationRules, ], ); async function handleBulkReEvaluateRules() { const selectedIds = Object.keys(rowSelection); consoleDebug('=== Re-evaluating rules for bulk transactions ==='); consoleDebug(`Selected ${selectedIds.length} transactions`); if (selectedIds.length === 0) { consoleDebug('❌ No transactions selected'); return; } setIsReEvaluating(true); try { const keyString = getStoredKey(); if (!keyString || !isKeySet) { consoleDebug('❌ Encryption key not set'); console.error('Encryption key not set'); toast.error( 'Please unlock your encryption key to re-evaluate rules', ); return; } consoleDebug('✓ Encryption key found'); const key = await importKey(keyString); consoleDebug(`Found ${automationRules.length} automation rules`); if (automationRules.length === 0) { consoleDebug('❌ No rules to evaluate'); return; } const selectedTransactions = transactions.filter((t) => selectedIds.includes(t.id.toString()), ); consoleDebug( 'Processing transactions:', selectedTransactions.map((t) => ({ id: t.id, description: t.decryptedDescription, currentCategory: t.category?.name || 'None', })), ); const updates: Array<{ transaction: DecryptedTransaction; categoryId: number | null; category: Category | null; notes: string | null; notesIv: string | null; decryptedNotes: string | null; }> = []; for (const transaction of selectedTransactions) { consoleDebug(`\nEvaluating transaction ${transaction.id}...`); const result = await evaluateRules( transaction, automationRules, categories, accounts, banks, key, ); consoleDebug('Rule evaluation result:', result); if (result) { consoleDebug('✓ Rule matched! Applying changes...'); let finalNotes = transaction.notes; let finalNotesIv = transaction.notes_iv; if (result.note && result.noteIv) { consoleDebug('Adding note from rule'); const decryptedRuleNote = await decrypt( result.note, key, result.noteIv, ); const combinedNote = appendNoteIfNotPresent( transaction.decryptedNotes, decryptedRuleNote, ); if (combinedNote !== transaction.decryptedNotes) { const encrypted = await encrypt(combinedNote, key); finalNotes = encrypted.encrypted; finalNotesIv = encrypted.iv; consoleDebug('Combined notes with rule note'); } else { consoleDebug('Rule note already present, skipping'); } } const updateData = { category_id: result.categoryId, notes: finalNotes, notes_iv: finalNotesIv, }; consoleDebug('Updating transaction with:', updateData); await transactionSyncService.update( transaction.id, updateData, ); consoleDebug('✓ Transaction updated in IndexedDB'); const selectedCategory = result.categoryId ? categories.find((c) => c.id === result.categoryId) || null : null; let decryptedNotes = transaction.decryptedNotes; if (finalNotes && finalNotesIv) { decryptedNotes = await decrypt( finalNotes, key, finalNotesIv, ); } updates.push({ transaction, categoryId: result.categoryId, category: selectedCategory, notes: finalNotes, notesIv: finalNotesIv, decryptedNotes, }); consoleDebug( `✓ Queued update for transaction ${transaction.id}`, ); } else { consoleDebug( `❌ No rules matched transaction ${transaction.id}`, ); } } consoleDebug(`\nApplying ${updates.length} updates to UI state...`); if (updates.length > 0) { setTransactions((previous) => previous.map((transaction) => { const update = updates.find( (u) => u.transaction.id === transaction.id, ); if (update) { consoleDebug( `Updating UI for transaction ${transaction.id}:`, { newCategory: update.category?.name || 'None', hasNotes: !!update.decryptedNotes, }, ); return { ...transaction, category_id: update.categoryId, category: update.category, notes: update.notes, notes_iv: update.notesIv, decryptedNotes: update.decryptedNotes, }; } return transaction; }), ); consoleDebug('✓ UI state updated successfully'); } else { consoleDebug('❌ No updates to apply'); } consoleDebug('Clearing selection...'); setRowSelection({}); } catch (error) { consoleDebug('❌ Error during bulk re-evaluation:', error); console.error('Failed to re-evaluate rules:', error); } finally { setIsReEvaluating(false); consoleDebug('=== Bulk re-evaluation complete ==='); } } const columns = useMemo(() => { const allColumns = createTransactionColumns({ categories, accounts, banks, labels, onEdit: setEditTransaction, onDelete: setDeleteTransaction, onUpdate: updateTransaction, onReEvaluateRules: handleReEvaluateRules, }); if (hideColumns.length === 0) { return allColumns; } return allColumns.filter((column) => { const columnId = 'accessorKey' in column ? column.accessorKey : column.id; return !hideColumns.includes(columnId as string); }); }, [ accounts, banks, categories, labels, updateTransaction, handleReEvaluateRules, hideColumns, ]); const table = useReactTable({ data: displayedTransactions, columns, onSortingChange: setSorting, onColumnFiltersChange: setColumnFilters, getRowId: (row) => row.id.toString(), getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), onColumnVisibilityChange: setColumnVisibility, onRowSelectionChange: setRowSelection, enableRowSelection: true, state: { sorting, columnFilters, columnVisibility, rowSelection, }, }); const loadMore = useCallback(() => { if (displayedCount < sortedTransactions.length && !isLoadingMore) { setIsLoadingMore(true); requestAnimationFrame(() => { setDisplayedCount((prev) => Math.min(prev + pageSize, sortedTransactions.length), ); requestAnimationFrame(() => { setIsLoadingMore(false); }); }); } }, [displayedCount, sortedTransactions.length, isLoadingMore, pageSize]); useEffect(() => { setDisplayedCount(pageSize); }, [filters, sorting, pageSize]); async function handleDelete() { if (!deleteTransaction) { return; } setIsDeleting(true); try { await transactionSyncService.delete(deleteTransaction.id); setTransactions((previous) => previous.filter( (transaction) => transaction.id !== deleteTransaction.id, ), ); setDeleteTransaction(null); setIsBulkDeleteMode(false); setRowSelection({}); } catch (error) { console.error('Failed to delete transaction:', error); } finally { setIsDeleting(false); } } async function handleBulkCategoryChange(categoryId: number | null) { if (!isKeySet) { toast.error( 'Please unlock your encryption key to update transactions', ); return; } const selectedIds = Object.keys(rowSelection); if (selectedIds.length === 0) { return; } setIsBulkUpdating(true); try { await transactionSyncService.updateMany(selectedIds, { category_id: categoryId, }); const categoriesMap = new Map( categories.map((category) => [category.id, category]), ); const selectedCategory = categoryId ? categoriesMap.get(categoryId) || null : null; setTransactions((previous) => previous.map((transaction) => { if (selectedIds.includes(transaction.id.toString())) { return { ...transaction, category_id: categoryId, category: selectedCategory, }; } return transaction; }), ); setRowSelection({}); } catch (error) { console.error('Failed to update transactions:', error); } finally { setIsBulkUpdating(false); } } function handleBulkDeleteClick() { const selectedIds = Object.keys(rowSelection); if (selectedIds.length === 0) { return; } const firstSelectedTransaction = filteredTransactions.find( (t) => t.id.toString() === selectedIds[0], ); if (firstSelectedTransaction) { setIsBulkDeleteMode(true); setDeleteTransaction(firstSelectedTransaction); } } async function handleBulkDelete() { const selectedIds = Object.keys(rowSelection); if (selectedIds.length === 0) { return; } setIsBulkDeleting(true); try { await transactionSyncService.deleteMany(selectedIds); setTransactions((previous) => previous.filter( (transaction) => !selectedIds.includes(transaction.id), ), ); setDeleteTransaction(null); setIsBulkDeleteMode(false); setRowSelection({}); } catch (error) { console.error('Failed to delete transactions:', error); } finally { setIsBulkDeleting(false); } } function handleClearSelection() { setRowSelection({}); } const renderTransactionRow = useCallback( ( row: Row, virtualRow: VirtualItem, rowVirtualizer: Virtualizer, ) => { return ( ); }, [handleReEvaluateRules], ); return ( <>
{showActionsMenu && ( setCreateDialogOpen(true) } transactions={transactions} onReEvaluateComplete={() => { setRowSelection({}); setTimeout(() => { window.location.reload(); }, 500); }} /> )} {headerActions}
} /> {isLoading ? (
{Array.from({ length: 6 }).map((_, index) => (
))}
) : ( <> row.transaction_date} renderDateHeader={(date, colSpan) => ( )} /> {displayedCount < sortedTransactions.length && ( )} )} !open && setEditTransaction(null)} onSuccess={updateTransaction} mode="edit" /> {}} mode="create" /> { if (!open) { setDeleteTransaction(null); setIsBulkDeleteMode(false); } }} > Delete Transaction {isBulkDeleteMode ? 's' : ''} {isBulkDeleteMode ? `Are you sure you want to delete ${Object.keys(rowSelection).length} transactions? This action cannot be undone.` : 'Are you sure you want to delete this transaction? This action cannot be undone.'} Cancel {isDeleting || isBulkDeleting ? 'Deleting...' : 'Delete'} ); }