import { categorize as categorizeRoute } from '@/actions/App/Http/Controllers/TransactionController'; import { AutomationRulesDialog } from '@/components/automation-rules/automation-rules-dialog'; import { EncryptedText } from '@/components/encrypted-text'; import { CategoryIcon } from '@/components/shared/category-combobox'; import { AmountDisplay } from '@/components/ui/amount-display'; import { Button } from '@/components/ui/button'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '@/components/ui/command'; import { Kbd } from '@/components/ui/kbd'; import { Skeleton } from '@/components/ui/skeleton'; import { useEncryptionKey } from '@/contexts/encryption-key-context'; import { decrypt, importKey } from '@/lib/crypto'; import { db } from '@/lib/dexie-db'; import { getStoredKey } from '@/lib/key-storage'; import { evaluateRules } from '@/lib/rule-engine'; import { cn } from '@/lib/utils'; import { transactionSyncService } from '@/services/transaction-sync'; import { type Account, type Bank } from '@/types/account'; import { type AutomationRule } from '@/types/automation-rule'; import { type Category, getCategoryColorClasses } from '@/types/category'; import { type DecryptedTransaction } from '@/types/transaction'; import { Head, Link, router, usePage } from '@inertiajs/react'; import { parseISO } from 'date-fns'; import { useLiveQuery } from 'dexie-react-hooks'; import { ArrowDown, ArrowLeft, ArrowUp, CheckCircle2, PartyPopper, Settings2, SkipBack, SkipForward, } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; const CATEGORY_USAGE_KEY = 'category-usage-order'; interface Props { categories: Category[]; accounts: Account[]; banks: Bank[]; } type AnimationState = 'idle' | 'exiting' | 'entering' | 'success'; function getCategoryUsageOrder(): string[] { try { const stored = localStorage.getItem(CATEGORY_USAGE_KEY); if (stored) { return JSON.parse(stored); } } catch { // Ignore errors } return []; } function updateCategoryUsageOrder(categoryId: string): void { try { const current = getCategoryUsageOrder(); const filtered = current.filter((id) => id !== categoryId); const updated = [categoryId, ...filtered]; localStorage.setItem(CATEGORY_USAGE_KEY, JSON.stringify(updated)); } catch { // Ignore errors } } function sortCategoriesByUsage( categories: Category[], usageOrder: string[], ): Category[] { const orderMap = new Map(usageOrder.map((id, index) => [id, index])); return [...categories].sort((a, b) => { const aIndex = orderMap.get(a.id) ?? Infinity; const bIndex = orderMap.get(b.id) ?? Infinity; if (aIndex === bIndex) { return a.name.localeCompare(b.name); } return aIndex - bIndex; }); } export default function CategorizeTransactions({ categories, accounts, banks, }: Props) { const { isKeySet } = useEncryptionKey(); const { automationRules: sharedAutomationRules } = usePage<{ automationRules: AutomationRule[]; }>().props; const transactionIds = useLiveQuery( async () => { const txs = await db.transactions.toArray(); return txs .map((t) => t.id) .sort() .join(','); }, [], '', ); const [uncategorizedTransactions, setUncategorizedTransactions] = useState< DecryptedTransaction[] >([]); const [currentIndex, setCurrentIndex] = useState(0); const [isLoading, setIsLoading] = useState(true); const [animationState, setAnimationState] = useState('idle'); const [categoryUsageOrder, setCategoryUsageOrder] = useState([]); const [lastSelectedCategory, setLastSelectedCategory] = useState(null); const [searchValue, setSearchValue] = useState(''); const [rulesDialogOpen, setRulesDialogOpen] = useState(false); const [encryptionKey, setEncryptionKey] = useState(null); const commandInputRef = useRef(null); const automationRules = useMemo( () => sharedAutomationRules.map((rule) => ({ ...rule, rules_json: typeof rule.rules_json === 'string' ? JSON.parse(rule.rules_json) : rule.rules_json, })) as AutomationRule[], [sharedAutomationRules], ); useEffect(() => { setCategoryUsageOrder(getCategoryUsageOrder()); }, []); useEffect(() => { if (!isLoading && animationState === 'idle') { commandInputRef.current?.focus(); } }, [isLoading, animationState, currentIndex]); useEffect(() => { async function loadUncategorizedTransactions() { if (transactionIds === undefined) { setIsLoading(true); return; } setIsLoading(true); try { const rawTransactions = await db.transactions.toArray(); const uncategorized = rawTransactions.filter( (t) => !t.category_id, ); const accountsMap = new Map( accounts.map((account) => [account.id, account]), ); const banksMap = new Map(banks.map((bank) => [bank.id, bank])); const keyString = getStoredKey(); let key: CryptoKey | null = null; if (keyString && isKeySet) { try { key = await importKey(keyString); setEncryptionKey(key); } catch (error) { console.error( 'Failed to import encryption key:', error, ); } } const decrypted = await Promise.all( uncategorized.map(async (transaction) => { try { let decryptedDescription = ''; let decryptedNotes: string | null = null; if (key) { try { decryptedDescription = await decrypt( transaction.description, key, transaction.description_iv, ); if ( transaction.notes && transaction.notes_iv ) { decryptedNotes = await decrypt( transaction.notes, key, transaction.notes_iv, ); } } catch (error) { console.error( 'Failed to decrypt transaction:', transaction.id, error, ); } } const account = accountsMap.get( transaction.account_id, ); const bank = account?.bank?.id ? banksMap.get(account.bank.id) : undefined; return { ...transaction, decryptedDescription, decryptedNotes, account, category: null, bank, } as DecryptedTransaction; } catch (error) { console.error( 'Failed to process transaction:', transaction.id, error, ); return null; } }), ); const validTransactions = decrypted.filter( (transaction): transaction is DecryptedTransaction => transaction !== null, ); validTransactions.sort((a, b) => { const dateA = parseISO(a.transaction_date).getTime(); const dateB = parseISO(b.transaction_date).getTime(); return dateB - dateA; }); setUncategorizedTransactions(validTransactions); } catch (error) { console.error( 'Failed to load uncategorized transactions:', error, ); } finally { setIsLoading(false); } } loadUncategorizedTransactions(); }, [transactionIds, accounts, banks, isKeySet]); const currentTransaction = uncategorizedTransactions[currentIndex]; const remainingCount = uncategorizedTransactions.length - currentIndex; const isComplete = currentIndex >= uncategorizedTransactions.length; const sortedCategories = useMemo(() => { return sortCategoriesByUsage(categories, categoryUsageOrder); }, [categories, categoryUsageOrder]); const applyAutomationRulesToQueue = useCallback(async () => { if ( !encryptionKey || !automationRules.length || uncategorizedTransactions.length === 0 ) { return; } const remainingTransactions = uncategorizedTransactions.slice(currentIndex); let categorizedCount = 0; const newUncategorizedList: DecryptedTransaction[] = [ ...uncategorizedTransactions.slice(0, currentIndex), ]; for (const transaction of remainingTransactions) { const result = await evaluateRules( transaction, automationRules, categories, accounts, banks, encryptionKey, ); if (result?.categoryId) { try { await transactionSyncService.update(transaction.id, { category_id: result.categoryId, }); categorizedCount++; const matchedCategory = categories.find( (c) => c.id === result.categoryId, ); if (matchedCategory) { updateCategoryUsageOrder(matchedCategory.id); } } catch (error) { console.error( 'Failed to apply automation rule to transaction:', error, ); newUncategorizedList.push(transaction); } } else { newUncategorizedList.push(transaction); } } if (categorizedCount > 0) { setCategoryUsageOrder(getCategoryUsageOrder()); setUncategorizedTransactions(newUncategorizedList); setCurrentIndex( Math.min(currentIndex, newUncategorizedList.length), ); } return categorizedCount; }, [ encryptionKey, automationRules, uncategorizedTransactions, currentIndex, categories, accounts, banks, ]); const handleRulesDialogClose = useCallback( async (open: boolean) => { setRulesDialogOpen(open); if (!open) { await applyAutomationRulesToQueue(); commandInputRef.current?.focus(); } }, [applyAutomationRulesToQueue], ); const handleCategorySelect = useCallback( async (category: Category) => { if (!currentTransaction || animationState !== 'idle') return; setLastSelectedCategory(category); setAnimationState('exiting'); try { await transactionSyncService.update(currentTransaction.id, { category_id: category.id, }); updateCategoryUsageOrder(category.id); setCategoryUsageOrder(getCategoryUsageOrder()); } catch (error) { console.error('Failed to update transaction:', error); setAnimationState('idle'); return; } setTimeout(() => { setAnimationState('success'); setTimeout(() => { setCurrentIndex((prev) => prev + 1); setAnimationState('entering'); setSearchValue(''); setTimeout(() => { setAnimationState('idle'); setLastSelectedCategory(null); commandInputRef.current?.focus(); }, 300); }, 400); }, 300); }, [currentTransaction, animationState], ); const handleSkip = useCallback(() => { if (animationState !== 'idle') return; setAnimationState('exiting'); setTimeout(() => { setCurrentIndex((prev) => prev + 1); setAnimationState('entering'); setTimeout(() => { setAnimationState('idle'); }, 300); }, 300); }, [animationState]); const handlePrevious = useCallback(() => { if (animationState !== 'idle' || currentIndex === 0) return; setAnimationState('exiting'); setTimeout(() => { setCurrentIndex((prev) => prev - 1); setAnimationState('entering'); setTimeout(() => { setAnimationState('idle'); }, 300); }, 300); }, [animationState, currentIndex]); // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Ctrl + R for rules dialog if (e.ctrlKey && e.key === 'r') { e.preventDefault(); setRulesDialogOpen(true); } // Ctrl + N for skip (next) if (e.ctrlKey && e.key === 'n') { e.preventDefault(); if (animationState === 'idle' && currentTransaction) { handleSkip(); } } // Ctrl + B for previous (back) if (e.ctrlKey && e.key === 'b') { e.preventDefault(); if (animationState === 'idle' && currentIndex > 0) { handlePrevious(); } } // Escape to go back to transactions if (e.key === 'Escape' && !rulesDialogOpen) { e.preventDefault(); const transactionsUrl = categorizeRoute .url() ?.replace('/categorize', ''); if (transactionsUrl) { router.visit(transactionsUrl); } } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [ animationState, currentTransaction, handleSkip, handlePrevious, currentIndex, rulesDialogOpen, ]); const formatDate = (dateStr: string): string => { const date = new Date(dateStr); return date.toLocaleDateString('en-US', { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric', }); }; if (isLoading) { return ( <>
{Array.from({ length: 9 }).map((_, i) => ( ))}
); } if (isComplete) { return ( <>

All Done!

You've categorized all your transactions.

); } if (uncategorizedTransactions.length === 0) { return ( <>

No Uncategorized Transactions

All your transactions are already categorized.

); } return ( <>
Back to Transactions
{remainingCount} remaining
{animationState === 'success' && lastSelectedCategory && (
{lastSelectedCategory.name}
)} {animationState !== 'success' && currentTransaction && (

{formatDate( currentTransaction.transaction_date, )}

{currentTransaction.decryptedDescription || 'Encrypted'}

{currentTransaction.account && (
{currentTransaction .bank ?.logo && ( { )}
)}
{currentTransaction.decryptedNotes && (

{ currentTransaction.decryptedNotes }

)}
= 0 } />
)}
{animationState !== 'success' && currentTransaction && (

Assign a new category

Search, move{' '} , and press

No categories found. {sortedCategories.map( (category) => { const colorClasses = getCategoryColorClasses( category.color, ); return ( handleCategorySelect( category, ) } disabled={ animationState !== 'idle' } className="group cursor-pointer gap-3 p-2" >
{category.name}
); }, )}
)}
); }