From 3cc225deb0487fb636be19bbfa1b6fe32c2e0ea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Wed, 26 Nov 2025 12:01:49 +0100 Subject: [PATCH] Y3:0 --- eslint.config.js | 9 ++ .../automation-rules/rule-builder.tsx | 5 +- resources/js/components/encrypted-text.tsx | 53 ++++---- .../components/shared/category-combobox.tsx | 33 +++-- .../import-transactions-drawer.tsx | 11 +- .../components/ui/data-table-pagination.tsx | 3 - resources/js/components/ui/data-table.tsx | 5 +- resources/js/hooks/use-appearance.tsx | 2 +- resources/js/lib/dexie-db.ts | 2 +- resources/js/lib/rule-builder-utils.ts | 12 +- resources/js/lib/sync-manager.ts | 10 +- resources/js/pages/auth/setup-encryption.tsx | 31 +++-- resources/js/pages/settings/accounts.tsx | 6 +- .../js/pages/settings/automation-rules.tsx | 6 +- resources/js/pages/settings/categories.tsx | 6 +- resources/js/pages/transactions/index.tsx | 118 ++++++++++-------- resources/js/pages/welcome.tsx | 1 - resources/js/services/account-sync.ts | 2 +- resources/js/services/category-sync.ts | 2 +- resources/js/types/automation-rule.ts | 2 +- 20 files changed, 183 insertions(+), 136 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 7a9fc2ce..71a733a6 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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'], }, diff --git a/resources/js/components/automation-rules/rule-builder.tsx b/resources/js/components/automation-rules/rule-builder.tsx index cdd0d205..898ade48 100644 --- a/resources/js/components/automation-rules/rule-builder.tsx +++ b/resources/js/components/automation-rules/rule-builder.tsx @@ -95,7 +95,7 @@ export function RuleBuilder({ value, onChange, error }: RuleBuilderProps) {
- {structure.groups.map((group, groupIndex) => ( + {structure.groups.map((group) => (
@@ -145,8 +145,7 @@ export function RuleBuilder({ value, onChange, error }: RuleBuilderProps) {
- {group.conditions.map( - (condition, conditionIndex) => ( + {group.conditions.map((condition) => (
generateMaskedText(targetLength), - [targetLength, encryptedText, iv], + [targetLength], ); const [cachedDecryption, setCachedDecryption] = useState<{ encryptedText: string; iv: string; value: string; } | null>(null); - const [displayState, setDisplayState] = useState(() => { - if (!isKeySet) { - return 'encrypted'; - } - - const keyString = getStoredKey(); - return keyString ? 'loading' : 'encrypted'; - }); + const [displayState, setDisplayState] = useState(() => getInitialDisplayState(isKeySet)); + const prevIsKeySetRef = useRef(isKeySet); useEffect(() => { - if (!isKeySet) { - setCachedDecryption(null); - setDisplayState('encrypted'); - return; - } + const wasKeySet = prevIsKeySetRef.current; + prevIsKeySetRef.current = isKeySet; - if ( - cachedDecryption && - cachedDecryption.encryptedText === encryptedText && - cachedDecryption.iv === iv - ) { - setDisplayState((current) => - current === 'decrypted' ? current : 'decrypted', - ); + if (!isKeySet) { + if (wasKeySet) { + setCachedDecryption(null); + setDisplayState('encrypted'); + } return; } const keyString = getStoredKey(); if (!keyString) { - setCachedDecryption(null); - setDisplayState('encrypted'); + if (wasKeySet !== isKeySet) { + setCachedDecryption(null); + setDisplayState('encrypted'); + } return; } + if (!wasKeySet && isKeySet) { + setDisplayState('loading'); + } + let cancelled = false; - setDisplayState('loading'); importKey(keyString) .then((key) => decrypt(encryptedText, key, iv)) diff --git a/resources/js/components/shared/category-combobox.tsx b/resources/js/components/shared/category-combobox.tsx index d4009588..d2f50302 100644 --- a/resources/js/components/shared/category-combobox.tsx +++ b/resources/js/components/shared/category-combobox.tsx @@ -13,13 +13,22 @@ import { } from '@/components/ui/popover'; import { cn } from '@/lib/utils'; 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 { useState } from 'react'; +import { memo, useState } from 'react'; -function resolveIconComponent(iconName?: string): Icons.LucideIcon { - const icon = Icons[iconName as keyof typeof Icons]; - return icon as Icons.LucideIcon; +const iconCache = new Map(); + +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 { @@ -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 IconComponent = resolveIconComponent(category.icon); + const iconName = category.icon; return (
- +
); -} +}); + +const DynamicIcon = memo(function DynamicIcon({ name, className }: { name?: string; className?: string }) { + const Icon = getIconComponent(name); + if (!Icon) return null; + return ; +}); diff --git a/resources/js/components/transactions/import-transactions-drawer.tsx b/resources/js/components/transactions/import-transactions-drawer.tsx index 8aa48cd5..d5bf48c4 100644 --- a/resources/js/components/transactions/import-transactions-drawer.tsx +++ b/resources/js/components/transactions/import-transactions-drawer.tsx @@ -7,7 +7,7 @@ import { DrawerTitle, } from '@/components/ui/drawer'; import { useEncryptionKey } from '@/contexts/encryption-key-context'; -import { decrypt, importKey } from '@/lib/crypto'; +import { importKey } from '@/lib/crypto'; import { autoDetectColumns, convertRowsToTransactions, @@ -18,10 +18,7 @@ import { saveImportConfig, } from '@/lib/import-config-storage'; import { getStoredKey } from '@/lib/key-storage'; -import { - evaluateRules, - evaluateRulesForNewTransaction, -} from '@/lib/rule-engine'; +import { evaluateRulesForNewTransaction } from '@/lib/rule-engine'; import { accountBalanceSyncService } from '@/services/account-balance-sync'; import { accountSyncService } from '@/services/account-sync'; import { automationRuleSyncService } from '@/services/automation-rule-sync'; @@ -34,7 +31,7 @@ import { type ImportState, } from '@/types/import'; import { Progress } from '@/components/ui/progress'; -import { Check, Loader2 } from 'lucide-react'; +import { Check } from 'lucide-react'; import { useEffect, useState } from 'react'; import { toast } from 'sonner'; import { ImportStepAccount } from './import-step-account'; @@ -320,7 +317,7 @@ export function ImportTransactionsDrawer({ return; } - const createdTransactions: any[] = []; + const createdTransactions: unknown[] = []; const errors: ImportError[] = []; const keyString = getStoredKey(); const key = keyString ? await importKey(keyString) : null; diff --git a/resources/js/components/ui/data-table-pagination.tsx b/resources/js/components/ui/data-table-pagination.tsx index 4a6514dc..a44d7df3 100644 --- a/resources/js/components/ui/data-table-pagination.tsx +++ b/resources/js/components/ui/data-table-pagination.tsx @@ -1,6 +1,3 @@ -import { Button } from '@/components/ui/button'; -import { RefreshCw } from 'lucide-react'; - interface DataTablePaginationProps { rowCountLabel?: string; displayedCount?: number; diff --git a/resources/js/components/ui/data-table.tsx b/resources/js/components/ui/data-table.tsx index 2f0cb954..6796a3fa 100644 --- a/resources/js/components/ui/data-table.tsx +++ b/resources/js/components/ui/data-table.tsx @@ -2,9 +2,10 @@ import { useRef } from 'react'; import { ColumnDef, flexRender, + Row, Table as TableType, } from '@tanstack/react-table'; -import { useVirtualizer } from '@tanstack/react-virtual'; +import { useVirtualizer, VirtualItem, Virtualizer } from '@tanstack/react-virtual'; import { Table, @@ -19,7 +20,7 @@ interface DataTableProps { table: TableType; columns: ColumnDef[]; emptyMessage?: string; - renderRow?: (row: any, virtualRow: any, rowVirtualizer: any) => React.ReactNode; + renderRow?: (row: Row, virtualRow: VirtualItem, rowVirtualizer: Virtualizer) => React.ReactNode; } export function DataTable({ diff --git a/resources/js/hooks/use-appearance.tsx b/resources/js/hooks/use-appearance.tsx index 2c6b5682..c020341b 100644 --- a/resources/js/hooks/use-appearance.tsx +++ b/resources/js/hooks/use-appearance.tsx @@ -70,7 +70,7 @@ export function useAppearance() { 'appearance', ) as Appearance | null; - // eslint-disable-next-line react-hooks/set-state-in-effect + updateAppearance(savedAppearance || 'system'); return () => diff --git a/resources/js/lib/dexie-db.ts b/resources/js/lib/dexie-db.ts index 1840cc7a..ab349aec 100644 --- a/resources/js/lib/dexie-db.ts +++ b/resources/js/lib/dexie-db.ts @@ -13,7 +13,7 @@ export interface PendingChange { id?: number; store: string; operation: 'create' | 'update' | 'delete'; - data: any; + data: Record; timestamp: string; } diff --git a/resources/js/lib/rule-builder-utils.ts b/resources/js/lib/rule-builder-utils.ts index ddd48abc..674ba7b6 100644 --- a/resources/js/lib/rule-builder-utils.ts +++ b/resources/js/lib/rule-builder-utils.ts @@ -76,7 +76,9 @@ export const OPERATOR_LABELS: Record = { is_not_empty: 'is not empty', }; -function buildConditionJsonLogic(condition: Condition): Record { +type JsonLogicRule = Record; + +function buildConditionJsonLogic(condition: Condition): JsonLogicRule { const { field, operator, value } = condition; switch (operator) { @@ -100,7 +102,7 @@ function buildConditionJsonLogic(condition: Condition): Record { } } -function buildGroupJsonLogic(group: ConditionGroup): Record { +function buildGroupJsonLogic(group: ConditionGroup): JsonLogicRule { if (group.conditions.length === 0) { return {}; } @@ -113,7 +115,7 @@ function buildGroupJsonLogic(group: ConditionGroup): Record { return { [group.operator]: conditions }; } -export function buildJsonLogic(structure: RuleStructure): Record { +export function buildJsonLogic(structure: RuleStructure): JsonLogicRule { const validGroups = structure.groups.filter( (group) => group.conditions.length > 0, ); @@ -131,7 +133,7 @@ export function buildJsonLogic(structure: RuleStructure): Record { } function parseConditionFromJsonLogic( - jsonLogic: Record, + jsonLogic: JsonLogicRule, ): Condition | null { const id = crypto.randomUUID(); @@ -211,7 +213,7 @@ function parseConditionFromJsonLogic( return null; } -export function parseJsonLogic(jsonLogic: Record): RuleStructure { +export function parseJsonLogic(jsonLogic: JsonLogicRule): RuleStructure { const defaultStructure: RuleStructure = { groups: [ { diff --git a/resources/js/lib/sync-manager.ts b/resources/js/lib/sync-manager.ts index 51fc440d..8a98349c 100644 --- a/resources/js/lib/sync-manager.ts +++ b/resources/js/lib/sync-manager.ts @@ -15,14 +15,14 @@ export interface IndexedDBRecord { user_id?: UUID | null; created_at: string; updated_at: string; - [key: string]: any; + [key: string]: unknown; } export interface SyncOptions { storeName: StoreName; endpoint: string; - transformFromServer?: (data: any) => any; - transformToServer?: (data: any) => any; + transformFromServer?: (data: Record) => Record; + transformToServer?: (data: Record) => Record; } export interface SyncResult { @@ -99,7 +99,7 @@ export class SyncManager { private async syncFromServer(result: SyncResult): Promise { const lastSync = await this.getLastSyncTime(); - const params: any = {}; + const params: Record = {}; if (lastSync) { params.since = lastSync; } @@ -114,7 +114,7 @@ export class SyncManager { const table = db[this.options.storeName]; 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) { const transformed = this.options.transformFromServer diff --git a/resources/js/pages/auth/setup-encryption.tsx b/resources/js/pages/auth/setup-encryption.tsx index 13b7ad34..11b96ed0 100644 --- a/resources/js/pages/auth/setup-encryption.tsx +++ b/resources/js/pages/auth/setup-encryption.tsx @@ -1,6 +1,6 @@ import { Head, router } from '@inertiajs/react'; import axios from 'axios'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import InputError from '@/components/input-error'; import { Button } from '@/components/ui/button'; @@ -27,6 +27,21 @@ import { import { storeKey } from '@/lib/key-storage'; 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() { const { refreshKeyState } = useEncryptionKey(); const [password, setPassword] = useState(''); @@ -35,22 +50,12 @@ export default function SetupEncryption() { 'session' | 'persistent' >('session'); const [processing, setProcessing] = useState(false); - const [cryptoAvailable, setCryptoAvailable] = useState(true); + const [cryptoAvailable] = useState(isCryptoAvailable); const [errors, setErrors] = useState<{ password?: string; confirmPassword?: string; general?: string; - }>({}); - - 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.', - }); - } - }, []); + }>(getInitialErrors); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); diff --git a/resources/js/pages/settings/accounts.tsx b/resources/js/pages/settings/accounts.tsx index 05dc5aa5..daf742ed 100644 --- a/resources/js/pages/settings/accounts.tsx +++ b/resources/js/pages/settings/accounts.tsx @@ -1,5 +1,6 @@ import { Head } from '@inertiajs/react'; import { + Cell, ColumnDef, ColumnFiltersState, flexRender, @@ -7,6 +8,7 @@ import { getFilteredRowModel, getPaginationRowModel, getSortedRowModel, + Row, SortingState, useReactTable, VisibilityState, @@ -109,7 +111,7 @@ function AccountActions({ ); } -function AccountRow({ row, onSuccess }: { row: any; onSuccess?: () => void }) { +function AccountRow({ row, onSuccess }: { row: Row; onSuccess?: () => void }) { const account = row.original; const [editOpen, setEditOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); @@ -127,7 +129,7 @@ function AccountRow({ row, onSuccess }: { row: any; onSuccess?: () => void }) { > {row .getVisibleCells() - .map((cell: any) => ( + .map((cell: Cell) => ( diff --git a/resources/js/pages/settings/automation-rules.tsx b/resources/js/pages/settings/automation-rules.tsx index a609fbd0..c97d871b 100644 --- a/resources/js/pages/settings/automation-rules.tsx +++ b/resources/js/pages/settings/automation-rules.tsx @@ -1,11 +1,13 @@ import { Head } from '@inertiajs/react'; import { + Cell, ColumnDef, ColumnFiltersState, flexRender, getCoreRowModel, getFilteredRowModel, getSortedRowModel, + Row, SortingState, useReactTable, VisibilityState, @@ -101,7 +103,7 @@ function AutomationRuleActions({ rule }: { rule: AutomationRule }) { ); } -function AutomationRuleRow({ row }: { row: any }) { +function AutomationRuleRow({ row }: { row: Row }) { const rule = row.original; const [editOpen, setEditOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); @@ -119,7 +121,7 @@ function AutomationRuleRow({ row }: { row: any }) { > {row .getVisibleCells() - .map((cell: any) => ( + .map((cell: Cell) => ( diff --git a/resources/js/pages/settings/categories.tsx b/resources/js/pages/settings/categories.tsx index dd1d82ae..c389e078 100644 --- a/resources/js/pages/settings/categories.tsx +++ b/resources/js/pages/settings/categories.tsx @@ -1,11 +1,13 @@ import { Head } from '@inertiajs/react'; import { + Cell, ColumnDef, ColumnFiltersState, flexRender, getCoreRowModel, getFilteredRowModel, getSortedRowModel, + Row, SortingState, useReactTable, VisibilityState, @@ -101,7 +103,7 @@ function CategoryActions({ category }: { category: Category }) { ); } -function CategoryRow({ row }: { row: any }) { +function CategoryRow({ row }: { row: Row }) { const category = row.original; const [editOpen, setEditOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); @@ -119,7 +121,7 @@ function CategoryRow({ row }: { row: any }) { > {row .getVisibleCells() - .map((cell: any) => ( + .map((cell: Cell) => ( diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx index 11e4e24b..67776291 100644 --- a/resources/js/pages/transactions/index.tsx +++ b/resources/js/pages/transactions/index.tsx @@ -1,6 +1,8 @@ import { Head } from '@inertiajs/react'; import { + Cell, ColumnFiltersState, + Row, SortingState, VisibilityState, flexRender, @@ -9,6 +11,7 @@ import { 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'; @@ -77,6 +80,64 @@ interface Props { const COLUMN_VISIBILITY_KEY = 'transactions-column-visibility'; +interface TransactionRowProps { + row: Row; + virtualRow: VirtualItem; + rowVirtualizer: Virtualizer; + onEdit: (transaction: DecryptedTransaction) => void; + onReEvaluateRules: (transaction: DecryptedTransaction) => void; + onDelete: (transaction: DecryptedTransaction) => void; +} + +function TransactionRowComponent({ + row, + virtualRow, + rowVirtualizer, + onEdit, + onReEvaluateRules, + onDelete, +}: TransactionRowProps) { + const transaction = row.original; + const [contextMenuOpen, setContextMenuOpen] = useState(false); + + return ( + + + { } + + {row.getVisibleCells().map((cell: Cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + + + Actions + onEdit(transaction)}> + Edit + + onReEvaluateRules(transaction)}> + Re-evaluate rules + + onDelete(transaction)} + variant="destructive" + > + Delete + + + + ); +} + function getInitialColumnVisibility(): VisibilityState { try { const stored = localStorage.getItem(COLUMN_VISIBILITY_KEY); @@ -950,66 +1011,21 @@ export default function Transactions({ categories, accounts, banks }: Props) { setRowSelection({}); } - const TransactionRow = useCallback( - ({ row, virtualRow, rowVirtualizer }: { row: any; virtualRow: any; rowVirtualizer: any }) => { - const transaction = row.original; - const [contextMenuOpen, setContextMenuOpen] = useState(false); - - return ( - - - - {row.getVisibleCells().map((cell: any) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ))} - - - - Actions - setEditTransaction(transaction)} - > - Edit - - handleReEvaluateRules(transaction)} - > - Re-evaluate rules - - setDeleteTransaction(transaction)} - variant="destructive" - > - Delete - - - - ); - }, - [handleReEvaluateRules], - ); - const renderTransactionRow = useCallback( - (row: any, virtualRow: any, rowVirtualizer: any) => { + (row: Row, virtualRow: VirtualItem, rowVirtualizer: Virtualizer) => { return ( - ); }, - [TransactionRow], + [handleReEvaluateRules], ); return ( diff --git a/resources/js/pages/welcome.tsx b/resources/js/pages/welcome.tsx index 5f62ec5c..8d245df0 100644 --- a/resources/js/pages/welcome.tsx +++ b/resources/js/pages/welcome.tsx @@ -5,7 +5,6 @@ import { Form, Head, Link, usePage } from '@inertiajs/react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import InputError from '@/components/input-error'; -import { Avatar, AvatarImage } from '@/components/ui/avatar'; import { LockIcon, ShieldCheckIcon, diff --git a/resources/js/services/account-sync.ts b/resources/js/services/account-sync.ts index 7f5e039e..cb1ab1b5 100644 --- a/resources/js/services/account-sync.ts +++ b/resources/js/services/account-sync.ts @@ -25,7 +25,7 @@ class AccountSyncService { } async create(data: Omit): Promise { - return await this.syncManager.createLocal(data as any); + return await this.syncManager.createLocal(data as Omit); } async update(id: UUID, data: Partial): Promise { diff --git a/resources/js/services/category-sync.ts b/resources/js/services/category-sync.ts index c0dfaa46..784b860f 100644 --- a/resources/js/services/category-sync.ts +++ b/resources/js/services/category-sync.ts @@ -25,7 +25,7 @@ class CategorySyncService { } async create(data: Omit): Promise { - return await this.syncManager.createLocal(data as any); + return await this.syncManager.createLocal(data as Omit); } async update(id: UUID, data: Partial): Promise { diff --git a/resources/js/types/automation-rule.ts b/resources/js/types/automation-rule.ts index d686f352..5f06784a 100644 --- a/resources/js/types/automation-rule.ts +++ b/resources/js/types/automation-rule.ts @@ -6,7 +6,7 @@ export interface AutomationRule { user_id: UUID; title: string; priority: number; - rules_json: Record; + rules_json: Record; action_category_id: UUID | null; action_note: string | null; action_note_iv: string | null;