diff --git a/src/app/page.tsx b/src/app/page.tsx index 616389b..1fbdb3f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -323,14 +323,38 @@ export default function BudgetDashboard() { setLoading(false); }; - const handleSetupComplete = (accs: Account[], envs: Envelope[]) => { - const accsWithBase = accs.map(a => { - const base = normalizeBalanceByType(a.type, a.startingBalance ?? a.balance); - return { ...a, startingBalance: base, balance: base }; - }); - setAccounts(accsWithBase); - setEnvelopes(envs); - setTransactions([]); + const handleSetupComplete = async (accs: Account[], envs: Envelope[]) => { + // Re-load from server so imported CSV transactions are included + try { + const userData = await DataService.loadUserData(); + if (userData) { + const accsWithBase = (userData.accounts ?? accs).map(a => { + const base = normalizeBalanceByType(a.type, a.startingBalance ?? a.balance); + return { ...a, startingBalance: base, balance: base }; + }); + setAccounts(accsWithBase); + setEnvelopes(userData.envelopes ?? envs); + setTransactions(userData.transactions ?? []); + setGoals(userData.goals ?? []); + setTransactionRules(userData.transactionRules ?? []); + } else { + const accsWithBase = accs.map(a => { + const base = normalizeBalanceByType(a.type, a.startingBalance ?? a.balance); + return { ...a, startingBalance: base, balance: base }; + }); + setAccounts(accsWithBase); + setEnvelopes(envs); + setTransactions([]); + } + } catch { + const accsWithBase = accs.map(a => { + const base = normalizeBalanceByType(a.type, a.startingBalance ?? a.balance); + return { ...a, startingBalance: base, balance: base }; + }); + setAccounts(accsWithBase); + setEnvelopes(envs); + setTransactions([]); + } setSetupCompleted(true); setShowSetupWizard(false); }; diff --git a/src/components/SetupWizard.tsx b/src/components/SetupWizard.tsx index 2b7fe67..9064c8d 100644 --- a/src/components/SetupWizard.tsx +++ b/src/components/SetupWizard.tsx @@ -32,7 +32,21 @@ interface SetupWizardProps { userId: string; } -type WizardStep = 'welcome' | 'data-source' | 'accounts' | 'envelopes' | 'income-setup' | 'get-paid-setup' | 'complete'; +type WizardStep = 'welcome' | 'data-source' | 'csv-import' | 'accounts' | 'envelopes' | 'income-setup' | 'get-paid-setup' | 'complete'; + +interface CsvRow { [key: string]: string } +interface CsvMapping { + date: string; + description: string; + amount: string; + amountIn?: string; + amountOut?: string; + splitAmounts: boolean; + accountName: string; + accountType: 'checking' | 'savings' | 'credit_card'; + skipRows: number; + negateAmount: boolean; +} export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardProps) { const [currentStep, setCurrentStep] = useState('welcome'); @@ -68,6 +82,17 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP const [isSaving, setIsSaving] = useState(false); const [errorMessage, setErrorMessage] = useState(null); + // CSV import state + const [csvRawRows, setCsvRawRows] = useState([]); + const [csvHeaders, setCsvHeaders] = useState([]); + const [csvMapping, setCsvMapping] = useState({ + date: '', description: '', amount: '', amountIn: '', amountOut: '', + splitAmounts: false, accountName: 'Imported Account', + accountType: 'checking', skipRows: 0, negateAmount: false, + }); + const [csvFileName, setCsvFileName] = useState(''); + const [pendingImportedTransactions, setPendingImportedTransactions] = useState([]); + // Modal ESC key handling and click outside useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -310,7 +335,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP await DataService.saveUserData({ accounts: accountsWithDefaults, envelopes: envelopesWithAllocations, - transactions: [], + transactions: pendingImportedTransactions, setupCompleted: true, }); @@ -364,7 +389,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
); + const handleCsvFile = (file: File) => { + setCsvFileName(file.name); + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target?.result as string; + const lines = text.split(/\r?\n/).filter(l => l.trim()); + if (lines.length < 2) return; + + // Parse CSV respecting quoted fields + const parseRow = (line: string): string[] => { + const result: string[] = []; + let cur = ''; + let inQuote = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === '"') { inQuote = !inQuote; } + else if (ch === ',' && !inQuote) { result.push(cur.trim()); cur = ''; } + else { cur += ch; } + } + result.push(cur.trim()); + return result; + }; + + const headers = parseRow(lines[0]); + const rows: CsvRow[] = lines.slice(1).map(line => { + const vals = parseRow(line); + const row: CsvRow = {}; + headers.forEach((h, i) => { row[h] = vals[i] ?? ''; }); + return row; + }); + + setCsvHeaders(headers); + setCsvRawRows(rows); + + // Auto-detect common column names + const find = (...candidates: string[]) => + headers.find(h => candidates.some(c => h.toLowerCase().includes(c))) ?? ''; + const dateCol = find('date', 'posted', 'transaction date', 'trans date'); + const descCol = find('description', 'desc', 'memo', 'payee', 'name', 'details'); + const amtCol = find('amount', 'amt', 'debit/credit', 'transaction amount'); + const inCol = find('credit', 'deposit', 'money in', 'additions'); + const outCol = find('debit', 'withdrawal', 'money out', 'subtractions'); + const hasSplit = !amtCol && (inCol || outCol); + + setCsvMapping(m => ({ + ...m, + date: dateCol, description: descCol, + amount: amtCol, amountIn: inCol, amountOut: outCol, + splitAmounts: hasSplit, + accountName: file.name.replace(/\.csv$/i, '').replace(/[_-]/g, ' '), + })); + }; + reader.readAsText(file); + }; + + const importCsvTransactions = () => { + const { date, description, amount, amountIn, amountOut, splitAmounts, + accountName, accountType, negateAmount } = csvMapping; + if (!date || !description || (!splitAmounts && !amount) || (splitAmounts && !amountIn && !amountOut)) { + alert('Please map the required columns (Date, Description, and Amount).'); + return; + } + + const parseAmt = (s: string): number => { + if (!s) return 0; + const n = parseFloat(s.replace(/[$,\s]/g, '').replace(/\((.+)\)/, '-$1')); + return isNaN(n) ? 0 : n; + }; + + const accountId = `csv-${Date.now()}`; + const newAcc: Account = { + id: accountId, name: accountName, type: accountType, + balance: 0, color: 'bg-blue-500', isActive: true, + }; + + let balance = 0; + const newTransactions: { id: string; accountId: string; amount: number; description: string; date: string; envelopeId?: string }[] = []; + + csvRawRows.forEach((row, i) => { + let amt: number; + if (splitAmounts) { + const inAmt = amountIn ? parseAmt(row[amountIn]) : 0; + const outAmt = amountOut ? parseAmt(row[amountOut]) : 0; + amt = inAmt - Math.abs(outAmt); + } else { + amt = parseAmt(row[amount]); + if (negateAmount) amt = -amt; + } + if (amt === 0) return; + + const rawDate = row[date] || ''; + let parsedDate = rawDate; + try { parsedDate = new Date(rawDate).toISOString().split('T')[0]; } catch {} + + balance += amt; + newTransactions.push({ + id: `csv-tx-${Date.now()}-${i}`, + accountId, + amount: amt, + description: row[description] || 'Imported transaction', + date: parsedDate, + }); + }); + + newAcc.balance = balance; + setAccounts(prev => [...prev, newAcc]); + setPendingImportedTransactions(newTransactions); + setCurrentStep('envelopes'); + }; + + const renderCsvImport = () => { + const previewRows = csvRawRows.slice(0, 5); + const colOpts = ['', ...csvHeaders]; + + return ( +
+
+

Import Bank CSV

+

Upload a CSV export from your bank or credit card.

+
+ + {/* File upload */} + {!csvHeaders.length ? ( + + ) : ( +
+ {/* File info */} +
+ ✓ {csvFileName} — {csvRawRows.length} rows detected + +
+ + {/* Account setup */} +
+
+ + setCsvMapping(m => ({ ...m, accountName: e.target.value }))} + className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" /> +
+
+ + +
+
+ + {/* Column mapping */} +
+

Column Mapping

+
+
+ + +
+
+ + +
+
+ + {/* Amount type toggle */} +
+ + + +
+ + {!csvMapping.splitAmounts ? ( +
+
+ + +
+
+ +
+
+ ) : ( +
+
+ + +
+
+ + +
+
+ )} +
+ + {/* Preview table */} + {previewRows.length > 0 && csvMapping.date && csvMapping.description && ( +
+

Preview (first 5 rows)

+
+ + + + + + + + + + {previewRows.map((row, i) => { + const parseAmt = (s: string) => parseFloat(s?.replace(/[$,\s]/g, '').replace(/\((.+)\)/, '-$1')) || 0; + let amt = csvMapping.splitAmounts + ? parseAmt(csvMapping.amountIn ? row[csvMapping.amountIn] : '') - Math.abs(parseAmt(csvMapping.amountOut ? row[csvMapping.amountOut] : '')) + : csvMapping.negateAmount ? -parseAmt(row[csvMapping.amount]) : parseAmt(row[csvMapping.amount]); + return ( + + + + + + ); + })} + +
DateDescriptionAmount
{row[csvMapping.date]}{row[csvMapping.description]}= 0 ? 'text-green-600' : 'text-red-600'}`}> + {amt >= 0 ? '+' : ''}${Math.abs(amt).toFixed(2)} +
+
+

{csvRawRows.length} total transactions will be imported

+
+ )} + + +
+ )} + +
+ +
+
+ ); + }; + const renderAccounts = () => (
@@ -1071,6 +1381,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
{currentStep === 'welcome' && 'Welcome'} {currentStep === 'data-source' && 'Choose Setup Method'} + {currentStep === 'csv-import' && 'Import Bank CSV'} {currentStep === 'accounts' && 'Add Your Accounts'} {currentStep === 'envelopes' && 'Create Budget Envelopes'} {currentStep === 'income-setup' && 'Configure Income Allocation'} @@ -1082,6 +1393,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP {/* Step Content */} {currentStep === 'welcome' && renderWelcome()} {currentStep === 'data-source' && renderDataSource()} + {currentStep === 'csv-import' && renderCsvImport()} {currentStep === 'accounts' && renderAccounts()} {currentStep === 'envelopes' && renderEnvelopes()} {currentStep === 'income-setup' && renderIncomeSetup()}