import { store } 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 { loadBalanceImportConfig, saveBalanceImportConfig, } from '@/lib/balance-import-config-storage'; import { autoDetectDateFormat, parseAmount, parseDate, parseFile, } from '@/lib/file-parser'; import { type Account } from '@/types/account'; import { BalanceImportStep, type BalanceColumnMapping, type BalanceImportState, type ParsedBalance, } from '@/types/balance-import'; import { DateFormat } from '@/types/import'; import type { UUID } from '@/types/uuid'; import { Check } from 'lucide-react'; import { useEffect, useState } from 'react'; import { toast } from 'sonner'; import { ImportBalanceStepAccount } from './import-balances/import-balance-step-account'; import { ImportBalanceStepMapping } from './import-balances/import-balance-step-mapping'; import { ImportBalanceStepPreview } from './import-balances/import-balance-step-preview'; import { ImportBalanceStepUpload } from './import-balances/import-balance-step-upload'; interface ImportBalancesDrawerProps { open: boolean; onOpenChange: (open: boolean) => void; accounts?: Account[]; accountId?: UUID; onSuccess?: () => void; } interface ImportError { rowNumber: number; balance: { date: string; amount: string; }; error: string; } export function ImportBalancesDrawer({ open, onOpenChange, accounts = [], accountId, onSuccess, }: ImportBalancesDrawerProps) { 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 initialStep = accountId ? BalanceImportStep.UploadFile : BalanceImportStep.SelectAccount; const [state, setState] = useState({ step: initialStep, selectedAccountId: accountId ?? null, file: null, parsedData: [], columnHeaders: [], columnOptions: [], columnMapping: { balance_date: null, balance: null, }, dateFormat: DateFormat.YearMonthDay, dateFormatDetected: false, balances: [], }); useEffect(() => { if (state.selectedAccountId) { const account = accounts.find( (a) => a.id === state.selectedAccountId, ); if (account) { setSelectedAccount(account); } } }, [state.selectedAccountId, accounts]); useEffect(() => { if (!open) { setState({ step: initialStep, selectedAccountId: accountId ?? null, file: null, parsedData: [], columnHeaders: [], columnOptions: [], columnMapping: { balance_date: null, balance: null, }, dateFormat: DateFormat.YearMonthDay, dateFormatDetected: false, balances: [], }); setIsImporting(false); setError(null); setSelectedAccount(null); setImportErrors([]); setImportProgress(0); setImportTotal(0); } }, [open, accountId, initialStep]); const handleAccountSelect = (selectedAccountId: UUID) => { setState((prev) => ({ ...prev, selectedAccountId })); }; const autoDetectBalanceColumns = ( headers: string[], ): BalanceColumnMapping => { const mapping: BalanceColumnMapping = { balance_date: null, balance: null, }; if (!headers || headers.length === 0) { return mapping; } const lowerHeaders = headers.map((h) => { if (h === null || h === undefined) { return ''; } return String(h).toLowerCase(); }); const datePatterns = [ 'date', 'balance date', 'fecha', 'balance_date', 'f. valor', ]; const balancePatterns = [ 'balance', 'saldo', 'amount', 'monto', 'value', 'valor', 'total', ]; for (let i = 0; i < lowerHeaders.length; i++) { const header = lowerHeaders[i]; const originalHeader = headers[i]; if (!header || typeof header !== 'string') { continue; } if ( !mapping.balance_date && datePatterns.some((p) => header.includes(p)) ) { mapping.balance_date = originalHeader; } if ( !mapping.balance && balancePatterns.some((p) => header.includes(p)) ) { mapping.balance = originalHeader; } } return mapping; }; 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 = autoDetectBalanceColumns(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.balance_date) { const detected = autoDetectDateFormat( data, autoMapping.balance_date, ); if (detected) { detectedFormat = detected; formatDetected = true; } } let finalMapping = autoMapping; let finalDateFormat = detectedFormat; if (state.selectedAccountId) { const savedConfig = loadBalanceImportConfig( state.selectedAccountId, ); if (savedConfig) { const isValidMapping = ( mapping: BalanceColumnMapping, ): 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 BalanceColumnMapping, value: string, ) => { setState((prev) => ({ ...prev, columnMapping: { ...prev.columnMapping, [field]: value, }, })); }; const handleDateFormatChange = (format: DateFormat) => { setState((prev) => ({ ...prev, dateFormat: format })); }; const handlePreviewBalances = () => { try { const parsedBalances: ParsedBalance[] = []; for (const row of state.parsedData) { if ( !state.columnMapping.balance_date || !state.columnMapping.balance ) { continue; } const date = parseDate( row[state.columnMapping.balance_date] as string | number, state.dateFormat, ); const balance = parseAmount( row[state.columnMapping.balance] as string | number, ); if (!date || balance === null) { continue; } const formattedDate = date.toISOString().split('T')[0]; parsedBalances.push({ balance_date: formattedDate, balance: Math.round(balance * 100), }); } if (state.selectedAccountId) { saveBalanceImportConfig(state.selectedAccountId, { columnMapping: state.columnMapping, dateFormat: state.dateFormat, }); } setState((prev) => ({ ...prev, balances: parsedBalances, step: BalanceImportStep.Preview, })); } catch (err) { setError( err instanceof Error ? err.message : 'Failed to process balances', ); } }; const handleConfirmImport = async () => { setIsImporting(true); setError(null); setImportErrors([]); const total = state.balances.length; setImportTotal(total); setImportProgress(0); if (!selectedAccount) { setError('Selected account not found'); setIsImporting(false); return; } const createdBalances: unknown[] = []; const errors: ImportError[] = []; const BATCH_SIZE = 50; let processedCount = 0; const xsrfToken = decodeURIComponent( document.cookie .split('; ') .find((row) => row.startsWith('XSRF-TOKEN=')) ?.split('=')[1] || '', ); for (let i = 0; i < state.balances.length; i += BATCH_SIZE) { const batch = state.balances.slice(i, i + BATCH_SIZE); const batchResults = await Promise.allSettled( batch.map(async (balance, batchIndex) => { const rowNumber = i + batchIndex + 1; const response = await fetch( store.url(selectedAccount.id), { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-XSRF-TOKEN': xsrfToken, Accept: 'application/json', }, body: JSON.stringify({ balance_date: balance.balance_date, balance: balance.balance, }), }, ); if (!response.ok) { const data = await response.json(); throw new Error( data.message || 'Failed to create balance', ); } const data = await response.json(); return { success: true, balance: data.data, rowNumber, }; }), ); batchResults.forEach((result, batchIndex) => { const balance = batch[batchIndex]; const rowNumber = i + batchIndex + 1; if (result.status === 'fulfilled') { createdBalances.push(result.value.balance); } else { const errorMessage = result.reason instanceof Error ? result.reason.message : 'Unknown error'; errors.push({ rowNumber, balance: { date: balance.balance_date, amount: (balance.balance / 100).toString(), }, error: errorMessage, }); } }); processedCount += batch.length; setImportProgress(processedCount); } setImportErrors(errors); setIsImporting(false); const successCount = createdBalances.length; const errorCount = errors.length; if (errorCount === 0 && successCount > 0) { toast.success( `${successCount} balance${successCount !== 1 ? 's' : ''} imported successfully`, { icon: , }, ); onSuccess?.(); onOpenChange(false); } else if (successCount > 0 && errorCount > 0) { toast.warning( `${successCount} balance${successCount !== 1 ? 's' : ''} imported, ${errorCount} failed`, ); onSuccess?.(); } else if (successCount > 0) { toast.success( `${successCount} balance${successCount !== 1 ? 's' : ''} imported successfully`, { icon: , }, ); onSuccess?.(); onOpenChange(false); } else { toast.error('All balances failed to import'); } }; const moveToStep = (step: BalanceImportStep) => { setState((prev) => ({ ...prev, step })); }; const getStepInfo = () => { switch (state.step) { case BalanceImportStep.SelectAccount: return { title: 'Select Account', description: 'Choose the account where balances will be imported', }; case BalanceImportStep.UploadFile: return { title: 'Upload File', description: 'Drop your CSV or Excel file here, or click to browse', }; case BalanceImportStep.MapColumns: return { title: 'Map Columns', description: 'Match your file columns to balance fields', }; case BalanceImportStep.Preview: return { title: 'Preview Balances', description: 'Review balances before importing', }; default: if (isImporting) { return { title: 'Importing Balances', description: 'Please wait while we import your balances', }; } return { title: 'Import Balances', description: 'Import balances from CSV or Excel files', }; } }; const handleBack = () => { if (state.step === BalanceImportStep.UploadFile && accountId) { onOpenChange(false); } else if (state.step === BalanceImportStep.UploadFile) { moveToStep(BalanceImportStep.SelectAccount); } else if (state.step === BalanceImportStep.MapColumns) { moveToStep(BalanceImportStep.UploadFile); } else if (state.step === BalanceImportStep.Preview) { moveToStep(BalanceImportStep.MapColumns); } }; const renderStep = () => { switch (state.step) { case BalanceImportStep.SelectAccount: return ( moveToStep(BalanceImportStep.UploadFile)} /> ); case BalanceImportStep.UploadFile: return ( moveToStep(BalanceImportStep.MapColumns)} onBack={handleBack} /> ); case BalanceImportStep.MapColumns: return ( ); case BalanceImportStep.Preview: return ( ); default: return null; } }; const renderImportProgress = () => { const percentage = importTotal > 0 ? (importProgress / importTotal) * 100 : 0; return (
{importProgress} of {importTotal} balances imported {Math.round(percentage)}%
{importErrors.length > 0 && (

Errors ({importErrors.length})

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