diff --git a/.cursor/rules/laravel-boost.mdc b/.cursor/rules/laravel-boost.mdc index fe9de9ed..5eaec232 100644 --- a/.cursor/rules/laravel-boost.mdc +++ b/.cursor/rules/laravel-boost.mdc @@ -42,7 +42,6 @@ This application is a Laravel application and its main Laravel ecosystems packag - Do not change the application's dependencies without approval. ## Frontend Bundling -- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `bun run build`, `bun run dev`, or `composer run dev`. Ask them. - NEVER run `bun run build` by yourself, ask the user to do it. ## Replies diff --git a/bun.lock b/bun.lock index 3aa72f1a..1f1df7c3 100644 --- a/bun.lock +++ b/bun.lock @@ -40,6 +40,7 @@ "lucide-react": "^0.553.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.0.1", "tailwindcss": "^4.0.0", "tw-animate-css": "^1.4.0", @@ -985,6 +986,8 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="], diff --git a/package.json b/package.json index 9d31d055..35b3203e 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "lucide-react": "^0.553.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.0.1", "tailwindcss": "^4.0.0", "tw-animate-css": "^1.4.0", diff --git a/resources/css/app.css b/resources/css/app.css index 468baf5b..7015d586 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,6 +1,6 @@ @import 'tailwindcss'; -@import "tw-animate-css"; +@import 'tw-animate-css'; @source '../views'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; diff --git a/resources/js/app.tsx b/resources/js/app.tsx index 8f8fab54..cfeee548 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -4,6 +4,7 @@ import { createInertiaApp } from '@inertiajs/react'; import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import { Toaster } from 'sonner'; import { EncryptionKeyProvider } from './contexts/encryption-key-context'; import { SyncProvider } from './contexts/sync-context'; import { initializeTheme } from './hooks/use-appearance'; @@ -20,15 +21,21 @@ createInertiaApp({ ), setup({ el, App, props }) { const root = createRoot(el); - const initialPageProps = props.initialPage - ?.props as Partial | undefined; + const initialPageProps = props.initialPage?.props as + | Partial + | undefined; const initialIsAuthenticated = Boolean(initialPageProps?.auth?.user); root.render( - + +
+ +
, diff --git a/resources/js/components/app-header.tsx b/resources/js/components/app-header.tsx index 36aba136..e2ec429c 100644 --- a/resources/js/components/app-header.tsx +++ b/resources/js/components/app-header.tsx @@ -1,3 +1,4 @@ +import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController'; import { Breadcrumbs } from '@/components/breadcrumbs'; import { Icon } from '@/components/icon'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; @@ -30,10 +31,16 @@ import { UserMenuContent } from '@/components/user-menu-content'; import { useInitials } from '@/hooks/use-initials'; import { cn, isSameUrl, resolveUrl } from '@/lib/utils'; import { dashboard } from '@/routes'; -import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController'; import { type BreadcrumbItem, type NavItem, type SharedData } from '@/types'; import { Link, usePage } from '@inertiajs/react'; -import { BookOpen, Folder, LayoutGrid, Menu, Receipt, Search } from 'lucide-react'; +import { + BookOpen, + Folder, + LayoutGrid, + Menu, + Receipt, + Search, +} from 'lucide-react'; import AppLogo from './app-logo'; import AppLogoIcon from './app-logo-icon'; diff --git a/resources/js/components/app-sidebar-header.tsx b/resources/js/components/app-sidebar-header.tsx index be3fbb09..2fc885a4 100644 --- a/resources/js/components/app-sidebar-header.tsx +++ b/resources/js/components/app-sidebar-header.tsx @@ -19,7 +19,10 @@ export function AppSidebarHeader({
- +
diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 6f510ded..b9a1f336 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -1,3 +1,4 @@ +import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController'; import { NavFooter } from '@/components/nav-footer'; import { NavMain } from '@/components/nav-main'; import { NavUser } from '@/components/nav-user'; @@ -11,7 +12,6 @@ import { SidebarMenuItem, } from '@/components/ui/sidebar'; import { dashboard } from '@/routes'; -import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController'; import { type NavItem } from '@/types'; import { Link } from '@inertiajs/react'; import { BookOpen, Folder, LayoutGrid, Receipt } from 'lucide-react'; diff --git a/resources/js/components/encrypted-text.tsx b/resources/js/components/encrypted-text.tsx index 253cf047..4e8c3d96 100644 --- a/resources/js/components/encrypted-text.tsx +++ b/resources/js/components/encrypted-text.tsx @@ -1,7 +1,7 @@ -import { useEffect, useMemo, useState } from 'react'; import { useEncryptionKey } from '@/contexts/encryption-key-context'; +import { decrypt, importKey } from '@/lib/crypto'; import { getStoredKey } from '@/lib/key-storage'; -import { importKey, decrypt } from '@/lib/crypto'; +import { useEffect, useMemo, useState } from 'react'; type Length = number | { min: number; max: number } | null; @@ -14,8 +14,7 @@ interface EncryptedTextProps { type DisplayState = 'encrypted' | 'decrypted' | 'loading'; -const ENCRYPTED_CHARSET = - '0123456789$%&#@!ABCDEFGHIJKLMNOPQRSTUVWXYZ'; +const ENCRYPTED_CHARSET = '0123456789$%&#@!ABCDEFGHIJKLMNOPQRSTUVWXYZ'; function resolveTargetLength(length: Length, fallback: number): number { if (typeof length === 'number') { @@ -126,7 +125,10 @@ export function EncryptedText(props: EncryptedTextProps) { if (displayState === 'loading') { const widthInCharacters = Math.max(targetLength, 3) / 2; - const loadingClassName = ['inline-block animate-pulse rounded bg-muted/60', className] + const loadingClassName = [ + 'inline-block animate-pulse rounded bg-muted/60', + className, + ] .filter(Boolean) .join(' '); diff --git a/resources/js/components/sync-status-button.tsx b/resources/js/components/sync-status-button.tsx index f506a68d..2b000973 100644 --- a/resources/js/components/sync-status-button.tsx +++ b/resources/js/components/sync-status-button.tsx @@ -1,4 +1,3 @@ -import { CloudAlert, CloudCheck, CloudOff, RefreshCw } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Tooltip, @@ -8,6 +7,7 @@ import { } from '@/components/ui/tooltip'; import { useSyncContext } from '@/contexts/sync-context'; import { formatDistanceToNow } from 'date-fns'; +import { CloudAlert, CloudCheck, CloudOff, RefreshCw } from 'lucide-react'; export function SyncStatusButton() { const { syncStatus, lastSyncTime, isOnline, sync, error } = @@ -71,7 +71,6 @@ export function SyncStatusButton() {

{getTooltipText()}

- + ); } - diff --git a/resources/js/components/transactions/bulk-actions-bar.tsx b/resources/js/components/transactions/bulk-actions-bar.tsx index 5085c941..005f85bc 100644 --- a/resources/js/components/transactions/bulk-actions-bar.tsx +++ b/resources/js/components/transactions/bulk-actions-bar.tsx @@ -63,7 +63,6 @@ export function BulkActionsBar({ - ('null'); const [notes, setNotes] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); @@ -54,6 +57,13 @@ export function EditTransactionDialog({ return; } + if (!isKeySet) { + toast.error( + 'Please unlock your encryption key to update transactions', + ); + return; + } + setIsSubmitting(true); try { const selectedCategoryId = diff --git a/resources/js/components/transactions/import-step-account.tsx b/resources/js/components/transactions/import-step-account.tsx index c257ebe5..c89bed24 100644 --- a/resources/js/components/transactions/import-step-account.tsx +++ b/resources/js/components/transactions/import-step-account.tsx @@ -1,11 +1,11 @@ -import { useEffect, useState } from 'react'; -import { accountSyncService } from '@/services/account-sync'; -import { type Account } from '@/types/account'; +import { EncryptedText } from '@/components/encrypted-text'; +import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; -import { Button } from '@/components/ui/button'; -import { EncryptedText } from '@/components/encrypted-text'; +import { accountSyncService } from '@/services/account-sync'; +import { type Account } from '@/types/account'; import { Building2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; interface ImportStepAccountProps { selectedAccountId: number | null; @@ -39,7 +39,9 @@ export function ImportStepAccount({ if (loading) { return (
-

Loading accounts...

+

+ Loading accounts... +

); } @@ -82,9 +84,7 @@ export function ImportStepAccount({ )} -
+
- {account.bank.name} • {account.currency_code} + {account.bank.name} •{' '} + {account.currency_code}
@@ -102,14 +103,10 @@ export function ImportStepAccount({
-
); } - diff --git a/resources/js/components/transactions/import-step-mapping.tsx b/resources/js/components/transactions/import-step-mapping.tsx index 25722da3..b292fd9f 100644 --- a/resources/js/components/transactions/import-step-mapping.tsx +++ b/resources/js/components/transactions/import-step-mapping.tsx @@ -1,5 +1,6 @@ import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { Select, SelectContent, @@ -7,9 +8,13 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { DateFormat, type ColumnMapping, type ColumnOption, type ParsedRow } from '@/types/import'; -import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; -import { parseDate, parseAmount } from '@/lib/file-parser'; +import { parseAmount, parseDate } from '@/lib/file-parser'; +import { + DateFormat, + type ColumnMapping, + type ColumnOption, + type ParsedRow, +} from '@/types/import'; interface ImportStepMappingProps { columnOptions: ColumnOption[]; @@ -43,7 +48,10 @@ export function ImportStepMapping({ const previewTransactions = parsedData.slice(0, 3).map((row) => { const date = columnMapping.transaction_date - ? parseDate(row[columnMapping.transaction_date] as string | number, dateFormat) + ? parseDate( + row[columnMapping.transaction_date] as string | number, + dateFormat, + ) : null; const description = columnMapping.description ? String(row[columnMapping.description] || '') @@ -53,14 +61,21 @@ export function ImportStepMapping({ : null; return { - date: date ? date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : 'Invalid date', + date: date + ? date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + : 'Invalid date', description: description || 'No description', - amount: amount !== null - ? new Intl.NumberFormat('en-US', { - style: 'currency', - currency: currencyCode, - }).format(amount) - : 'Invalid amount', + amount: + amount !== null + ? new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currencyCode, + }).format(amount) + : 'Invalid amount', }; }); @@ -69,7 +84,8 @@ export function ImportStepMapping({
onMappingChange('amount', value)} + onValueChange={(value) => + onMappingChange('amount', value) + } > {columnOptions.map((option) => ( - + {option.label} ))} @@ -140,15 +167,26 @@ export function ImportStepMapping({ {isValid && previewTransactions.length > 0 && (
- +
{previewTransactions.map((transaction, index) => ( -
+
- {transaction.date} - {transaction.description} + + {transaction.date} + + + {transaction.description} +
- {transaction.amount} + + {transaction.amount} +
))}
@@ -160,14 +198,19 @@ export function ImportStepMapping({ onDateFormatChange(value as DateFormat)} + onValueChange={(value) => + onDateFormatChange(value as DateFormat) + } >
-
@@ -176,7 +219,10 @@ export function ImportStepMapping({ value={DateFormat.MonthDayYear} id="format-mdy" /> -
@@ -185,7 +231,10 @@ export function ImportStepMapping({ value={DateFormat.DayMonthYear} id="format-dmy" /> -
@@ -205,4 +254,3 @@ export function ImportStepMapping({
); } - diff --git a/resources/js/components/transactions/import-step-preview.tsx b/resources/js/components/transactions/import-step-preview.tsx index 4706f348..d1eaf1e7 100644 --- a/resources/js/components/transactions/import-step-preview.tsx +++ b/resources/js/components/transactions/import-step-preview.tsx @@ -1,5 +1,5 @@ -import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; import { Table, TableBody, @@ -72,7 +72,8 @@ export function ImportStepPreview({ {stats.newCount === 0 && stats.duplicateCount > 0 && (

- All transactions appear to be duplicates. No new transactions will be imported. + All transactions appear to be duplicates. No new + transactions will be imported.

)} @@ -84,13 +85,18 @@ export function ImportStepPreview({ Date Description Amount - Status + + Status + {transactions.length === 0 ? ( - + No valid transactions found @@ -99,11 +105,15 @@ export function ImportStepPreview({ - {formatDate(transaction.transaction_date)} + {formatDate( + transaction.transaction_date, + )} {transaction.description} @@ -113,7 +123,9 @@ export function ImportStepPreview({ {transaction.isDuplicate ? ( - Duplicate + + Duplicate + ) : ( New )} @@ -126,7 +138,11 @@ export function ImportStepPreview({
-
); } - diff --git a/resources/js/components/transactions/import-transactions-button.tsx b/resources/js/components/transactions/import-transactions-button.tsx index 9bae2312..8a24b603 100644 --- a/resources/js/components/transactions/import-transactions-button.tsx +++ b/resources/js/components/transactions/import-transactions-button.tsx @@ -1,15 +1,17 @@ -import { useState } from 'react'; import { Button } from '@/components/ui/button'; -import { Upload } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; -import { ImportTransactionsDrawer } from './import-transactions-drawer'; +import { useEncryptionKey } from '@/contexts/encryption-key-context'; import { type Account, type Bank } from '@/types/account'; import { type Category } from '@/types/category'; +import { Upload } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { ImportTransactionsDrawer } from './import-transactions-drawer'; interface ImportTransactionsButtonProps { categories: Category[]; @@ -22,8 +24,19 @@ export function ImportTransactionsButton({ accounts, banks, }: ImportTransactionsButtonProps) { + const { isKeySet } = useEncryptionKey(); const [drawerOpen, setDrawerOpen] = useState(false); + const handleOpenDrawer = () => { + if (!isKeySet) { + toast.error( + 'Please unlock your encryption key to import transactions', + ); + return; + } + setDrawerOpen(true); + }; + return ( <> @@ -32,14 +45,16 @@ export function ImportTransactionsButton({ - Import transactions from CSV/Excel + + Import transactions from CSV/Excel + @@ -53,4 +68,3 @@ export function ImportTransactionsButton({ ); } - diff --git a/resources/js/components/transactions/import-transactions-drawer.tsx b/resources/js/components/transactions/import-transactions-drawer.tsx index d23373b6..47c79772 100644 --- a/resources/js/components/transactions/import-transactions-drawer.tsx +++ b/resources/js/components/transactions/import-transactions-drawer.tsx @@ -30,7 +30,9 @@ import { type ColumnMapping, type ImportState, } from '@/types/import'; +import { Check, Loader2 } from 'lucide-react'; import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; import { ImportStepAccount } from './import-step-account'; import { ImportStepMapping } from './import-step-mapping'; import { ImportStepPreview } from './import-step-preview'; @@ -288,15 +290,23 @@ export function ImportTransactionsDrawer({ setIsImporting(true); setError(null); + const newTransactions = state.transactions.filter( + (t) => !t.isDuplicate, + ); + const total = newTransactions.length; + + const toastId = toast('Importing transactions...', { + icon: , + duration: Infinity, + }); + + onOpenChange(false); + try { if (!selectedAccount) { throw new Error('Selected account not found'); } - const newTransactions = state.transactions.filter( - (t) => !t.isDuplicate, - ); - const transactionsToImport = await Promise.all( newTransactions.map(async (transaction) => { const { encrypted, iv } = @@ -321,7 +331,8 @@ export function ImportTransactionsDrawer({ }), ); - const createdTransactions = await transactionSyncService.createMany(transactionsToImport); + const createdTransactions = + await transactionSyncService.createMany(transactionsToImport); const keyString = getStoredKey(); if (keyString) { @@ -340,7 +351,9 @@ export function ImportTransactionsDrawer({ (a) => a.id === transaction.account_id, ); const category = transaction.category_id - ? categories.find((c) => c.id === transaction.category_id) + ? categories.find( + (c) => c.id === transaction.category_id, + ) : null; const decryptedTransaction = { @@ -371,11 +384,14 @@ export function ImportTransactionsDrawer({ finalNotesIv = result.noteIv; } - await transactionSyncService.update(transaction.id, { - category_id: result.categoryId, - notes: finalNotes, - notes_iv: finalNotesIv, - }); + await transactionSyncService.update( + transaction.id, + { + category_id: result.categoryId, + notes: finalNotes, + notes_iv: finalNotesIv, + }, + ); } } } @@ -383,8 +399,20 @@ export function ImportTransactionsDrawer({ sync(); - onOpenChange(false); + toast.success( + `${total} transaction${total !== 1 ? 's' : ''} imported`, + { + id: toastId, + icon: , + }, + ); } catch (err) { + toast.error( + err instanceof Error + ? err.message + : 'Failed to import transactions', + { id: toastId }, + ); setError( err instanceof Error ? err.message diff --git a/resources/js/components/transactions/transaction-filters.tsx b/resources/js/components/transactions/transaction-filters.tsx index e5313880..1a46db61 100644 --- a/resources/js/components/transactions/transaction-filters.tsx +++ b/resources/js/components/transactions/transaction-filters.tsx @@ -1,7 +1,9 @@ -import { type ReactNode, useState } from 'react'; -import * as Icons from 'lucide-react'; import { format } from 'date-fns'; +import * as Icons from 'lucide-react'; +import { type ReactNode, useState } from 'react'; +import { EncryptedText } from '@/components/encrypted-text'; +import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -10,10 +12,8 @@ import { PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; -import { Badge } from '@/components/ui/badge'; -import { EncryptedText } from '@/components/encrypted-text'; -import { type Category, getCategoryColorClasses } from '@/types/category'; import { type Account } from '@/types/account'; +import { type Category, getCategoryColorClasses } from '@/types/category'; import { type TransactionFilters } from '@/types/transaction'; interface TransactionFiltersProps { @@ -116,12 +116,15 @@ export function TransactionFilters({
-
+
@@ -138,7 +141,10 @@ export function TransactionFilters({ type="date" value={ filters.dateTo - ? format(filters.dateTo, 'yyyy-MM-dd') + ? format( + filters.dateTo, + 'yyyy-MM-dd', + ) : '' } onChange={(e) => @@ -156,7 +162,7 @@ export function TransactionFilters({
-
+
-
+
- handleCategoryToggle(UNCATEGORIZED_CATEGORY_ID) + handleCategoryToggle( + UNCATEGORIZED_CATEGORY_ID, + ) } > @@ -214,21 +227,28 @@ export function TransactionFilters({ {categories.map((category) => { const isSelected = - filters.categoryIds.includes(category.id); - const IconComponent = resolveIconComponent( - category.icon, - ); + filters.categoryIds.includes( + category.id, + ); + const IconComponent = + resolveIconComponent(category.icon); const colorClasses = - getCategoryColorClasses(category.color); + getCategoryColorClasses( + category.color, + ); return ( - handleCategoryToggle(category.id) + handleCategoryToggle( + category.id, + ) } > -
+
{accounts.map((account) => { const isSelected = - filters.accountIds.includes(account.id); + filters.accountIds.includes( + account.id, + ); return ( - handleAccountToggle(account.id) + handleAccountToggle( + account.id, + ) } > { @@ -193,4 +196,3 @@ export function useSyncContext() { } return context; } - diff --git a/resources/js/hooks/use-online-status.ts b/resources/js/hooks/use-online-status.ts index 92d8d235..1718aad6 100644 --- a/resources/js/hooks/use-online-status.ts +++ b/resources/js/hooks/use-online-status.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useEffect, useState } from 'react'; export function useOnlineStatus() { const [isOnline, setIsOnline] = useState(() => { @@ -21,4 +21,3 @@ export function useOnlineStatus() { return isOnline; } - diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/settings/layout.tsx index a3a7cf03..128e284c 100644 --- a/resources/js/layouts/settings/layout.tsx +++ b/resources/js/layouts/settings/layout.tsx @@ -1,12 +1,12 @@ +import { index as accountsIndex } from '@/actions/App/Http/Controllers/Settings/AccountController'; +import { index as automationRulesIndex } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController'; +import { index as categoriesIndex } from '@/actions/App/Http/Controllers/Settings/CategoryController'; import Heading from '@/components/heading'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; import { cn, isSameUrl, resolveUrl } from '@/lib/utils'; -import { edit as editAppearance } from '@/routes/appearance'; -import { index as accountsIndex } from '@/actions/App/Http/Controllers/Settings/AccountController'; -import { index as categoriesIndex } from '@/actions/App/Http/Controllers/Settings/CategoryController'; -import { index as automationRulesIndex } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController'; import { edit as editAccount } from '@/routes/account'; +import { edit as editAppearance } from '@/routes/appearance'; import { edit as editDeleteAccount } from '@/routes/delete-account'; import { type NavItem } from '@/types'; import { Link } from '@inertiajs/react'; diff --git a/resources/js/lib/crypto.ts b/resources/js/lib/crypto.ts index 9ccb0956..bf49f618 100644 --- a/resources/js/lib/crypto.ts +++ b/resources/js/lib/crypto.ts @@ -1,7 +1,7 @@ function ensureCryptoAvailable(): void { if (!window.crypto || !window.crypto.subtle) { throw new Error( - 'Web Crypto API is not available. Please ensure you are using HTTPS or localhost.' + 'Web Crypto API is not available. Please ensure you are using HTTPS or localhost.', ); } } @@ -14,13 +14,13 @@ export async function getKeyFromPassword(password: string): Promise { encoder.encode(password), 'PBKDF2', false, - ['deriveBits', 'deriveKey'] + ['deriveBits', 'deriveKey'], ); } export async function getAESKeyFromPBKDF( key: CryptoKey, - salt: Uint8Array + salt: Uint8Array, ): Promise { ensureCryptoAvailable(); return await window.crypto.subtle.deriveKey( @@ -33,13 +33,13 @@ export async function getAESKeyFromPBKDF( key, { name: 'AES-GCM', length: 256 }, true, - ['encrypt', 'decrypt'] + ['encrypt', 'decrypt'], ); } export async function encrypt( plaintext: string, - key: CryptoKey + key: CryptoKey, ): Promise<{ encrypted: string; iv: string }> { ensureCryptoAvailable(); const encoder = new TextEncoder(); @@ -53,7 +53,7 @@ export async function encrypt( iv, }, key, - data + data, ); return { @@ -65,7 +65,7 @@ export async function encrypt( export async function decrypt( encrypted: string, key: CryptoKey, - iv: string + iv: string, ): Promise { ensureCryptoAvailable(); const encryptedBuffer = base64ToBuffer(encrypted); @@ -77,7 +77,7 @@ export async function decrypt( iv: ivBuffer, }, key, - encryptedBuffer + encryptedBuffer, ); const decoder = new TextDecoder(); @@ -122,7 +122,6 @@ export async function importKey(keyString: string): Promise { keyBuffer, { name: 'AES-GCM', length: 256 }, true, - ['encrypt', 'decrypt'] + ['encrypt', 'decrypt'], ); } - diff --git a/resources/js/lib/db-migration-helper.ts b/resources/js/lib/db-migration-helper.ts index 128fe211..01c06774 100644 --- a/resources/js/lib/db-migration-helper.ts +++ b/resources/js/lib/db-migration-helper.ts @@ -41,4 +41,3 @@ export async function checkDatabaseVersion(): Promise<{ }; } } - diff --git a/resources/js/lib/dexie-db.ts b/resources/js/lib/dexie-db.ts index 0f1de32b..0f6ee965 100644 --- a/resources/js/lib/dexie-db.ts +++ b/resources/js/lib/dexie-db.ts @@ -1,8 +1,8 @@ -import Dexie, { type EntityTable } from 'dexie'; -import type { Account, Bank } from '@/types/account'; -import type { Category } from '@/types/category'; -import type { AutomationRule } from '@/types/automation-rule'; import type { Transaction } from '@/services/transaction-sync'; +import type { Account, Bank } from '@/types/account'; +import type { AutomationRule } from '@/types/automation-rule'; +import type { Category } from '@/types/category'; +import Dexie, { type EntityTable } from 'dexie'; export interface SyncMetadata { key: string; @@ -38,4 +38,3 @@ db.version(3).stores({ }); export { db }; - diff --git a/resources/js/lib/file-parser.ts b/resources/js/lib/file-parser.ts index c3c67356..d4d6dc68 100644 --- a/resources/js/lib/file-parser.ts +++ b/resources/js/lib/file-parser.ts @@ -1,5 +1,10 @@ +import { + DateFormat, + type ColumnMapping, + type ParsedRow, + type ParsedTransaction, +} from '@/types/import'; import * as XLSX from 'xlsx'; -import { DateFormat, type ParsedRow, type ColumnMapping, type ParsedTransaction } from '@/types/import'; function detectHeaderRow(columns: unknown[][]): number { if (!columns || columns.length === 0) { @@ -7,21 +12,28 @@ function detectHeaderRow(columns: unknown[][]): number { } const firstRowWithValue = columns.map((column) => - column.findIndex((cell) => cell !== undefined && cell !== null && String(cell).length > 1) + column.findIndex( + (cell) => + cell !== undefined && cell !== null && String(cell).length > 1, + ), ); const percentages = [0.95, 0.75]; for (const minPercentage of percentages) { - const uniqueRows = [...new Set(firstRowWithValue)].sort((a, b) => a - b); + const uniqueRows = [...new Set(firstRowWithValue)].sort( + (a, b) => a - b, + ); for (const rowNumber of uniqueRows) { if (rowNumber === -1) continue; const columnsWithValues = columns.filter((column) => { - return column[rowNumber] !== undefined && - column[rowNumber] !== null && - String(column[rowNumber]).length > 1; + return ( + column[rowNumber] !== undefined && + column[rowNumber] !== null && + String(column[rowNumber]).length > 1 + ); }).length; if (columnsWithValues / columns.length >= minPercentage) { @@ -33,7 +45,12 @@ function detectHeaderRow(columns: unknown[][]): number { return 0; } -export async function parseFile(file: File): Promise<{ headers: string[]; data: ParsedRow[]; columns: unknown[][]; headerRowIndex: number }> { +export async function parseFile(file: File): Promise<{ + headers: string[]; + data: ParsedRow[]; + columns: unknown[][]; + headerRowIndex: number; +}> { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -49,48 +66,99 @@ export async function parseFile(file: File): Promise<{ headers: string[]; data: const firstSheetName = workbook.SheetNames[0]; const worksheet = workbook.Sheets[firstSheetName]; - const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as unknown[][]; + const jsonData = XLSX.utils.sheet_to_json(worksheet, { + header: 1, + }) as unknown[][]; if (jsonData.length === 0) { reject(new Error('File is empty')); return; } - const maxColumns = Math.max(...jsonData.map(row => Array.isArray(row) ? row.length : 0)); + const maxColumns = Math.max( + ...jsonData.map((row) => + Array.isArray(row) ? row.length : 0, + ), + ); const columns: unknown[][] = []; for (let colIndex = 0; colIndex < maxColumns; colIndex++) { - const columnData = jsonData.map(row => - Array.isArray(row) ? row[colIndex] : undefined + const columnData = jsonData.map((row) => + Array.isArray(row) ? row[colIndex] : undefined, ); columns.push(columnData); } const headerRowIndex = detectHeaderRow(columns); - const letters = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z']; + const letters = [ + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', + 'I', + 'J', + 'K', + 'L', + 'M', + 'N', + 'O', + 'P', + 'Q', + 'R', + 'S', + 'T', + 'U', + 'V', + 'W', + 'X', + 'Y', + 'Z', + ]; const headers = columns.map((column, index) => { const headerValue = column[headerRowIndex]; const headerStr = String(headerValue || '').trim(); - if (headerStr && headerStr.length > 1 && isNaN(Number(headerStr))) { + if ( + headerStr && + headerStr.length > 1 && + isNaN(Number(headerStr)) + ) { return headerStr; } return letters[index] || `Column ${index + 1}`; }); - const dataRows = jsonData.slice(headerRowIndex + 1) as unknown[][]; + const dataRows = jsonData.slice( + headerRowIndex + 1, + ) as unknown[][]; const parsedData: ParsedRow[] = dataRows - .filter(row => Array.isArray(row) && row.some(cell => cell !== null && cell !== undefined && cell !== '')) - .map(row => { + .filter( + (row) => + Array.isArray(row) && + row.some( + (cell) => + cell !== null && + cell !== undefined && + cell !== '', + ), + ) + .map((row) => { const obj: ParsedRow = {}; headers.forEach((header, index) => { if (header) { const value = row[index]; - obj[header] = (value === null || value === undefined) ? null : value as string | number; + obj[header] = + value === null || value === undefined + ? null + : (value as string | number); } }); return obj; @@ -110,12 +178,19 @@ export async function parseFile(file: File): Promise<{ headers: string[]; data: }); } -export function autoDetectDateFormat(data: ParsedRow[], dateColumnName: string): DateFormat | null { +export function autoDetectDateFormat( + data: ParsedRow[], + dateColumnName: string, +): DateFormat | null { if (!data || data.length === 0 || !dateColumnName) { return null; } - const formats = [DateFormat.YearMonthDay, DateFormat.DayMonthYear, DateFormat.MonthDayYear]; + const formats = [ + DateFormat.YearMonthDay, + DateFormat.DayMonthYear, + DateFormat.MonthDayYear, + ]; const sampleSize = Math.min(10, data.length); const scores: Record = { [DateFormat.YearMonthDay]: 0, @@ -141,7 +216,7 @@ export function autoDetectDateFormat(data: ParsedRow[], dateColumnName: string): return null; } - const bestFormat = formats.find(format => scores[format] === maxScore); + const bestFormat = formats.find((format) => scores[format] === maxScore); if (maxScore >= sampleSize * 0.8) { return bestFormat || null; @@ -161,16 +236,43 @@ export function autoDetectColumns(headers: string[]): ColumnMapping { return mapping; } - const lowerHeaders = headers.map(h => { + const lowerHeaders = headers.map((h) => { if (h === null || h === undefined) { return ''; } return String(h).toLowerCase(); }); - const datePatterns = ['date', 'transaction date', 'fecha', 'transaction_date', 'trans date', 'trans_date', 'f. valor']; - const descriptionPatterns = ['description', 'desc', 'descripcion', 'concept', 'concepto', 'details', 'detalles', 'memo', 'descripción']; - const amountPatterns = ['amount', 'monto', 'value', 'valor', 'total', 'importe', 'quantity', 'cantidad']; + const datePatterns = [ + 'date', + 'transaction date', + 'fecha', + 'transaction_date', + 'trans date', + 'trans_date', + 'f. valor', + ]; + const descriptionPatterns = [ + 'description', + 'desc', + 'descripcion', + 'concept', + 'concepto', + 'details', + 'detalles', + 'memo', + 'descripción', + ]; + const amountPatterns = [ + 'amount', + 'monto', + 'value', + 'valor', + 'total', + 'importe', + 'quantity', + 'cantidad', + ]; for (let i = 0; i < lowerHeaders.length; i++) { const header = lowerHeaders[i]; @@ -180,15 +282,21 @@ export function autoDetectColumns(headers: string[]): ColumnMapping { continue; } - if (!mapping.transaction_date && datePatterns.some(p => header.includes(p))) { + if ( + !mapping.transaction_date && + datePatterns.some((p) => header.includes(p)) + ) { mapping.transaction_date = originalHeader; } - if (!mapping.description && descriptionPatterns.some(p => header.includes(p))) { + if ( + !mapping.description && + descriptionPatterns.some((p) => header.includes(p)) + ) { mapping.description = originalHeader; } - if (!mapping.amount && amountPatterns.some(p => header.includes(p))) { + if (!mapping.amount && amountPatterns.some((p) => header.includes(p))) { mapping.amount = originalHeader; } } @@ -196,7 +304,10 @@ export function autoDetectColumns(headers: string[]): ColumnMapping { return mapping; } -export function parseDate(dateStr: string | number, format: DateFormat): Date | null { +export function parseDate( + dateStr: string | number, + format: DateFormat, +): Date | null { if (!dateStr) { return null; } @@ -209,19 +320,26 @@ export function parseDate(dateStr: string | number, format: DateFormat): Date | } let str = String(dateStr).trim(); - str = str.replace(/\//g, '-').replace(/\./g, '-').replace(/[^\d-]/g, ''); + str = str + .replace(/\//g, '-') + .replace(/\./g, '-') + .replace(/[^\d-]/g, ''); - let year: number | undefined, month: number | undefined, day: number | undefined; + let year: number | undefined, + month: number | undefined, + day: number | undefined; if (str.length === 5) { const dateRegex = /^(\d{1,2})-(\d{1,2})$/; const dateArray = dateRegex.exec(str); if (dateArray) { - month = Number(dateArray[format === DateFormat.DayMonthYear ? 2 : 1]); + month = Number( + dateArray[format === DateFormat.DayMonthYear ? 2 : 1], + ); day = Number(dateArray[format === DateFormat.DayMonthYear ? 1 : 2]); } } else { - const parts = str.split('-').filter(p => p.length > 0); + const parts = str.split('-').filter((p) => p.length > 0); if (parts.length === 3) { switch (format) { @@ -255,7 +373,12 @@ export function parseDate(dateStr: string | number, format: DateFormat): Date | const date = new Date(year, month - 1, day); - if (isNaN(date.getTime()) || date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) { + if ( + isNaN(date.getTime()) || + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day + ) { return null; } @@ -276,9 +399,12 @@ export function parseAmount(amountStr: string | number): number | null { const dotPos = str.lastIndexOf('.'); const commaPos = str.lastIndexOf(','); - const decimalSep = dotPos > commaPos && dotPos !== -1 ? dotPos : - commaPos > dotPos && commaPos !== -1 ? commaPos : - -1; + const decimalSep = + dotPos > commaPos && dotPos !== -1 + ? dotPos + : commaPos > dotPos && commaPos !== -1 + ? commaPos + : -1; if (decimalSep !== -1) { const integerPart = str.substring(0, decimalSep).replace(/[^\d]/g, ''); @@ -300,14 +426,17 @@ export function parseAmount(amountStr: string | number): number | null { export function validateTransaction( row: ParsedRow, mapping: ColumnMapping, - dateFormat: DateFormat + dateFormat: DateFormat, ): { isValid: boolean; errors: string[] } { const errors: string[] = []; if (!mapping.transaction_date || !row[mapping.transaction_date]) { errors.push('Missing transaction date'); } else { - const date = parseDate(row[mapping.transaction_date] as string | number, dateFormat); + const date = parseDate( + row[mapping.transaction_date] as string | number, + dateFormat, + ); if (!date) { errors.push('Invalid date format'); } @@ -317,7 +446,11 @@ export function validateTransaction( errors.push('Missing description'); } - if (!mapping.amount || row[mapping.amount] === null || row[mapping.amount] === undefined) { + if ( + !mapping.amount || + row[mapping.amount] === null || + row[mapping.amount] === undefined + ) { errors.push('Missing amount'); } else { const amount = parseAmount(row[mapping.amount] as string | number); @@ -335,7 +468,7 @@ export function validateTransaction( export function convertRowsToTransactions( rows: ParsedRow[], mapping: ColumnMapping, - dateFormat: DateFormat + dateFormat: DateFormat, ): ParsedTransaction[] { const results: ParsedTransaction[] = []; @@ -346,7 +479,10 @@ export function convertRowsToTransactions( continue; } - const date = parseDate(row[mapping.transaction_date!] as string | number, dateFormat); + const date = parseDate( + row[mapping.transaction_date!] as string | number, + dateFormat, + ); const amount = parseAmount(row[mapping.amount!] as string | number); const description = String(row[mapping.description!] || '').trim(); @@ -366,4 +502,3 @@ export function convertRowsToTransactions( return results; } - diff --git a/resources/js/lib/import-config-storage.ts b/resources/js/lib/import-config-storage.ts index af155104..b68b2f3c 100644 --- a/resources/js/lib/import-config-storage.ts +++ b/resources/js/lib/import-config-storage.ts @@ -7,7 +7,10 @@ interface ImportConfig { const STORAGE_KEY_PREFIX = 'import_config_account_'; -export function saveImportConfig(accountId: number, config: ImportConfig): void { +export function saveImportConfig( + accountId: number, + config: ImportConfig, +): void { try { const key = `${STORAGE_KEY_PREFIX}${accountId}`; localStorage.setItem(key, JSON.stringify(config)); @@ -46,4 +49,3 @@ export function clearImportConfig(accountId: number): void { console.error('Failed to clear import configuration:', error); } } - diff --git a/resources/js/lib/key-storage.ts b/resources/js/lib/key-storage.ts index d8aeac64..aac6502e 100644 --- a/resources/js/lib/key-storage.ts +++ b/resources/js/lib/key-storage.ts @@ -23,4 +23,3 @@ export function clearKey(): void { export function isKeyPersistent(): boolean { return localStorage.getItem(ENCRYPTION_KEY_NAME) !== null; } - diff --git a/resources/js/lib/rule-engine.ts b/resources/js/lib/rule-engine.ts index 727222bc..45dbc907 100644 --- a/resources/js/lib/rule-engine.ts +++ b/resources/js/lib/rule-engine.ts @@ -107,9 +107,15 @@ export function evaluateRules( consoleDebug('[Rule Engine] Rule JSON:', rule.rules_json); const normalizedRulesJson = normalizeRuleJson(rule.rules_json); - consoleDebug('[Rule Engine] Normalized Rule JSON:', normalizedRulesJson); + consoleDebug( + '[Rule Engine] Normalized Rule JSON:', + normalizedRulesJson, + ); - const result = jsonLogic.apply(normalizedRulesJson, transactionData); + const result = jsonLogic.apply( + normalizedRulesJson, + transactionData, + ); consoleDebug(`[Rule Engine] Rule #${rule.id} result:`, result); diff --git a/resources/js/lib/sync-manager.ts b/resources/js/lib/sync-manager.ts index 2f4c6766..bd264d9b 100644 --- a/resources/js/lib/sync-manager.ts +++ b/resources/js/lib/sync-manager.ts @@ -1,7 +1,12 @@ import axios from 'axios'; import { db } from './dexie-db'; -export type StoreName = 'categories' | 'accounts' | 'banks' | 'automation_rules' | 'transactions'; +export type StoreName = + | 'categories' + | 'accounts' + | 'banks' + | 'automation_rules' + | 'transactions'; export interface IndexedDBRecord { id: number | string; @@ -203,7 +208,9 @@ export class SyncManager { const existing = await table.get(id); if (!existing) { - throw new Error(`Record ${id} not found in ${this.options.storeName}`); + throw new Error( + `Record ${id} not found in ${this.options.storeName}`, + ); } const timestamp = new Date().toISOString(); @@ -236,16 +243,15 @@ export class SyncManager { async getAll(): Promise { const table = db[this.options.storeName]; - return await table.toArray() as T[]; + return (await table.toArray()) as T[]; } async getById(id: number): Promise { const table = db[this.options.storeName]; - return (await table.get(id) as T) || null; + return ((await table.get(id)) as T) || null; } isSyncing(): boolean { return this.syncInProgress; } } - diff --git a/resources/js/pages/auth/verify-email.tsx b/resources/js/pages/auth/verify-email.tsx index 54a7afb9..dd94e18c 100644 --- a/resources/js/pages/auth/verify-email.tsx +++ b/resources/js/pages/auth/verify-email.tsx @@ -1,5 +1,4 @@ // Components -import TextLink from '@/components/text-link'; import { Button } from '@/components/ui/button'; import { Spinner } from '@/components/ui/spinner'; import AuthLayout from '@/layouts/auth-layout'; @@ -39,7 +38,7 @@ export default function VerifyEmail({ status }: { status?: string }) { href={logout()} as="button" onClick={handleLogout} - className="mx-auto block text-sm text-blue-600 hover:text-blue-800 underline" + className="mx-auto block text-sm text-blue-600 underline hover:text-blue-800" > Log out diff --git a/resources/js/pages/settings/automation-rules.tsx b/resources/js/pages/settings/automation-rules.tsx index bb1b3775..364d2032 100644 --- a/resources/js/pages/settings/automation-rules.tsx +++ b/resources/js/pages/settings/automation-rules.tsx @@ -98,7 +98,8 @@ function AutomationRuleActions({ rule }: { rule: AutomationRule }) { export default function AutomationRules() { const { isKeySet } = useEncryptionKey(); - const rawRules = useLiveQuery(() => db.automation_rules.toArray(), []) || []; + const rawRules = + useLiveQuery(() => db.automation_rules.toArray(), []) || []; const rules = rawRules.map((rule) => ({ ...rule, rules_json: diff --git a/resources/js/pages/settings/categories.tsx b/resources/js/pages/settings/categories.tsx index 5e52cbd5..a010afbd 100644 --- a/resources/js/pages/settings/categories.tsx +++ b/resources/js/pages/settings/categories.tsx @@ -153,11 +153,7 @@ export default function Categories() { { id: 'actions', enableHiding: false, - cell: ({ row }) => ( - - ), + cell: ({ row }) => , }, ]; diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx index d662f3fa..205c1289 100644 --- a/resources/js/pages/transactions/index.tsx +++ b/resources/js/pages/transactions/index.tsx @@ -8,9 +8,10 @@ import { getSortedRowModel, useReactTable, } from '@tanstack/react-table'; -import { useLiveQuery } from 'dexie-react-hooks'; import { isWithinInterval, parseISO } from 'date-fns'; +import { useLiveQuery } from 'dexie-react-hooks'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { toast } from 'sonner'; import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController'; import HeadingSmall from '@/components/heading-small'; @@ -171,7 +172,10 @@ export default function Transactions({ categories, accounts, banks }: Props) { try { key = await importKey(keyString); } catch (error) { - console.error('Failed to import encryption key:', error); + console.error( + 'Failed to import encryption key:', + error, + ); } } @@ -189,7 +193,10 @@ export default function Transactions({ categories, accounts, banks }: Props) { transaction.description_iv, ); - if (transaction.notes && transaction.notes_iv) { + if ( + transaction.notes && + transaction.notes_iv + ) { decryptedNotes = await decrypt( transaction.notes, key, @@ -205,7 +212,9 @@ export default function Transactions({ categories, accounts, banks }: Props) { } } - const account = accountsMap.get(transaction.account_id); + const account = accountsMap.get( + transaction.account_id, + ); const category = transaction.category_id ? categoriesMap.get(transaction.category_id) : null; @@ -404,115 +413,127 @@ export default function Transactions({ categories, accounts, banks }: Props) { return filteredTransactions.slice(0, displayedCount); }, [filteredTransactions, 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', - }); + 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'); - 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, + 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 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, - }); + consoleDebug('Evaluating rules against transaction...'); + const result = evaluateRules( + transaction, + rules, + categories, + accounts, + banks, + ); - updateTransaction(updatedTransaction); - consoleDebug('✓ UI state updated successfully'); - } else { - consoleDebug('❌ No rules matched this transaction'); + 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 ==='); } - } 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]); + }, + [isKeySet, categories, accounts, banks, updateTransaction], + ); async function handleBulkReEvaluateRules() { const selectedIds = Object.keys(rowSelection); @@ -530,6 +551,9 @@ export default function Transactions({ categories, accounts, banks }: Props) { 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'); @@ -777,6 +801,13 @@ export default function Transactions({ categories, accounts, banks }: Props) { } 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; diff --git a/resources/js/services/account-sync.ts b/resources/js/services/account-sync.ts index 0e1a4927..7c1dc46d 100644 --- a/resources/js/services/account-sync.ts +++ b/resources/js/services/account-sync.ts @@ -45,4 +45,3 @@ class AccountSyncService { } export const accountSyncService = new AccountSyncService(); - diff --git a/resources/js/services/bank-sync.ts b/resources/js/services/bank-sync.ts index 0ebdd303..22f866cc 100644 --- a/resources/js/services/bank-sync.ts +++ b/resources/js/services/bank-sync.ts @@ -41,4 +41,3 @@ class BankSyncService { } export const bankSyncService = new BankSyncService(); - diff --git a/resources/js/services/category-sync.ts b/resources/js/services/category-sync.ts index 2ec313c3..5e45039f 100644 --- a/resources/js/services/category-sync.ts +++ b/resources/js/services/category-sync.ts @@ -45,4 +45,3 @@ class CategorySyncService { } export const categorySyncService = new CategorySyncService(); - diff --git a/resources/js/services/transaction-sync.ts b/resources/js/services/transaction-sync.ts index 06d5c8e1..b264f610 100644 --- a/resources/js/services/transaction-sync.ts +++ b/resources/js/services/transaction-sync.ts @@ -165,7 +165,7 @@ class TransactionSyncService { async deleteMany(ids: string[]): Promise { const timestamp = new Date().toISOString(); - + for (const id of ids) { const transaction = await this.getById(id); if (!transaction) { diff --git a/resources/js/types/account.ts b/resources/js/types/account.ts index 6bff5130..a9cc6142 100644 --- a/resources/js/types/account.ts +++ b/resources/js/types/account.ts @@ -48,4 +48,3 @@ export function formatAccountType(type: AccountType): string { }; return typeMap[type] || type; } - diff --git a/resources/js/types/automation-rule.ts b/resources/js/types/automation-rule.ts index ebb0b341..9b1da02e 100644 --- a/resources/js/types/automation-rule.ts +++ b/resources/js/types/automation-rule.ts @@ -60,4 +60,3 @@ export function formatRuleActions(rule: AutomationRule): string { return 'Add note'; } - diff --git a/resources/js/types/category.ts b/resources/js/types/category.ts index 5063b6e7..8a2a3a6f 100644 --- a/resources/js/types/category.ts +++ b/resources/js/types/category.ts @@ -55,25 +55,75 @@ export function getCategoryColorClasses(color: CategoryColor): { text: string; } { const colorMap: Record = { - red: { bg: 'bg-red-100 dark:bg-red-700', text: 'text-red-700 dark:text-red-100' }, - orange: { bg: 'bg-orange-100 dark:bg-orange-700', text: 'text-orange-700 dark:text-orange-100' }, - amber: { bg: 'bg-amber-100 dark:bg-amber-700', text: 'text-amber-700 dark:text-amber-100' }, - yellow: { bg: 'bg-yellow-100 dark:bg-yellow-700', text: 'text-yellow-700 dark:text-yellow-100' }, - lime: { bg: 'bg-lime-100 dark:bg-lime-700', text: 'text-lime-700 dark:text-lime-100' }, - green: { bg: 'bg-green-100 dark:bg-green-700', text: 'text-green-700 dark:text-green-100' }, - emerald: { bg: 'bg-emerald-100 dark:bg-emerald-700', text: 'text-emerald-700 dark:text-emerald-100' }, - teal: { bg: 'bg-teal-100 dark:bg-teal-700', text: 'text-teal-700 dark:text-teal-100' }, - cyan: { bg: 'bg-cyan-100 dark:bg-cyan-700', text: 'text-cyan-700 dark:text-cyan-100' }, - sky: { bg: 'bg-sky-100 dark:bg-sky-700', text: 'text-sky-700 dark:text-sky-100' }, - blue: { bg: 'bg-blue-100 dark:bg-blue-700', text: 'text-blue-700 dark:text-blue-100' }, - indigo: { bg: 'bg-indigo-100 dark:bg-indigo-700', text: 'text-indigo-700 dark:text-indigo-100' }, - violet: { bg: 'bg-violet-100 dark:bg-violet-700', text: 'text-violet-700 dark:text-violet-100' }, - purple: { bg: 'bg-purple-100 dark:bg-purple-700', text: 'text-purple-700 dark:text-purple-100' }, - fuchsia: { bg: 'bg-fuchsia-100 dark:bg-fuchsia-700', text: 'text-fuchsia-700 dark:text-fuchsia-100' }, - pink: { bg: 'bg-pink-100 dark:bg-pink-700', text: 'text-pink-700 dark:text-pink-100' }, - rose: { bg: 'bg-rose-100 dark:bg-rose-700', text: 'text-rose-700 dark:text-rose-100' }, + red: { + bg: 'bg-red-100 dark:bg-red-700', + text: 'text-red-700 dark:text-red-100', + }, + orange: { + bg: 'bg-orange-100 dark:bg-orange-700', + text: 'text-orange-700 dark:text-orange-100', + }, + amber: { + bg: 'bg-amber-100 dark:bg-amber-700', + text: 'text-amber-700 dark:text-amber-100', + }, + yellow: { + bg: 'bg-yellow-100 dark:bg-yellow-700', + text: 'text-yellow-700 dark:text-yellow-100', + }, + lime: { + bg: 'bg-lime-100 dark:bg-lime-700', + text: 'text-lime-700 dark:text-lime-100', + }, + green: { + bg: 'bg-green-100 dark:bg-green-700', + text: 'text-green-700 dark:text-green-100', + }, + emerald: { + bg: 'bg-emerald-100 dark:bg-emerald-700', + text: 'text-emerald-700 dark:text-emerald-100', + }, + teal: { + bg: 'bg-teal-100 dark:bg-teal-700', + text: 'text-teal-700 dark:text-teal-100', + }, + cyan: { + bg: 'bg-cyan-100 dark:bg-cyan-700', + text: 'text-cyan-700 dark:text-cyan-100', + }, + sky: { + bg: 'bg-sky-100 dark:bg-sky-700', + text: 'text-sky-700 dark:text-sky-100', + }, + blue: { + bg: 'bg-blue-100 dark:bg-blue-700', + text: 'text-blue-700 dark:text-blue-100', + }, + indigo: { + bg: 'bg-indigo-100 dark:bg-indigo-700', + text: 'text-indigo-700 dark:text-indigo-100', + }, + violet: { + bg: 'bg-violet-100 dark:bg-violet-700', + text: 'text-violet-700 dark:text-violet-100', + }, + purple: { + bg: 'bg-purple-100 dark:bg-purple-700', + text: 'text-purple-700 dark:text-purple-100', + }, + fuchsia: { + bg: 'bg-fuchsia-100 dark:bg-fuchsia-700', + text: 'text-fuchsia-700 dark:text-fuchsia-100', + }, + pink: { + bg: 'bg-pink-100 dark:bg-pink-700', + text: 'text-pink-700 dark:text-pink-100', + }, + rose: { + bg: 'bg-rose-100 dark:bg-rose-700', + text: 'text-rose-700 dark:text-rose-100', + }, }; return colorMap[color]; } - diff --git a/resources/js/types/import.ts b/resources/js/types/import.ts index f5c7b595..857fc15b 100644 --- a/resources/js/types/import.ts +++ b/resources/js/types/import.ts @@ -47,5 +47,3 @@ export interface ImportState { dateFormatDetected: boolean; transactions: ParsedTransaction[]; } - - diff --git a/resources/js/types/transaction.ts b/resources/js/types/transaction.ts index 0bcb861f..6812f7bd 100644 --- a/resources/js/types/transaction.ts +++ b/resources/js/types/transaction.ts @@ -34,4 +34,3 @@ export interface TransactionFilters { accountIds: number[]; searchText: string; } -