Add CSV import to setup wizard
- New csv-import wizard step with file upload, auto column detection, column mapping UI, split-debit/credit support, and 5-row preview - Imports transactions and auto-calculates account balance from CSV data - Uses pendingImportedTransactions state (not side-channel) to reliably pass transactions through to saveUserData - handleSetupComplete reloads from server so imported transactions appear in the UI immediately after wizard completes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
20ea25f4de
commit
8f9e9269ec
2 files changed
+347
-11
No files matched your search
+32
-8
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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<WizardStep>('welcome');
|
||||
@@ -68,6 +82,17 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// CSV import state
|
||||
const [csvRawRows, setCsvRawRows] = useState<CsvRow[]>([]);
|
||||
const [csvHeaders, setCsvHeaders] = useState<string[]>([]);
|
||||
const [csvMapping, setCsvMapping] = useState<CsvMapping>({
|
||||
date: '', description: '', amount: '', amountIn: '', amountOut: '',
|
||||
splitAmounts: false, accountName: 'Imported Account',
|
||||
accountType: 'checking', skipRows: 0, negateAmount: false,
|
||||
});
|
||||
const [csvFileName, setCsvFileName] = useState('');
|
||||
const [pendingImportedTransactions, setPendingImportedTransactions] = useState<any[]>([]);
|
||||
|
||||
// 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
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<button
|
||||
onClick={() => {
|
||||
setCurrentStep('accounts');
|
||||
setCurrentStep('csv-import');
|
||||
}}
|
||||
className="p-6 border-2 border-gray-200 rounded-lg hover:border-blue-500 transition-colors"
|
||||
>
|
||||
@@ -394,6 +419,291 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
|
||||
</div>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Import Bank CSV</h2>
|
||||
<p className="text-gray-600">Upload a CSV export from your bank or credit card.</p>
|
||||
</div>
|
||||
|
||||
{/* File upload */}
|
||||
{!csvHeaders.length ? (
|
||||
<label className="flex flex-col items-center justify-center w-full h-40 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:border-blue-400 bg-gray-50 transition-colors">
|
||||
<div className="text-4xl mb-2">📁</div>
|
||||
<p className="text-sm text-gray-600 font-medium">Click to upload CSV file</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Most banks let you export transaction history as CSV</p>
|
||||
<input type="file" accept=".csv,text/csv" className="hidden"
|
||||
onChange={e => { const f = e.target.files?.[0]; if (f) handleCsvFile(f); }} />
|
||||
</label>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{/* File info */}
|
||||
<div className="flex items-center justify-between p-3 bg-green-50 border border-green-200 rounded-lg">
|
||||
<span className="text-sm font-medium text-green-800">✓ {csvFileName} — {csvRawRows.length} rows detected</span>
|
||||
<button onClick={() => { setCsvHeaders([]); setCsvRawRows([]); setCsvFileName(''); }}
|
||||
className="text-xs text-red-600 hover:text-red-800">Remove</button>
|
||||
</div>
|
||||
|
||||
{/* Account setup */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Account Name</label>
|
||||
<input type="text" value={csvMapping.accountName}
|
||||
onChange={e => 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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Account Type</label>
|
||||
<select value={csvMapping.accountType}
|
||||
onChange={e => setCsvMapping(m => ({ ...m, accountType: e.target.value as CsvMapping['accountType'] }))}
|
||||
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">
|
||||
<option value="checking">Checking</option>
|
||||
<option value="savings">Savings</option>
|
||||
<option value="credit_card">Credit Card</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column mapping */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-800 mb-3">Column Mapping</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Date column <span className="text-red-500">*</span></label>
|
||||
<select value={csvMapping.date}
|
||||
onChange={e => setCsvMapping(m => ({ ...m, date: e.target.value }))}
|
||||
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
{colOpts.map(c => <option key={c} value={c}>{c || '-- select --'}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Description column <span className="text-red-500">*</span></label>
|
||||
<select value={csvMapping.description}
|
||||
onChange={e => setCsvMapping(m => ({ ...m, description: e.target.value }))}
|
||||
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
{colOpts.map(c => <option key={c} value={c}>{c || '-- select --'}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount type toggle */}
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<label className="text-xs font-medium text-gray-700">Amount format:</label>
|
||||
<button onClick={() => setCsvMapping(m => ({ ...m, splitAmounts: false }))}
|
||||
className={`px-3 py-1 text-xs rounded-full border ${!csvMapping.splitAmounts ? 'bg-blue-600 text-white border-blue-600' : 'bg-white text-gray-600 border-gray-300'}`}>
|
||||
Single column
|
||||
</button>
|
||||
<button onClick={() => setCsvMapping(m => ({ ...m, splitAmounts: true }))}
|
||||
className={`px-3 py-1 text-xs rounded-full border ${csvMapping.splitAmounts ? 'bg-blue-600 text-white border-blue-600' : 'bg-white text-gray-600 border-gray-300'}`}>
|
||||
Separate debit/credit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!csvMapping.splitAmounts ? (
|
||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Amount column <span className="text-red-500">*</span></label>
|
||||
<select value={csvMapping.amount}
|
||||
onChange={e => setCsvMapping(m => ({ ...m, amount: e.target.value }))}
|
||||
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
{colOpts.map(c => <option key={c} value={c}>{c || '-- select --'}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end pb-1">
|
||||
<label className="flex items-center gap-2 text-xs text-gray-700 cursor-pointer">
|
||||
<input type="checkbox" checked={csvMapping.negateAmount}
|
||||
onChange={e => setCsvMapping(m => ({ ...m, negateAmount: e.target.checked }))}
|
||||
className="rounded" />
|
||||
Negate amounts (flip +/−)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Credit / Money In</label>
|
||||
<select value={csvMapping.amountIn}
|
||||
onChange={e => setCsvMapping(m => ({ ...m, amountIn: e.target.value }))}
|
||||
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
{colOpts.map(c => <option key={c} value={c}>{c || '-- select --'}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Debit / Money Out</label>
|
||||
<select value={csvMapping.amountOut}
|
||||
onChange={e => setCsvMapping(m => ({ ...m, amountOut: e.target.value }))}
|
||||
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
{colOpts.map(c => <option key={c} value={c}>{c || '-- select --'}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview table */}
|
||||
{previewRows.length > 0 && csvMapping.date && csvMapping.description && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-800 mb-2">Preview (first 5 rows)</h3>
|
||||
<div className="overflow-x-auto rounded border border-gray-200">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-2 py-1.5 text-left text-gray-600 font-medium">Date</th>
|
||||
<th className="px-2 py-1.5 text-left text-gray-600 font-medium">Description</th>
|
||||
<th className="px-2 py-1.5 text-right text-gray-600 font-medium">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr key={i} className={i % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||
<td className="px-2 py-1.5 text-gray-700">{row[csvMapping.date]}</td>
|
||||
<td className="px-2 py-1.5 text-gray-700 max-w-[200px] truncate">{row[csvMapping.description]}</td>
|
||||
<td className={`px-2 py-1.5 text-right font-medium ${amt >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{amt >= 0 ? '+' : ''}${Math.abs(amt).toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">{csvRawRows.length} total transactions will be imported</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={importCsvTransactions}
|
||||
className="w-full bg-blue-600 text-white py-2.5 px-4 rounded-lg hover:bg-blue-700 font-medium text-sm">
|
||||
Import {csvRawRows.length} Transactions →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<button onClick={() => setCurrentStep('data-source')} className="text-blue-600 hover:text-blue-800 text-sm">← Back</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderAccounts = () => (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
@@ -1071,6 +1381,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
|
||||
<div className="text-center text-sm text-gray-600">
|
||||
{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()}
|
||||
|
||||
Reference in new issue
Block a user