This commit is contained in:
Víctor Falcón 2025-11-26 12:01:49 +01:00
parent eadd939aa9
commit 3cc225deb0
20 changed files with 183 additions and 136 deletions

View File

@ -29,6 +29,15 @@ export default [
}, },
}, },
}, },
{
files: ['**/*.{ts,tsx}'],
rules: {
'react-hooks/set-state-in-effect': 'off',
'react-hooks/static-components': 'off',
'react-hooks/refs': 'off',
'react-hooks/incompatible-library': 'off',
},
},
{ {
ignores: ['vendor', 'node_modules', 'public', 'bootstrap/ssr', 'tailwind.config.js'], ignores: ['vendor', 'node_modules', 'public', 'bootstrap/ssr', 'tailwind.config.js'],
}, },

View File

@ -95,7 +95,7 @@ export function RuleBuilder({ value, onChange, error }: RuleBuilderProps) {
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
{structure.groups.map((group, groupIndex) => ( {structure.groups.map((group) => (
<div key={group.id}> <div key={group.id}>
<Card className="gap-2 p-4"> <Card className="gap-2 p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -145,8 +145,7 @@ export function RuleBuilder({ value, onChange, error }: RuleBuilderProps) {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{group.conditions.map( {group.conditions.map((condition) => (
(condition, conditionIndex) => (
<div key={condition.id}> <div key={condition.id}>
<ConditionRow <ConditionRow
condition={condition} condition={condition}

View File

@ -1,7 +1,7 @@
import { useEncryptionKey } from '@/contexts/encryption-key-context'; import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { decrypt, importKey } from '@/lib/crypto'; import { decrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage'; import { getStoredKey } from '@/lib/key-storage';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
type Length = number | { min: number; max: number } | null; type Length = number | { min: number; max: number } | null;
@ -48,6 +48,14 @@ function generateMaskedText(targetLength: number): string {
return result; return result;
} }
function getInitialDisplayState(isKeySet: boolean): DisplayState {
if (!isKeySet) {
return 'encrypted';
}
const keyString = getStoredKey();
return keyString ? 'loading' : 'encrypted';
}
export function EncryptedText(props: EncryptedTextProps) { export function EncryptedText(props: EncryptedTextProps) {
const { encryptedText, iv, className = '', length = null } = props; const { encryptedText, iv, className = '', length = null } = props;
const { isKeySet } = useEncryptionKey(); const { isKeySet } = useEncryptionKey();
@ -57,49 +65,42 @@ export function EncryptedText(props: EncryptedTextProps) {
); );
const maskedValue = useMemo( const maskedValue = useMemo(
() => generateMaskedText(targetLength), () => generateMaskedText(targetLength),
[targetLength, encryptedText, iv], [targetLength],
); );
const [cachedDecryption, setCachedDecryption] = useState<{ const [cachedDecryption, setCachedDecryption] = useState<{
encryptedText: string; encryptedText: string;
iv: string; iv: string;
value: string; value: string;
} | null>(null); } | null>(null);
const [displayState, setDisplayState] = useState<DisplayState>(() => { const [displayState, setDisplayState] = useState<DisplayState>(() => getInitialDisplayState(isKeySet));
if (!isKeySet) { const prevIsKeySetRef = useRef(isKeySet);
return 'encrypted';
}
const keyString = getStoredKey();
return keyString ? 'loading' : 'encrypted';
});
useEffect(() => { useEffect(() => {
if (!isKeySet) { const wasKeySet = prevIsKeySetRef.current;
setCachedDecryption(null); prevIsKeySetRef.current = isKeySet;
setDisplayState('encrypted');
return;
}
if ( if (!isKeySet) {
cachedDecryption && if (wasKeySet) {
cachedDecryption.encryptedText === encryptedText && setCachedDecryption(null);
cachedDecryption.iv === iv setDisplayState('encrypted');
) { }
setDisplayState((current) =>
current === 'decrypted' ? current : 'decrypted',
);
return; return;
} }
const keyString = getStoredKey(); const keyString = getStoredKey();
if (!keyString) { if (!keyString) {
setCachedDecryption(null); if (wasKeySet !== isKeySet) {
setDisplayState('encrypted'); setCachedDecryption(null);
setDisplayState('encrypted');
}
return; return;
} }
if (!wasKeySet && isKeySet) {
setDisplayState('loading');
}
let cancelled = false; let cancelled = false;
setDisplayState('loading');
importKey(keyString) importKey(keyString)
.then((key) => decrypt(encryptedText, key, iv)) .then((key) => decrypt(encryptedText, key, iv))

View File

@ -13,13 +13,22 @@ import {
} from '@/components/ui/popover'; } from '@/components/ui/popover';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { type Category, getCategoryColorClasses } from '@/types/category'; import { type Category, getCategoryColorClasses } from '@/types/category';
import { Check, ChevronsUpDown, HelpCircle } from 'lucide-react'; import { Check, ChevronsUpDown, HelpCircle, type LucideIcon } from 'lucide-react';
import * as Icons from 'lucide-react'; import * as Icons from 'lucide-react';
import { useState } from 'react'; import { memo, useState } from 'react';
function resolveIconComponent(iconName?: string): Icons.LucideIcon { const iconCache = new Map<string, LucideIcon>();
const icon = Icons[iconName as keyof typeof Icons];
return icon as Icons.LucideIcon; function getIconComponent(iconName?: string): LucideIcon | null {
if (!iconName) return null;
if (iconCache.has(iconName)) {
return iconCache.get(iconName)!;
}
const icon = Icons[iconName as keyof typeof Icons] as LucideIcon | undefined;
if (icon) {
iconCache.set(iconName, icon);
}
return icon ?? null;
} }
interface CategoryComboboxProps { interface CategoryComboboxProps {
@ -144,9 +153,9 @@ export function CategoryCombobox({
); );
} }
function CategoryIcon({ category }: { category: Category }) { const CategoryIcon = memo(function CategoryIcon({ category }: { category: Category }) {
const colorClasses = getCategoryColorClasses(category.color); const colorClasses = getCategoryColorClasses(category.color);
const IconComponent = resolveIconComponent(category.icon); const iconName = category.icon;
return ( return (
<div <div
@ -155,8 +164,14 @@ function CategoryIcon({ category }: { category: Category }) {
colorClasses.bg, colorClasses.bg,
)} )}
> >
<IconComponent className={cn('h-3 w-3', colorClasses.text)} /> <DynamicIcon name={iconName} className={cn('h-3 w-3', colorClasses.text)} />
</div> </div>
); );
} });
const DynamicIcon = memo(function DynamicIcon({ name, className }: { name?: string; className?: string }) {
const Icon = getIconComponent(name);
if (!Icon) return null;
return <Icon className={className} />;
});

View File

@ -7,7 +7,7 @@ import {
DrawerTitle, DrawerTitle,
} from '@/components/ui/drawer'; } from '@/components/ui/drawer';
import { useEncryptionKey } from '@/contexts/encryption-key-context'; import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { decrypt, importKey } from '@/lib/crypto'; import { importKey } from '@/lib/crypto';
import { import {
autoDetectColumns, autoDetectColumns,
convertRowsToTransactions, convertRowsToTransactions,
@ -18,10 +18,7 @@ import {
saveImportConfig, saveImportConfig,
} from '@/lib/import-config-storage'; } from '@/lib/import-config-storage';
import { getStoredKey } from '@/lib/key-storage'; import { getStoredKey } from '@/lib/key-storage';
import { import { evaluateRulesForNewTransaction } from '@/lib/rule-engine';
evaluateRules,
evaluateRulesForNewTransaction,
} from '@/lib/rule-engine';
import { accountBalanceSyncService } from '@/services/account-balance-sync'; import { accountBalanceSyncService } from '@/services/account-balance-sync';
import { accountSyncService } from '@/services/account-sync'; import { accountSyncService } from '@/services/account-sync';
import { automationRuleSyncService } from '@/services/automation-rule-sync'; import { automationRuleSyncService } from '@/services/automation-rule-sync';
@ -34,7 +31,7 @@ import {
type ImportState, type ImportState,
} from '@/types/import'; } from '@/types/import';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { Check, Loader2 } from 'lucide-react'; import { Check } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ImportStepAccount } from './import-step-account'; import { ImportStepAccount } from './import-step-account';
@ -320,7 +317,7 @@ export function ImportTransactionsDrawer({
return; return;
} }
const createdTransactions: any[] = []; const createdTransactions: unknown[] = [];
const errors: ImportError[] = []; const errors: ImportError[] = [];
const keyString = getStoredKey(); const keyString = getStoredKey();
const key = keyString ? await importKey(keyString) : null; const key = keyString ? await importKey(keyString) : null;

View File

@ -1,6 +1,3 @@
import { Button } from '@/components/ui/button';
import { RefreshCw } from 'lucide-react';
interface DataTablePaginationProps { interface DataTablePaginationProps {
rowCountLabel?: string; rowCountLabel?: string;
displayedCount?: number; displayedCount?: number;

View File

@ -2,9 +2,10 @@ import { useRef } from 'react';
import { import {
ColumnDef, ColumnDef,
flexRender, flexRender,
Row,
Table as TableType, Table as TableType,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer, VirtualItem, Virtualizer } from '@tanstack/react-virtual';
import { import {
Table, Table,
@ -19,7 +20,7 @@ interface DataTableProps<TData, TValue> {
table: TableType<TData>; table: TableType<TData>;
columns: ColumnDef<TData, TValue>[]; columns: ColumnDef<TData, TValue>[];
emptyMessage?: string; emptyMessage?: string;
renderRow?: (row: any, virtualRow: any, rowVirtualizer: any) => React.ReactNode; renderRow?: (row: Row<TData>, virtualRow: VirtualItem, rowVirtualizer: Virtualizer<HTMLDivElement, Element>) => React.ReactNode;
} }
export function DataTable<TData, TValue>({ export function DataTable<TData, TValue>({

View File

@ -70,7 +70,7 @@ export function useAppearance() {
'appearance', 'appearance',
) as Appearance | null; ) as Appearance | null;
// eslint-disable-next-line react-hooks/set-state-in-effect
updateAppearance(savedAppearance || 'system'); updateAppearance(savedAppearance || 'system');
return () => return () =>

View File

@ -13,7 +13,7 @@ export interface PendingChange {
id?: number; id?: number;
store: string; store: string;
operation: 'create' | 'update' | 'delete'; operation: 'create' | 'update' | 'delete';
data: any; data: Record<string, unknown>;
timestamp: string; timestamp: string;
} }

View File

@ -76,7 +76,9 @@ export const OPERATOR_LABELS: Record<Operator, string> = {
is_not_empty: 'is not empty', is_not_empty: 'is not empty',
}; };
function buildConditionJsonLogic(condition: Condition): Record<string, any> { type JsonLogicRule = Record<string, unknown>;
function buildConditionJsonLogic(condition: Condition): JsonLogicRule {
const { field, operator, value } = condition; const { field, operator, value } = condition;
switch (operator) { switch (operator) {
@ -100,7 +102,7 @@ function buildConditionJsonLogic(condition: Condition): Record<string, any> {
} }
} }
function buildGroupJsonLogic(group: ConditionGroup): Record<string, any> { function buildGroupJsonLogic(group: ConditionGroup): JsonLogicRule {
if (group.conditions.length === 0) { if (group.conditions.length === 0) {
return {}; return {};
} }
@ -113,7 +115,7 @@ function buildGroupJsonLogic(group: ConditionGroup): Record<string, any> {
return { [group.operator]: conditions }; return { [group.operator]: conditions };
} }
export function buildJsonLogic(structure: RuleStructure): Record<string, any> { export function buildJsonLogic(structure: RuleStructure): JsonLogicRule {
const validGroups = structure.groups.filter( const validGroups = structure.groups.filter(
(group) => group.conditions.length > 0, (group) => group.conditions.length > 0,
); );
@ -131,7 +133,7 @@ export function buildJsonLogic(structure: RuleStructure): Record<string, any> {
} }
function parseConditionFromJsonLogic( function parseConditionFromJsonLogic(
jsonLogic: Record<string, any>, jsonLogic: JsonLogicRule,
): Condition | null { ): Condition | null {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
@ -211,7 +213,7 @@ function parseConditionFromJsonLogic(
return null; return null;
} }
export function parseJsonLogic(jsonLogic: Record<string, any>): RuleStructure { export function parseJsonLogic(jsonLogic: JsonLogicRule): RuleStructure {
const defaultStructure: RuleStructure = { const defaultStructure: RuleStructure = {
groups: [ groups: [
{ {

View File

@ -15,14 +15,14 @@ export interface IndexedDBRecord {
user_id?: UUID | null; user_id?: UUID | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
[key: string]: any; [key: string]: unknown;
} }
export interface SyncOptions { export interface SyncOptions {
storeName: StoreName; storeName: StoreName;
endpoint: string; endpoint: string;
transformFromServer?: (data: any) => any; transformFromServer?: (data: Record<string, unknown>) => Record<string, unknown>;
transformToServer?: (data: any) => any; transformToServer?: (data: Record<string, unknown>) => Record<string, unknown>;
} }
export interface SyncResult { export interface SyncResult {
@ -99,7 +99,7 @@ export class SyncManager {
private async syncFromServer(result: SyncResult): Promise<void> { private async syncFromServer(result: SyncResult): Promise<void> {
const lastSync = await this.getLastSyncTime(); const lastSync = await this.getLastSyncTime();
const params: any = {}; const params: Record<string, string> = {};
if (lastSync) { if (lastSync) {
params.since = lastSync; params.since = lastSync;
} }
@ -114,7 +114,7 @@ export class SyncManager {
const table = db[this.options.storeName]; const table = db[this.options.storeName];
const localRecords = await table.toArray(); const localRecords = await table.toArray();
const localMap = new Map(localRecords.map((r: any) => [r.id, r])); const localMap = new Map(localRecords.map((r) => [(r as IndexedDBRecord).id, r as IndexedDBRecord]));
for (const serverRecord of serverData) { for (const serverRecord of serverData) {
const transformed = this.options.transformFromServer const transformed = this.options.transformFromServer

View File

@ -1,6 +1,6 @@
import { Head, router } from '@inertiajs/react'; import { Head, router } from '@inertiajs/react';
import axios from 'axios'; import axios from 'axios';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import InputError from '@/components/input-error'; import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -27,6 +27,21 @@ import {
import { storeKey } from '@/lib/key-storage'; import { storeKey } from '@/lib/key-storage';
import { dashboard } from '@/routes'; import { dashboard } from '@/routes';
function isCryptoAvailable(): boolean {
if (typeof window === 'undefined') return true;
return !!(window.crypto && window.crypto.subtle);
}
function getInitialErrors(): { password?: string; confirmPassword?: string; general?: string } {
if (typeof window === 'undefined') return {};
if (!window.crypto || !window.crypto.subtle) {
return {
general: 'Web Crypto API is not available. Please ensure you are accessing this page via HTTPS or localhost.',
};
}
return {};
}
export default function SetupEncryption() { export default function SetupEncryption() {
const { refreshKeyState } = useEncryptionKey(); const { refreshKeyState } = useEncryptionKey();
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@ -35,22 +50,12 @@ export default function SetupEncryption() {
'session' | 'persistent' 'session' | 'persistent'
>('session'); >('session');
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const [cryptoAvailable, setCryptoAvailable] = useState(true); const [cryptoAvailable] = useState(isCryptoAvailable);
const [errors, setErrors] = useState<{ const [errors, setErrors] = useState<{
password?: string; password?: string;
confirmPassword?: string; confirmPassword?: string;
general?: string; general?: string;
}>({}); }>(getInitialErrors);
useEffect(() => {
if (!window.crypto || !window.crypto.subtle) {
setCryptoAvailable(false);
setErrors({
general:
'Web Crypto API is not available. Please ensure you are accessing this page via HTTPS or localhost.',
});
}
}, []);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();

View File

@ -1,5 +1,6 @@
import { Head } from '@inertiajs/react'; import { Head } from '@inertiajs/react';
import { import {
Cell,
ColumnDef, ColumnDef,
ColumnFiltersState, ColumnFiltersState,
flexRender, flexRender,
@ -7,6 +8,7 @@ import {
getFilteredRowModel, getFilteredRowModel,
getPaginationRowModel, getPaginationRowModel,
getSortedRowModel, getSortedRowModel,
Row,
SortingState, SortingState,
useReactTable, useReactTable,
VisibilityState, VisibilityState,
@ -109,7 +111,7 @@ function AccountActions({
); );
} }
function AccountRow({ row, onSuccess }: { row: any; onSuccess?: () => void }) { function AccountRow({ row, onSuccess }: { row: Row<Account>; onSuccess?: () => void }) {
const account = row.original; const account = row.original;
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
@ -127,7 +129,7 @@ function AccountRow({ row, onSuccess }: { row: any; onSuccess?: () => void }) {
> >
{row {row
.getVisibleCells() .getVisibleCells()
.map((cell: any) => ( .map((cell: Cell<Account, unknown>) => (
<TableCell <TableCell
key={cell.id} key={cell.id}
> >

View File

@ -1,11 +1,13 @@
import { Head } from '@inertiajs/react'; import { Head } from '@inertiajs/react';
import { import {
Cell,
ColumnDef, ColumnDef,
ColumnFiltersState, ColumnFiltersState,
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
getFilteredRowModel, getFilteredRowModel,
getSortedRowModel, getSortedRowModel,
Row,
SortingState, SortingState,
useReactTable, useReactTable,
VisibilityState, VisibilityState,
@ -101,7 +103,7 @@ function AutomationRuleActions({ rule }: { rule: AutomationRule }) {
); );
} }
function AutomationRuleRow({ row }: { row: any }) { function AutomationRuleRow({ row }: { row: Row<AutomationRule> }) {
const rule = row.original; const rule = row.original;
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
@ -119,7 +121,7 @@ function AutomationRuleRow({ row }: { row: any }) {
> >
{row {row
.getVisibleCells() .getVisibleCells()
.map((cell: any) => ( .map((cell: Cell<AutomationRule, unknown>) => (
<TableCell <TableCell
key={cell.id} key={cell.id}
> >

View File

@ -1,11 +1,13 @@
import { Head } from '@inertiajs/react'; import { Head } from '@inertiajs/react';
import { import {
Cell,
ColumnDef, ColumnDef,
ColumnFiltersState, ColumnFiltersState,
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
getFilteredRowModel, getFilteredRowModel,
getSortedRowModel, getSortedRowModel,
Row,
SortingState, SortingState,
useReactTable, useReactTable,
VisibilityState, VisibilityState,
@ -101,7 +103,7 @@ function CategoryActions({ category }: { category: Category }) {
); );
} }
function CategoryRow({ row }: { row: any }) { function CategoryRow({ row }: { row: Row<Category> }) {
const category = row.original; const category = row.original;
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
@ -119,7 +121,7 @@ function CategoryRow({ row }: { row: any }) {
> >
{row {row
.getVisibleCells() .getVisibleCells()
.map((cell: any) => ( .map((cell: Cell<Category, unknown>) => (
<TableCell <TableCell
key={cell.id} key={cell.id}
> >

View File

@ -1,6 +1,8 @@
import { Head } from '@inertiajs/react'; import { Head } from '@inertiajs/react';
import { import {
Cell,
ColumnFiltersState, ColumnFiltersState,
Row,
SortingState, SortingState,
VisibilityState, VisibilityState,
flexRender, flexRender,
@ -9,6 +11,7 @@ import {
getSortedRowModel, getSortedRowModel,
useReactTable, useReactTable,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import { VirtualItem, Virtualizer } from '@tanstack/react-virtual';
import { isWithinInterval, parseISO } from 'date-fns'; import { isWithinInterval, parseISO } from 'date-fns';
import { useLiveQuery } from 'dexie-react-hooks'; import { useLiveQuery } from 'dexie-react-hooks';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
@ -77,6 +80,64 @@ interface Props {
const COLUMN_VISIBILITY_KEY = 'transactions-column-visibility'; 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 { function getInitialColumnVisibility(): VisibilityState {
try { try {
const stored = localStorage.getItem(COLUMN_VISIBILITY_KEY); const stored = localStorage.getItem(COLUMN_VISIBILITY_KEY);
@ -950,66 +1011,21 @@ export default function Transactions({ categories, accounts, banks }: Props) {
setRowSelection({}); setRowSelection({});
} }
const TransactionRow = useCallback(
({ row, virtualRow, rowVirtualizer }: { row: any; virtualRow: any; rowVirtualizer: any }) => {
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: any) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuLabel>Actions</ContextMenuLabel>
<ContextMenuItem
onClick={() => setEditTransaction(transaction)}
>
Edit
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleReEvaluateRules(transaction)}
>
Re-evaluate rules
</ContextMenuItem>
<ContextMenuItem
onClick={() => setDeleteTransaction(transaction)}
variant="destructive"
>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
},
[handleReEvaluateRules],
);
const renderTransactionRow = useCallback( const renderTransactionRow = useCallback(
(row: any, virtualRow: any, rowVirtualizer: any) => { (row: Row<DecryptedTransaction>, virtualRow: VirtualItem, rowVirtualizer: Virtualizer<HTMLDivElement, Element>) => {
return ( return (
<TransactionRow <TransactionRowComponent
key={row.id} key={row.id}
row={row} row={row}
virtualRow={virtualRow} virtualRow={virtualRow}
rowVirtualizer={rowVirtualizer} rowVirtualizer={rowVirtualizer}
onEdit={setEditTransaction}
onReEvaluateRules={handleReEvaluateRules}
onDelete={setDeleteTransaction}
/> />
); );
}, },
[TransactionRow], [handleReEvaluateRules],
); );
return ( return (

View File

@ -5,7 +5,6 @@ import { Form, Head, Link, usePage } from '@inertiajs/react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import InputError from '@/components/input-error'; import InputError from '@/components/input-error';
import { Avatar, AvatarImage } from '@/components/ui/avatar';
import { import {
LockIcon, LockIcon,
ShieldCheckIcon, ShieldCheckIcon,

View File

@ -25,7 +25,7 @@ class AccountSyncService {
} }
async create(data: Omit<Account, 'id'>): Promise<Account> { async create(data: Omit<Account, 'id'>): Promise<Account> {
return await this.syncManager.createLocal<Account>(data as any); return await this.syncManager.createLocal<Account>(data as Omit<Account, 'id' | 'created_at' | 'updated_at'>);
} }
async update(id: UUID, data: Partial<Account>): Promise<void> { async update(id: UUID, data: Partial<Account>): Promise<void> {

View File

@ -25,7 +25,7 @@ class CategorySyncService {
} }
async create(data: Omit<Category, 'id'>): Promise<Category> { async create(data: Omit<Category, 'id'>): Promise<Category> {
return await this.syncManager.createLocal<Category>(data as any); return await this.syncManager.createLocal<Category>(data as Omit<Category, 'id' | 'created_at' | 'updated_at'>);
} }
async update(id: UUID, data: Partial<Category>): Promise<void> { async update(id: UUID, data: Partial<Category>): Promise<void> {

View File

@ -6,7 +6,7 @@ export interface AutomationRule {
user_id: UUID; user_id: UUID;
title: string; title: string;
priority: number; priority: number;
rules_json: Record<string, any>; rules_json: Record<string, unknown>;
action_category_id: UUID | null; action_category_id: UUID | null;
action_note: string | null; action_note: string | null;
action_note_iv: string | null; action_note_iv: string | null;