whisper-money/resources/js/pages/transactions/index.tsx

1711 lines
62 KiB
TypeScript

import { Head, router } 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 { format, getYear, isWithinInterval, parse, 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 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 { useSyncContext } from '@/contexts/sync-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 { appendNoteIfNotPresent, cn } from '@/lib/utils';
import { transactionSyncService } from '@/services/transaction-sync';
import { type BreadcrumbItem } from '@/types';
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';
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Transactions',
href: transactionsIndex.url(),
},
];
interface Props {
categories: Category[];
accounts: Account[];
banks: Bank[];
labels: Label[];
automationRules: AutomationRule[];
}
const COLUMN_VISIBILITY_KEY = 'transactions-column-visibility';
/**
* Parse filters from URL query parameters
*/
function parseFiltersFromURL(): Filters {
if (typeof window === 'undefined') {
return {
dateFrom: null,
dateTo: null,
amountMin: null,
amountMax: null,
categoryIds: [],
accountIds: [],
labelIds: [],
searchText: '',
};
}
const urlParams = new URLSearchParams(window.location.search);
// Parse dates
let dateFrom: Date | null = null;
let dateTo: Date | null = null;
const dateFromParam = urlParams.get('dateFrom');
const dateToParam = urlParams.get('dateTo');
if (dateFromParam) {
try {
const parsed = parse(dateFromParam, 'yyyy-MM-dd', new Date());
if (!isNaN(parsed.getTime())) {
dateFrom = parsed;
}
} catch {
// Invalid date, ignore
}
}
if (dateToParam) {
try {
const parsed = parse(dateToParam, 'yyyy-MM-dd', new Date());
if (!isNaN(parsed.getTime())) {
dateTo = parsed;
}
} catch {
// Invalid date, ignore
}
}
// Parse amounts
const amountMinParam = urlParams.get('amountMin');
const amountMaxParam = urlParams.get('amountMax');
const amountMin =
amountMinParam !== null ? parseFloat(amountMinParam) || null : null;
const amountMax =
amountMaxParam !== null ? parseFloat(amountMaxParam) || null : null;
// Parse ID arrays (comma-separated)
const categoryIdsParam = urlParams.get('categoryIds');
const accountIdsParam = urlParams.get('accountIds');
const labelIdsParam = urlParams.get('labelIds');
const categoryIds = categoryIdsParam
? categoryIdsParam.split(',').filter(Boolean)
: [];
const accountIds = accountIdsParam
? accountIdsParam.split(',').filter(Boolean)
: [];
const labelIds = labelIdsParam
? labelIdsParam.split(',').filter(Boolean)
: [];
// Parse search text
const searchText = urlParams.get('search') || '';
return {
dateFrom,
dateTo,
amountMin,
amountMax,
categoryIds,
accountIds,
labelIds,
searchText,
};
}
/**
* Serialize filters to URL query parameters
*/
function serializeFiltersToURL(filters: Filters): Record<string, string> {
const params: Record<string, string> = {};
if (filters.dateFrom) {
params.dateFrom = format(filters.dateFrom, 'yyyy-MM-dd');
}
if (filters.dateTo) {
params.dateTo = format(filters.dateTo, 'yyyy-MM-dd');
}
if (filters.amountMin !== null) {
params.amountMin = filters.amountMin.toString();
}
if (filters.amountMax !== null) {
params.amountMax = filters.amountMax.toString();
}
if (filters.categoryIds.length > 0) {
params.categoryIds = filters.categoryIds.join(',');
}
if (filters.accountIds.length > 0) {
params.accountIds = filters.accountIds.join(',');
}
if (filters.labelIds.length > 0) {
params.labelIds = filters.labelIds.join(',');
}
if (filters.searchText) {
params.search = filters.searchText;
}
return params;
}
interface TransactionRowProps {
row: Row<DecryptedTransaction>;
virtualRow: VirtualItem;
rowVirtualizer: Virtualizer<HTMLDivElement, Element>;
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<HTMLTableRowElement>) => {
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 (
<ContextMenu key={row.id} onOpenChange={setContextMenuOpen}>
<ContextMenuTrigger asChild>
<TableRow
ref={rowVirtualizer.measureElement}
data-state={
(row.getIsSelected() || contextMenuOpen) && 'selected'
}
data-index={virtualRow.index}
className="cursor-pointer"
onClick={handleRowClick}
>
{row
.getVisibleCells()
.filter((cell: Cell<DecryptedTransaction, unknown>) => {
const meta = cell.column.columnDef.meta as
| {
isVirtual?: boolean;
cellClassName?: string;
cellStyle?: React.CSSProperties;
}
| undefined;
return !meta?.isVirtual;
})
.map((cell: Cell<DecryptedTransaction, unknown>) => {
const meta = cell.column.columnDef.meta as
| {
cellClassName?: string;
cellStyle?: React.CSSProperties;
}
| undefined;
return (
<TableCell
key={cell.id}
className={cn(
meta?.cellClassName,
'pt-2.5 pb-2',
)}
style={meta?.cellStyle}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
);
})}
</TableRow>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuLabel>Actions</ContextMenuLabel>
<ContextMenuItem onClick={() => onEdit(transaction)}>
Edit
</ContextMenuItem>
<ContextMenuItem onClick={() => onReEvaluateRules(transaction)}>
Re-evaluate rules
</ContextMenuItem>
<ContextMenuItem
onClick={() => onDelete(transaction)}
variant="destructive"
>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
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 (
<tr className="hidden">
<td
colSpan={colSpan}
className="px-4 pt-2 text-sm text-muted-foreground"
>
{format(parsedDate, formatString)}
</td>
</tr>
);
}
export default function Transactions({
categories,
accounts,
banks,
labels: initialLabels,
automationRules,
}: Props) {
const { isKeySet } = useEncryptionKey();
const { sync } = useSyncContext();
// Sync transactions when page loads
useEffect(() => {
sync();
}, [sync]);
const transactionIds = useLiveQuery(
async () => {
const txs = await db.transactions.toArray();
return txs
.map((t) => `${t.id}:${t.updated_at}`)
.sort()
.join(',');
},
[],
'',
);
const [transactions, setTransactions] = useState<DecryptedTransaction[]>(
[],
);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState<SortingState>([
{ id: 'transaction_date', desc: true },
]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
getInitialColumnVisibility(),
);
const [rowSelection, setRowSelection] = useState({});
const [filters, setFilters] = useState<Filters>(() =>
parseFiltersFromURL(),
);
const labels = initialLabels;
const [editTransaction, setEditTransaction] =
useState<DecryptedTransaction | null>(null);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [deleteTransaction, setDeleteTransaction] =
useState<DecryptedTransaction | null>(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 [isSelectingAll, setIsSelectingAll] = 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;
// Map label_ids to Label objects
const transactionLabels =
transaction.label_ids
?.map((labelId) =>
labels.find((l) => l.id === labelId),
)
.filter(
(label): label is Label =>
label !== undefined,
) || [];
return {
...transaction,
decryptedDescription,
decryptedNotes,
account,
category: category || null,
bank,
labels: transactionLabels,
} 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, labels, isKeySet]);
useEffect(() => {
try {
localStorage.setItem(
COLUMN_VISIBILITY_KEY,
JSON.stringify(columnVisibility),
);
} catch (error) {
console.error(
'Failed to save column visibility to localStorage:',
error,
);
}
}, [columnVisibility]);
// Sync filters to URL
useEffect(() => {
const params = serializeFiltersToURL(filters);
const currentParams = new URLSearchParams(window.location.search);
const currentParamsObj: Record<string, string> = {};
currentParams.forEach((value, key) => {
currentParamsObj[key] = value;
});
// Check if params have changed
const hasChanged =
JSON.stringify(params) !== JSON.stringify(currentParamsObj);
if (hasChanged) {
router.visit(transactionsIndex({ query: params }).url, {
preserveScroll: true,
preserveState: true,
replace: true,
});
}
}, [filters]);
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.labelIds.length > 0) {
const transactionLabelIds =
transaction.labels?.map((l) => l.id) || [];
const hasMatchingLabel = filters.labelIds.some((labelId) =>
transactionLabelIds.includes(labelId),
);
if (!hasMatchingLabel) {
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 = automationRules;
consoleDebug(`Found ${rules.length} automation rules`);
if (rules.length === 0) {
consoleDebug('❌ No rules to evaluate');
return;
}
consoleDebug('Evaluating rules against transaction...');
const result = await evaluateRules(
transaction,
rules,
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 BATCH_SIZE = 25;
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 = automationRules;
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',
})),
);
// Collect all updates first without updating IndexedDB
const allUpdates: Array<{
transaction: DecryptedTransaction;
categoryId: number | null;
category: Category | null;
notes: string | null;
notesIv: string | null;
decryptedNotes: string | null;
}> = [];
const dbUpdates: Array<{
id: string;
data: {
category_id: number | null;
notes: string | null;
notes_iv: string | null;
};
}> = [];
for (const transaction of selectedTransactions) {
consoleDebug(`\nEvaluating transaction ${transaction.id}...`);
const result = await evaluateRules(
transaction,
rules,
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('Queuing update for transaction:', updateData);
// Collect for batch IndexedDB update
dbUpdates.push({
id: transaction.id,
data: updateData,
});
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,
);
}
allUpdates.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}`,
);
}
}
// Batch update IndexedDB
if (dbUpdates.length > 0) {
consoleDebug(
`\nBatch updating ${dbUpdates.length} transactions in IndexedDB...`,
);
await transactionSyncService.updateManyIndividual(dbUpdates);
consoleDebug('✓ IndexedDB batch update complete');
}
// Update UI state in batches
consoleDebug(
`\nApplying ${allUpdates.length} updates to UI state in batches of ${BATCH_SIZE}...`,
);
if (allUpdates.length > 0) {
for (let i = 0; i < allUpdates.length; i += BATCH_SIZE) {
const batch = allUpdates.slice(i, i + BATCH_SIZE);
const batchIds = new Set(
batch.map((u) => u.transaction.id),
);
consoleDebug(
`Applying batch ${Math.floor(i / BATCH_SIZE) + 1} (${batch.length} updates)...`,
);
setTransactions((previous) =>
previous.map((transaction) => {
if (!batchIds.has(transaction.id)) {
return transaction;
}
const update = batch.find(
(u) => u.transaction.id === transaction.id,
);
if (update) {
return {
...transaction,
category_id: update.categoryId,
category: update.category,
notes: update.notes,
notes_iv: update.notesIv,
decryptedNotes: update.decryptedNotes,
};
}
return transaction;
}),
);
// Small delay between batches to allow React to process renders
if (i + BATCH_SIZE < allUpdates.length) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
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,
labels,
onEdit: setEditTransaction,
onDelete: setDeleteTransaction,
onUpdate: updateTransaction,
onReEvaluateRules: handleReEvaluateRules,
}),
[
accounts,
banks,
categories,
labels,
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({});
// Sync to update IndexedDB
sync();
} 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;
}
if (selectedIds.length === 0 && !isSelectingAll) {
return;
}
setIsBulkUpdating(true);
try {
const categoriesMap = new Map(
categories.map((category) => [category.id, category]),
);
const selectedCategory = categoryId
? categoriesMap.get(categoryId) || null
: null;
if (isSelectingAll) {
// Update via filters
await transactionSyncService.updateByFilters(filters, {
category_id: categoryId,
});
// Optimistically update matching transactions in state
setTransactions((previous) =>
previous.map((transaction) => ({
...transaction,
category_id: categoryId,
category: selectedCategory,
})),
);
toast.success(
`Updated ${sortedTransactions.length} transactions`,
);
} else {
// Update selected transactions
await transactionSyncService.updateMany(selectedIds, {
category_id: categoryId,
});
// Optimistically update selected transactions in state
setTransactions((previous) =>
previous.map((transaction) => {
if (selectedIds.includes(transaction.id.toString())) {
return {
...transaction,
category_id: categoryId,
category: selectedCategory,
};
}
return transaction;
}),
);
toast.success(`Updated ${selectedIds.length} transactions`);
}
setRowSelection({});
setIsSelectingAll(false);
// Sync to update IndexedDB
sync();
} catch (error) {
console.error('Failed to update transactions:', error);
toast.error('Failed to update transactions');
} finally {
setIsBulkUpdating(false);
}
}
const selectedIds = useMemo(
() => Object.keys(rowSelection),
[rowSelection],
);
const selectedCount = useMemo(() => selectedIds.length, [selectedIds]);
function handleBulkDeleteClick() {
if (selectedIds.length === 0) {
return;
}
// Defer the find operation until delete is actually clicked
const firstSelectedTransaction = filteredTransactions.find(
(t) => t.id.toString() === selectedIds[0],
);
if (firstSelectedTransaction) {
setIsBulkDeleteMode(true);
setDeleteTransaction(firstSelectedTransaction);
}
}
async function handleBulkDelete() {
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({});
// Sync to update IndexedDB
sync();
} catch (error) {
console.error('Failed to delete transactions:', error);
} finally {
setIsBulkDeleting(false);
}
}
async function handleBulkLabelsChange(labelIds: string[]) {
if (selectedIds.length === 0 && !isSelectingAll) {
return;
}
setIsBulkUpdating(true);
try {
const selectedLabels = labels.filter((l) =>
labelIds.includes(l.id),
);
if (isSelectingAll) {
// Update via filters
await transactionSyncService.updateByFilters(filters, {
label_ids: labelIds,
});
// Optimistically update matching transactions in state
setTransactions((previous) =>
previous.map((transaction) => {
// If labelIds is empty, remove all labels
if (labelIds.length === 0) {
return {
...transaction,
labels: [],
};
}
// Otherwise, merge with existing labels
const existingLabels = transaction.labels || [];
const mergedLabels = [
...existingLabels,
...selectedLabels.filter(
(l) =>
!existingLabels.some(
(el) => el.id === l.id,
),
),
];
return {
...transaction,
labels: mergedLabels,
};
}),
);
toast.success(
`Updated ${sortedTransactions.length} transactions`,
);
} else {
// Update selected transactions
await transactionSyncService.updateMany(selectedIds, {
label_ids: labelIds,
});
// Optimistically update selected transactions in state
setTransactions((previous) =>
previous.map((transaction) => {
if (selectedIds.includes(transaction.id.toString())) {
// If labelIds is empty, remove all labels
if (labelIds.length === 0) {
return {
...transaction,
labels: [],
};
}
// Otherwise, merge with existing labels
const existingLabels = transaction.labels || [];
const mergedLabels = [
...existingLabels,
...selectedLabels.filter(
(l) =>
!existingLabels.some(
(el) => el.id === l.id,
),
),
];
return {
...transaction,
labels: mergedLabels,
};
}
return transaction;
}),
);
toast.success(`Updated ${selectedIds.length} transactions`);
}
setRowSelection({});
setIsSelectingAll(false);
// Sync to update IndexedDB
sync();
} catch (error) {
console.error('Failed to update transactions with labels:', error);
toast.error('Failed to update transactions with labels');
} finally {
setIsBulkUpdating(false);
}
}
function handleClearSelection() {
setRowSelection({});
setIsSelectingAll(false);
}
const handleSelectAll = useCallback(() => {
setIsSelectingAll(true);
// Use requestAnimationFrame to defer the expensive reduce operation
requestAnimationFrame(() => {
const allIds = sortedTransactions.reduce(
(acc, transaction) => {
acc[transaction.id.toString()] = true;
return acc;
},
{} as Record<string, boolean>,
);
setRowSelection(allIds);
});
}, [sortedTransactions]);
const renderTransactionRow = useCallback(
(
row: Row<DecryptedTransaction>,
virtualRow: VirtualItem,
rowVirtualizer: Virtualizer<HTMLDivElement, Element>,
) => {
return (
<TransactionRowComponent
key={row.id}
row={row}
virtualRow={virtualRow}
rowVirtualizer={rowVirtualizer}
onEdit={setEditTransaction}
onReEvaluateRules={handleReEvaluateRules}
onDelete={setDeleteTransaction}
/>
);
},
[handleReEvaluateRules],
);
return (
<AppSidebarLayout breadcrumbs={breadcrumbs}>
<Head title="Transactions" />
<div className="space-y-6 p-6">
<HeadingSmall
title="Transactions"
description="View and manage your transactions"
/>
<div className="space-y-4">
<TransactionFiltersComponent
filters={filters}
onFiltersChange={setFilters}
categories={categories}
labels={labels}
accounts={accounts}
isKeySet={isKeySet}
actions={
<div className="flex w-full justify-between gap-2 sm:justify-end">
<TransactionActionsMenu
categories={categories}
accounts={accounts}
banks={banks}
onAddTransaction={() =>
setCreateDialogOpen(true)
}
transactions={transactions}
onReEvaluateComplete={() => {
setRowSelection({});
setTimeout(() => {
window.location.reload();
}, 500);
}}
/>
<DataTableViewOptions table={table} />
</div>
}
/>
{isLoading ? (
<div className="space-y-4">
<div className="overflow-hidden rounded-md border">
<div className="grid grid-cols-4 gap-4 border-b p-4">
<Skeleton className="h-5 w-8" />
<Skeleton className="h-5 w-24" />
<Skeleton className="h-5 w-64" />
<Skeleton className="h-5 w-20 justify-self-end" />
</div>
<div className="divide-y">
<div className="bg-muted/50 px-4 py-2">
<Skeleton className="h-4 w-32" />
</div>
{Array.from({ length: 6 }).map(
(_, index) => (
<div
key={index}
className="grid grid-cols-4 gap-4 p-4"
>
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-28" />
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-3 w-48" />
</div>
<Skeleton className="h-4 w-20 justify-self-end" />
</div>
),
)}
</div>
</div>
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-9 w-48" />
</div>
</div>
) : (
<>
<DataTable
table={table}
columns={columns}
emptyMessage="No transactions found."
renderRow={renderTransactionRow}
getRowDate={(row) => row.transaction_date}
renderDateHeader={(date, colSpan) => (
<DateHeader date={date} colSpan={colSpan} />
)}
/>
<DataTablePagination
displayedCount={displayedCount}
total={sortedTransactions.length}
rowCountLabel="transactions total"
>
{displayedCount < sortedTransactions.length && (
<Button
onClick={loadMore}
disabled={isLoadingMore}
variant="outline"
>
{isLoadingMore ? (
<>
<Spinner />
Loading
</>
) : (
<>Load more</>
)}
</Button>
)}
</DataTablePagination>
</>
)}
</div>
</div>
<EditTransactionDialog
transaction={editTransaction}
categories={categories}
accounts={accounts}
banks={banks}
labels={labels}
open={!!editTransaction}
onOpenChange={(open) => !open && setEditTransaction(null)}
onSuccess={updateTransaction}
mode="edit"
/>
<EditTransactionDialog
transaction={null}
categories={categories}
accounts={accounts}
banks={banks}
labels={labels}
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSuccess={() => {}}
mode="create"
/>
<AlertDialog
open={!!deleteTransaction}
onOpenChange={(open) => {
if (!open) {
setDeleteTransaction(null);
setIsBulkDeleteMode(false);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Delete Transaction
{isBulkDeleteMode ? 's' : ''}
</AlertDialogTitle>
<AlertDialogDescription>
{isBulkDeleteMode
? `Are you sure you want to delete ${selectedCount} transactions? This action cannot be undone.`
: 'Are you sure you want to delete this transaction? This action cannot be undone.'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
disabled={isDeleting || isBulkDeleting}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={
isBulkDeleteMode
? handleBulkDelete
: handleDelete
}
disabled={isDeleting || isBulkDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting || isBulkDeleting
? 'Deleting...'
: 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<BulkActionsBar
selectedCount={selectedCount}
totalFilteredCount={sortedTransactions.length}
isSelectingAll={isSelectingAll}
categories={categories}
labels={labels}
onCategoryChange={handleBulkCategoryChange}
onLabelsChange={handleBulkLabelsChange}
onDelete={handleBulkDeleteClick}
onReEvaluateRules={handleBulkReEvaluateRules}
onSelectAll={handleSelectAll}
onClear={handleClearSelection}
isUpdating={isBulkUpdating || isReEvaluating}
/>
</AppSidebarLayout>
);
}