import { Head } from '@inertiajs/react'; import { Cell, ColumnFiltersState, Row, SortingState, VisibilityState, flexRender, getCoreRowModel, getFilteredRowModel, getSortedRowModel, useReactTable, } from '@tanstack/react-table'; import { VirtualItem, Virtualizer } from '@tanstack/react-virtual'; import { isWithinInterval, parseISO } from 'date-fns'; import { useLiveQuery } from 'dexie-react-hooks'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController'; import HeadingSmall from '@/components/heading-small'; 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 } 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 AppSidebarLayout from '@/layouts/app/app-sidebar-layout'; 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 { automationRuleSyncService } from '@/services/automation-rule-sync'; import { transactionSyncService } from '@/services/transaction-sync'; import { type BreadcrumbItem } from '@/types'; import { type Account, type Bank } from '@/types/account'; import { type Category } from '@/types/category'; import { type DecryptedTransaction, type TransactionFilters as Filters, } from '@/types/transaction'; const breadcrumbs: BreadcrumbItem[] = [ { title: 'Transactions', href: transactionsIndex.url(), }, ]; interface Props { categories: Category[]; accounts: Account[]; banks: Bank[]; } 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); return ( { } {row.getVisibleCells().map((cell: Cell) => ( {flexRender( cell.column.columnDef.cell, cell.getContext(), )} ))} Actions onEdit(transaction)}> Edit onReEvaluateRules(transaction)}> Re-evaluate rules onDelete(transaction)} variant="destructive" > Delete ); } function getInitialColumnVisibility(): VisibilityState { try { const stored = localStorage.getItem(COLUMN_VISIBILITY_KEY); if (stored) { return JSON.parse(stored); } } catch (error) { console.error( 'Failed to load column visibility from localStorage:', error, ); } return { account: false }; } export default function Transactions({ categories, accounts, banks }: Props) { const { isKeySet } = useEncryptionKey(); const transactionIds = useLiveQuery( async () => { const txs = await db.transactions.toArray(); return txs .map((t) => t.id) .sort() .join(','); }, [], '', ); 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: [], 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(25); 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 { const rawTransactions = await db.transactions.toArray(); 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]); 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 ( 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]); 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); const rules = await automationRuleSyncService.getAll(); consoleDebug(`Found ${rules.length} automation rules`); if (rules.length === 0) { consoleDebug('❌ No rules to evaluate'); return; } consoleDebug('Evaluating rules against transaction...'); const result = evaluateRules( transaction, rules, categories, accounts, banks, ); 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'); if (transaction.decryptedNotes) { const combinedNote = `${transaction.decryptedNotes}\n${await decrypt(result.note, key, result.noteIv)}`; const encrypted = await encrypt(combinedNote, key); finalNotes = encrypted.encrypted; finalNotesIv = encrypted.iv; consoleDebug( 'Combined existing notes with rule note', ); } else { finalNotes = result.note; finalNotesIv = result.noteIv; consoleDebug('Set rule note as new note'); } } 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], ); 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); const rules = await automationRuleSyncService.getAll(); consoleDebug(`Found ${rules.length} automation rules`); if (rules.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 = evaluateRules( transaction, rules, categories, accounts, banks, ); 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'); if (transaction.decryptedNotes) { const combinedNote = `${transaction.decryptedNotes}\n${await decrypt(result.note, key, result.noteIv)}`; const encrypted = await encrypt(combinedNote, key); finalNotes = encrypted.encrypted; finalNotesIv = encrypted.iv; consoleDebug( 'Combined existing notes with rule note', ); } else { finalNotes = result.note; finalNotesIv = result.noteIv; consoleDebug('Set rule note as new note'); } } 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( () => createTransactionColumns({ categories, accounts, banks, onEdit: setEditTransaction, onDelete: setDeleteTransaction, onUpdate: updateTransaction, onReEvaluateRules: handleReEvaluateRules, }), [accounts, banks, categories, updateTransaction, handleReEvaluateRules], ); 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 + 25, sortedTransactions.length), ); requestAnimationFrame(() => { setIsLoadingMore(false); }); }); } }, [displayedCount, sortedTransactions.length, isLoadingMore]); useEffect(() => { setDisplayedCount(25); }, [filters, sorting]); 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 (
setCreateDialogOpen(true) } transactions={transactions} onReEvaluateComplete={() => { setRowSelection({}); setTimeout(() => { window.location.reload(); }, 500); }} /> } /> {isLoading ? (
{Array.from({ length: 6 }).map( (_, index) => (
), )}
) : ( <> {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'}
); }