import AlertError from '@/components/alert-error'; import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, } from '@/components/ui/drawer'; import { useEncryptionKey } from '@/contexts/encryption-key-context'; 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 { accountBalanceSyncService } from '@/services/account-balance-sync'; import { accountSyncService } from '@/services/account-sync'; import { automationRuleSyncService } from '@/services/automation-rule-sync'; import { transactionSyncService } from '@/services/transaction-sync'; import { type Account } from '@/types/account'; import { DateFormat, ImportStep, type ColumnMapping, type ImportState, } from '@/types/import'; import { Progress } from '@/components/ui/progress'; import { Check } 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'; import { ImportStepUpload } from './import-step-upload'; interface ImportTransactionsDrawerProps { open: boolean; onOpenChange: (open: boolean) => void; categories: import('@/types/category').Category[]; accounts: import('@/types/account').Account[]; banks: import('@/types/account').Bank[]; } interface ImportError { rowNumber: number; transaction: { date: string; description: string; amount: string; }; error: string; } export function ImportTransactionsDrawer({ open, onOpenChange, categories, accounts, banks, }: ImportTransactionsDrawerProps) { const { isKeySet } = useEncryptionKey(); 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) { accountSyncService .getById(state.selectedAccountId) .then((account) => { if (account) { setSelectedAccount(account); } }); } }, [state.selectedAccountId]); useEffect(() => { if (!open) { 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); } }, [open]); 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) => 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) => { 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 = await accountSyncService.getById( 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], }), ); if (state.selectedAccountId) { saveImportConfig(state.selectedAccountId, { columnMapping: state.columnMapping, dateFormat: state.dateFormat, }); } setState((prev) => ({ ...prev, transactions: transactionsWithDuplicateCheck, step: ImportStep.Preview, })); } catch (err) { setError( err instanceof Error ? err.message : 'Failed to process transactions', ); } }; const handleConfirmImport = async () => { if (!isKeySet) { setError('Please unlock your encryption key first'); return; } setIsImporting(true); setError(null); setImportErrors([]); const newTransactions = state.transactions.filter( (t) => !t.isDuplicate, ); 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 ? await automationRuleSyncService.getAll() : []; const BATCH_SIZE = 20; let processedCount = 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 = evaluateRulesForNewTransaction( { description: transaction.description, amount: transaction.amount / 100, transaction_date: transaction.transaction_date, account_id: selectedAccount.id, }, rules, categories, accounts, banks, ); 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, }; const createdTransaction = await transactionSyncService.create(transactionData); return { success: true, transaction: createdTransaction, rowNumber }; }), ); batchResults.forEach((result, batchIndex) => { const transaction = batch[batchIndex]; const rowNumber = i + batchIndex + 1; if (result.status === 'fulfilled') { createdTransactions.push(result.value.transaction); } else { errors.push({ rowNumber, transaction: { date: transaction.transaction_date, description: transaction.description, amount: transaction.amount.toString(), }, error: result.reason instanceof Error ? result.reason.message : 'Unknown error', }); } }); 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 balanceRecords = Array.from(balancesToImport.entries()).map( ([date, balance]) => ({ account_id: selectedAccount.id, balance_date: date, balance, }), ); await accountBalanceSyncService.createMany(balanceRecords); } 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 (errorCount === 0 && successCount > 0) { toast.success( `${successCount} transaction${successCount !== 1 ? 's' : ''} imported successfully`, { icon: , }, ); onOpenChange(false); } else if (successCount > 0 && errorCount > 0) { toast.warning( `${successCount} transaction${successCount !== 1 ? 's' : ''} imported, ${errorCount} failed`, ); } else if (successCount > 0) { toast.success( `${successCount} transaction${successCount !== 1 ? 's' : ''} imported successfully`, { icon: , }, ); onOpenChange(false); } else { toast.error('All transactions failed to import'); } transactionSyncService.sync().catch((syncError) => { console.error('Failed to sync transactions with backend:', syncError); }); accountBalanceSyncService.sync().catch((syncError) => { console.error('Failed to sync balances with backend:', syncError); }); }; 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 ( moveToStep(ImportStep.UploadFile)} /> ); case ImportStep.UploadFile: return ( moveToStep(ImportStep.MapColumns)} onBack={() => moveToStep(ImportStep.SelectAccount)} /> ); case ImportStep.MapColumns: return ( moveToStep(ImportStep.UploadFile)} /> ); case ImportStep.Preview: return ( moveToStep(ImportStep.MapColumns)} 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()}
); }