1218 lines
46 KiB
TypeScript
1218 lines
46 KiB
TypeScript
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<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);
|
|
|
|
return (
|
|
<ContextMenu key={row.id} onOpenChange={setContextMenuOpen}>
|
|
<ContextMenuTrigger asChild>
|
|
{ }
|
|
<TableRow
|
|
ref={rowVirtualizer.measureElement}
|
|
data-state={(row.getIsSelected() || contextMenuOpen) && 'selected'}
|
|
data-index={virtualRow.index}
|
|
>
|
|
{row.getVisibleCells().map((cell: Cell<DecryptedTransaction, unknown>) => (
|
|
<TableCell key={cell.id}>
|
|
{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 {
|
|
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<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>({
|
|
dateFrom: null,
|
|
dateTo: null,
|
|
amountMin: null,
|
|
amountMax: null,
|
|
categoryIds: [],
|
|
accountIds: [],
|
|
searchText: '',
|
|
});
|
|
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 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<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">
|
|
<TransactionFilters
|
|
filters={filters}
|
|
onFiltersChange={setFilters}
|
|
categories={categories}
|
|
accounts={accounts}
|
|
isKeySet={isKeySet}
|
|
actions={
|
|
<>
|
|
<TransactionActionsMenu
|
|
categories={categories}
|
|
accounts={accounts}
|
|
banks={banks}
|
|
onAddTransaction={() =>
|
|
setCreateDialogOpen(true)
|
|
}
|
|
transactions={transactions}
|
|
onReEvaluateComplete={() => {
|
|
setRowSelection({});
|
|
setTimeout(() => {
|
|
window.location.reload();
|
|
}, 500);
|
|
}}
|
|
/>
|
|
<DataTableViewOptions table={table} />
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{isLoading ? (
|
|
<div className="space-y-4">
|
|
<div className="overflow-hidden rounded-md border">
|
|
<div className="grid grid-cols-6 gap-4 border-b p-4">
|
|
<Skeleton className="h-5 w-24" />
|
|
<Skeleton className="h-5 w-28" />
|
|
<Skeleton className="h-5 w-64" />
|
|
<Skeleton className="h-5 w-28" />
|
|
<Skeleton className="h-5 w-28" />
|
|
<Skeleton className="h-5 w-16 justify-self-end" />
|
|
</div>
|
|
<div className="divide-y">
|
|
{Array.from({ length: 6 }).map(
|
|
(_, index) => (
|
|
<div
|
|
key={index}
|
|
className="grid grid-cols-6 gap-4 p-4"
|
|
>
|
|
<Skeleton className="h-4 w-32" />
|
|
<Skeleton className="h-4 w-36" />
|
|
<Skeleton className="h-4 w-full" />
|
|
<Skeleton className="h-4 w-32" />
|
|
<Skeleton className="h-4 w-40" />
|
|
<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}
|
|
/>
|
|
|
|
<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}
|
|
open={!!editTransaction}
|
|
onOpenChange={(open) => !open && setEditTransaction(null)}
|
|
onSuccess={updateTransaction}
|
|
mode="edit"
|
|
/>
|
|
|
|
<EditTransactionDialog
|
|
transaction={null}
|
|
categories={categories}
|
|
accounts={accounts}
|
|
banks={banks}
|
|
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 ${Object.keys(rowSelection).length} 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={Object.keys(rowSelection).length}
|
|
categories={categories}
|
|
onCategoryChange={handleBulkCategoryChange}
|
|
onDelete={handleBulkDeleteClick}
|
|
onReEvaluateRules={handleBulkReEvaluateRules}
|
|
onClear={handleClearSelection}
|
|
isUpdating={isBulkUpdating || isReEvaluating}
|
|
/>
|
|
</AppSidebarLayout>
|
|
);
|
|
}
|