import { store as storeBalance } from '@/actions/App/Http/Controllers/AccountBalanceController'; import AlertError from '@/components/alert-error'; import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, } from '@/components/ui/drawer'; import { Progress } from '@/components/ui/progress'; import { useEncryptionKey } from '@/contexts/encryption-key-context'; import { IMPORT_FUNNEL_EVENT_UUID } from '@/lib/constants'; import { importKey } from '@/lib/crypto'; import { autoDetectColumns, convertRowsToTransactions, parseFile, } from '@/lib/file-parser'; import { loadImportConfig, saveImportConfig, } from '@/lib/import-config-storage'; import { getStoredKey } from '@/lib/key-storage'; import { evaluateRulesForNewTransaction } from '@/lib/rule-engine'; import { useTrackEvent } from '@/lib/track-event'; import { transactionSyncService } from '@/services/transaction-sync'; import { type Account, type Bank } from '@/types/account'; import { type AutomationRule } from '@/types/automation-rule'; import { type Category } from '@/types/category'; import { DateFormat, ImportStep, type ColumnMapping, type ImportState, } from '@/types/import'; import { router } from '@inertiajs/react'; import { useCallback, useEffect, useRef, 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'; import { ImportStepUpload } from './import-step-upload'; interface ImportTransactionsDrawerProps { accounts?: Account[]; categories?: Category[]; banks?: Bank[]; automationRules?: AutomationRule[]; open: boolean; onOpenChange: (open: boolean) => void; } interface ImportError { rowNumber: number; transaction: { date: string; description: string; amount: string; }; error: string; } type ImportFunnelStep = | 'Open' | 'Choose account' | 'Set file' | 'Set mapping' | 'Confirm Preview' | 'Finish'; export function ImportTransactionsDrawer({ accounts = [], categories = [], banks = [], automationRules = [], open, onOpenChange, }: ImportTransactionsDrawerProps) { const { isKeySet } = useEncryptionKey(); const trackEvent = useTrackEvent(); const trackedStepsRef = useRef>(new Set()); const [isImporting, setIsImporting] = useState(false); const [importProgress, setImportProgress] = useState(0); const [importTotal, setImportTotal] = useState(0); const [importErrors, setImportErrors] = useState([]); const [error, setError] = useState(null); const [selectedAccount, setSelectedAccount] = useState( null, ); const [state, setState] = useState({ step: ImportStep.SelectAccount, selectedAccountId: null, file: null, parsedData: [], columnHeaders: [], columnOptions: [], columnMapping: { transaction_date: null, description: null, amount: null, }, dateFormat: DateFormat.YearMonthDay, dateFormatDetected: false, transactions: [], }); useEffect(() => { if (state.selectedAccountId) { const account = accounts.find( (a) => a.id === state.selectedAccountId, ); if (account) { setSelectedAccount(account); } } }, [state.selectedAccountId, accounts]); const trackFunnelStep = useCallback( (step: ImportFunnelStep) => { if (!trackedStepsRef.current.has(step)) { trackedStepsRef.current.add(step); trackEvent(IMPORT_FUNNEL_EVENT_UUID, { step }); } }, [trackEvent], ); useEffect(() => { if (open) { trackFunnelStep('Open'); } else { setState({ step: ImportStep.SelectAccount, selectedAccountId: null, file: null, parsedData: [], columnHeaders: [], columnOptions: [], columnMapping: { transaction_date: null, description: null, amount: null, }, dateFormat: DateFormat.YearMonthDay, dateFormatDetected: false, transactions: [], }); setIsImporting(false); setError(null); setSelectedAccount(null); trackedStepsRef.current.clear(); } }, [open, trackFunnelStep]); const handleAccountSelect = (accountId: number) => { setState((prev) => ({ ...prev, selectedAccountId: accountId })); }; const handleFileSelect = async (file: File) => { if (!file) { setState((prev) => ({ ...prev, file: null, parsedData: [], columnHeaders: [], columnOptions: [], })); return; } try { const { headers, data, columns, headerRowIndex } = await parseFile(file); const autoMapping = autoDetectColumns(headers); const columnOptions = headers.map((header, index) => { const columnData = columns[index] || []; const middleIndex = Math.floor(columnData.length / 2); const examples = columnData .slice( Math.max(headerRowIndex + 1, middleIndex), Math.max(headerRowIndex + 1, middleIndex) + 3, ) .filter( (cell) => cell !== null && cell !== undefined && String(cell).trim() !== '', ) .map((cell) => String(cell)) .slice(0, 3); return { value: header, label: header, examples, }; }); let detectedFormat = DateFormat.YearMonthDay; let formatDetected = false; if (autoMapping.transaction_date) { const { autoDetectDateFormat } = await import( '@/lib/file-parser' ); const detected = autoDetectDateFormat( data, autoMapping.transaction_date, ); if (detected) { detectedFormat = detected; formatDetected = true; } } let finalMapping = autoMapping; let finalDateFormat = detectedFormat; if (state.selectedAccountId) { const savedConfig = loadImportConfig(state.selectedAccountId); if (savedConfig) { const isValidMapping = ( mapping: ColumnMapping, ): boolean => { const values = Object.values(mapping).filter( (v) => v !== null, ); return values.every((value) => { if (Array.isArray(value)) { return value.every((v) => headers.includes(v as string), ); } return headers.includes(value as string); }); }; if (isValidMapping(savedConfig.columnMapping)) { finalMapping = savedConfig.columnMapping; finalDateFormat = savedConfig.dateFormat; formatDetected = true; } } } setState((prev) => ({ ...prev, file, parsedData: data, columnHeaders: headers, columnOptions, columnMapping: finalMapping, dateFormat: finalDateFormat, dateFormatDetected: formatDetected, })); } catch (err) { setError( err instanceof Error ? err.message : 'Failed to parse file', ); } }; const handleMappingChange = ( field: keyof ColumnMapping, value: string | string[], ) => { setState((prev) => ({ ...prev, columnMapping: { ...prev.columnMapping, [field]: value, }, })); }; const handleDateFormatChange = (format: DateFormat) => { setState((prev) => ({ ...prev, dateFormat: format })); }; const handlePreviewTransactions = async () => { try { const parsedTransactions = convertRowsToTransactions( state.parsedData, state.columnMapping, state.dateFormat, ); const account = accounts.find( (a) => a.id === state.selectedAccountId, ); if (!account) { setError('Selected account not found'); return; } const duplicateFlags = await transactionSyncService.checkDuplicates( account.id, parsedTransactions, ); const transactionsWithDuplicateCheck = parsedTransactions.map( (transaction, index) => ({ ...transaction, isDuplicate: duplicateFlags[index], selected: !duplicateFlags[index], }), ); if (state.selectedAccountId) { saveImportConfig(state.selectedAccountId, { columnMapping: state.columnMapping, dateFormat: state.dateFormat, }); } setState((prev) => ({ ...prev, transactions: transactionsWithDuplicateCheck, step: ImportStep.Preview, })); trackFunnelStep('Set mapping'); } catch (err) { setError( err instanceof Error ? err.message : 'Failed to process transactions', ); } }; const handleConfirmImport = async () => { trackFunnelStep('Confirm Preview'); if (!isKeySet) { setError('Please unlock your encryption key first'); return; } setIsImporting(true); setError(null); setImportErrors([]); const newTransactions = state.transactions.filter((t) => t.selected); const total = newTransactions.length; setImportTotal(total); setImportProgress(0); if (!selectedAccount) { setError('Selected account not found'); setIsImporting(false); return; } const createdTransactions: unknown[] = []; const errors: ImportError[] = []; const keyString = getStoredKey(); const key = keyString ? await importKey(keyString) : null; const rules = key ? automationRules : []; const BATCH_SIZE = 20; let processedCount = 0; let uncategorizedCount = 0; for (let i = 0; i < newTransactions.length; i += BATCH_SIZE) { const batch = newTransactions.slice(i, i + BATCH_SIZE); const batchResults = await Promise.allSettled( batch.map(async (transaction, batchIndex) => { const rowNumber = i + batchIndex + 1; const { encrypted, iv } = await transactionSyncService.encryptDescription( transaction.description, ); let categoryId: string | null = null; let notes: string | null = null; let notesIv: string | null = null; if (key && rules.length > 0) { const ruleMatch = await evaluateRulesForNewTransaction( { description: transaction.description, amount: transaction.amount / 100, transaction_date: transaction.transaction_date, account_id: selectedAccount.id, }, rules, categories, accounts, banks, key, ); if (ruleMatch) { if (ruleMatch.categoryId) { categoryId = ruleMatch.categoryId; } if (ruleMatch.note && ruleMatch.noteIv) { notes = ruleMatch.note; notesIv = ruleMatch.noteIv; } } } const transactionData = { user_id: (selectedAccount as Account & { user_id?: number }) .user_id || 0, account_id: selectedAccount.id, category_id: categoryId, description: encrypted, description_iv: iv, transaction_date: transaction.transaction_date, amount: transaction.amount.toString(), currency_code: selectedAccount.currency_code, notes: notes, notes_iv: notesIv, source: 'imported' as const, }; const createdTransaction = await transactionSyncService.create(transactionData); return { success: true, transaction: createdTransaction, rowNumber, hasCategory: categoryId !== null, }; }), ); batchResults.forEach((result, batchIndex) => { const transaction = batch[batchIndex]; const rowNumber = i + batchIndex + 1; if (result.status === 'fulfilled') { createdTransactions.push(result.value.transaction); if (!result.value.hasCategory) { uncategorizedCount++; } } else { const errorMessage = result.reason instanceof Error ? result.reason.message : 'Unknown error'; console.error(`Transaction ${rowNumber} failed:`, { transaction, error: result.reason, errorMessage, stack: result.reason instanceof Error ? result.reason.stack : undefined, }); errors.push({ rowNumber, transaction: { date: transaction.transaction_date, description: transaction.description, amount: transaction.amount.toString(), }, error: errorMessage, }); } }); processedCount += batch.length; setImportProgress(processedCount); } const balancesToImport = new Map(); for (const transaction of newTransactions) { if ( transaction.balance !== null && transaction.balance !== undefined ) { balancesToImport.set( transaction.transaction_date, transaction.balance, ); } } if (balancesToImport.size > 0) { try { const xsrfToken = decodeURIComponent( document.cookie .split('; ') .find((row) => row.startsWith('XSRF-TOKEN=')) ?.split('=')[1] || '', ); const balanceRecords = Array.from(balancesToImport.entries()); for (const [date, balance] of balanceRecords) { await fetch(storeBalance.url(selectedAccount.id), { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-XSRF-TOKEN': xsrfToken, Accept: 'application/json', }, body: JSON.stringify({ balance_date: date, balance, }), }); } } catch (err) { console.error('Failed to import balances:', err); } } setImportErrors(errors); setIsImporting(false); const successCount = createdTransactions.length; const errorCount = errors.length; console.log('Import complete:', { successCount, errorCount, total }); if (successCount > 0) { trackFunnelStep('Finish'); } if (errorCount === 0 && successCount > 0) { const message = uncategorizedCount > 0 ? `${successCount} transaction${successCount !== 1 ? 's' : ''} imported (${uncategorizedCount} uncategorized)` : `${successCount} transaction${successCount !== 1 ? 's' : ''} imported successfully`; toast.success(message, { action: uncategorizedCount > 0 ? { label: 'Categorize', onClick: () => router.visit('/transactions/categorize'), } : undefined, }); onOpenChange(false); } else if (successCount > 0 && errorCount > 0) { const message = uncategorizedCount > 0 ? `${successCount} transaction${successCount !== 1 ? 's' : ''} imported (${uncategorizedCount} uncategorized), ${errorCount} failed` : `${successCount} transaction${successCount !== 1 ? 's' : ''} imported, ${errorCount} failed`; toast.warning(message, { action: uncategorizedCount > 0 ? { label: 'Categorize', onClick: () => router.visit('/transactions/categorize'), } : undefined, }); } else if (successCount > 0) { const message = uncategorizedCount > 0 ? `${successCount} transaction${successCount !== 1 ? 's' : ''} imported (${uncategorizedCount} uncategorized)` : `${successCount} transaction${successCount !== 1 ? 's' : ''} imported successfully`; toast.success(message, { action: uncategorizedCount > 0 ? { label: 'Categorize', onClick: () => router.visit('/transactions/categorize'), } : undefined, }); onOpenChange(false); } else { toast.error('All transactions failed to import'); } transactionSyncService.sync().catch((syncError) => { console.error( 'Failed to sync transactions with backend:', syncError, ); }); }; const handleSelectionChange = (index: number, selected: boolean) => { setState((prev) => ({ ...prev, transactions: prev.transactions.map((t, i) => i === index ? { ...t, selected } : t, ), })); }; const handleSelectAll = (selected: boolean) => { setState((prev) => ({ ...prev, transactions: prev.transactions.map((t) => t.isDuplicate ? t : { ...t, selected }, ), })); }; const moveToStep = (step: ImportStep) => { setState((prev) => ({ ...prev, step })); }; const getStepInfo = () => { switch (state.step) { case ImportStep.SelectAccount: return { title: 'Select Account', description: 'Choose the account where transactions will be imported', }; case ImportStep.UploadFile: return { title: 'Upload File', description: 'Drop your CSV or Excel file here, or click to browse', }; case ImportStep.MapColumns: return { title: 'Map Columns', description: 'Match your file columns to transaction fields', }; case ImportStep.Preview: return { title: 'Preview Transactions', description: 'Review transactions before importing', }; default: if (isImporting) { return { title: 'Importing Transactions', description: 'Please wait while we import your transactions', }; } return { title: 'Import Transactions', description: 'Import transactions from CSV or Excel files', }; } }; const renderStep = () => { switch (state.step) { case ImportStep.SelectAccount: return ( { trackFunnelStep('Choose account'); moveToStep(ImportStep.UploadFile); }} /> ); case ImportStep.UploadFile: return ( { trackFunnelStep('Set file'); moveToStep(ImportStep.MapColumns); }} onBack={() => moveToStep(ImportStep.SelectAccount)} /> ); case ImportStep.MapColumns: return ( moveToStep(ImportStep.UploadFile)} /> ); case ImportStep.Preview: return ( moveToStep(ImportStep.MapColumns)} onSelectionChange={handleSelectionChange} onSelectAll={handleSelectAll} isImporting={isImporting} /> ); default: return null; } }; const renderImportProgress = () => { const percentage = importTotal > 0 ? (importProgress / importTotal) * 100 : 0; return (
{importProgress} of {importTotal} transactions imported {Math.round(percentage)}%
{importErrors.length > 0 && (

Errors ({importErrors.length})

{importErrors.map((error, index) => ( ))}
Row Date Description Amount Error
{error.rowNumber} {error.transaction.date} {error.transaction.description} {error.transaction.amount} {error.error}
)}
); }; const stepInfo = getStepInfo(); return (
{stepInfo.title} {stepInfo.description} {error && (
)}
{isImporting ? renderImportProgress() : renderStep()}
); }