Add CSV import UI, recurring transactions, and security fixes

- FileUpload: full rewrite with column mapping, split debit/credit columns,
  negate toggle, duplicate detection, OFX/QFX support, and live preview
- ManualEntry: add recurring transaction checkbox with weekly/biweekly/monthly/yearly options
- DataInput: pass existingTransactions to FileUpload for duplicate detection
- Settings: use session token for delete-account; replace reset-code password
  flow with proper current+new password form
- page.tsx: fix TypeScript build error by mapping date strings to Date objects
  after setup wizard completes
- SetupWizard: fix hasSplit boolean type error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-07-14 08:56:59 -04:00
1 parent 8f9e9269ec
commit 18fe6b5141
6 files changed
+330 -295

No files matched your search

+5 -1
View File
@@ -332,9 +332,13 @@ export default function BudgetDashboard() {
const base = normalizeBalanceByType(a.type, a.startingBalance ?? a.balance); const base = normalizeBalanceByType(a.type, a.startingBalance ?? a.balance);
return { ...a, startingBalance: base, balance: base }; return { ...a, startingBalance: base, balance: base };
}); });
const txnsWithDates = (userData.transactions ?? []).map((t: any) => ({
...t,
date: typeof t.date === 'string' ? new Date(t.date) : t.date,
}));
setAccounts(accsWithBase); setAccounts(accsWithBase);
setEnvelopes(userData.envelopes ?? envs); setEnvelopes(userData.envelopes ?? envs);
setTransactions(userData.transactions ?? []); setTransactions(txnsWithDates);
setGoals(userData.goals ?? []); setGoals(userData.goals ?? []);
setTransactionRules(userData.transactionRules ?? []); setTransactionRules(userData.transactionRules ?? []);
} else { } else {
+3 -1
View File
@@ -55,6 +55,8 @@ export default function DataInput({ onTransactionsAdded, envelopes, accounts, tr
date: Date; date: Date;
envelopeId?: string; envelopeId?: string;
accountId: string; accountId: string;
isRecurring?: boolean;
recurringFrequency?: 'weekly' | 'biweekly' | 'monthly' | 'yearly';
}) => { }) => {
const newTransaction = { const newTransaction = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
@@ -103,7 +105,7 @@ export default function DataInput({ onTransactionsAdded, envelopes, accounts, tr
transactions={transactions} transactions={transactions}
/> />
) : ( ) : (
<FileUpload onTransactionsParsed={handleFileTransactionsParsed} accounts={accounts} /> <FileUpload onTransactionsParsed={handleFileTransactionsParsed} accounts={accounts} existingTransactions={transactions as any} />
)} )}
{/* Help Text */} {/* Help Text */}
+243 -225
View File
@@ -5,6 +5,7 @@ import { useState } from 'react';
interface FileUploadProps { interface FileUploadProps {
onTransactionsParsed: (transactions: ParsedTransaction[]) => void; onTransactionsParsed: (transactions: ParsedTransaction[]) => void;
accounts: { id: string; name: string }[]; accounts: { id: string; name: string }[];
existingTransactions?: { description: string; date: Date | string; amount: number }[];
} }
export interface ParsedTransaction { export interface ParsedTransaction {
@@ -15,265 +16,282 @@ export interface ParsedTransaction {
accountId?: string; accountId?: string;
} }
export default function FileUpload({ onTransactionsParsed, accounts }: FileUploadProps) { interface CsvRow { [key: string]: string }
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [fileType, setFileType] = useState<string>('csv');
const [isProcessing, setIsProcessing] = useState(false);
const [selectedAccountId, setSelectedAccountId] = useState<string>('');
const supportedFormats = [ export default function FileUpload({ onTransactionsParsed, accounts, existingTransactions = [] }: FileUploadProps) {
{ value: 'csv', label: 'CSV (Comma Separated Values)', extensions: '.csv' }, const [selectedAccountId, setSelectedAccountId] = useState(accounts?.[0]?.id || '');
{ value: 'ofx', label: 'OFX (Open Financial Exchange)', extensions: '.ofx' }, const [csvHeaders, setCsvHeaders] = useState<string[]>([]);
{ value: 'qfx', label: 'QFX (Quicken Financial Exchange)', extensions: '.qfx' }, const [csvRows, setCsvRows] = useState<CsvRow[]>([]);
{ value: 'qbo', label: 'QBO (QuickBooks Online)', extensions: '.qbo' }, const [fileName, setFileName] = useState('');
{ value: 'pdf', label: 'PDF Statement', extensions: '.pdf' }, const [dateCol, setDateCol] = useState('');
]; const [descCol, setDescCol] = useState('');
const [amtCol, setAmtCol] = useState('');
const [amtInCol, setAmtInCol] = useState('');
const [amtOutCol, setAmtOutCol] = useState('');
const [splitAmounts, setSplitAmounts] = useState(false);
const [negateAmt, setNegateAmt] = useState(false);
const [skipDuplicates, setSkipDuplicates] = useState(true);
const [fileType, setFileType] = useState<'csv' | 'ofx'>('csv');
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { const parseRow = (line: string): string[] => {
const file = event.target.files?.[0]; const result: string[] = [];
if (file) { let cur = '';
setSelectedFile(file); let inQuote = false;
for (const ch of line) {
if (ch === '"') { inQuote = !inQuote; }
else if (ch === ',' && !inQuote) { result.push(cur.trim()); cur = ''; }
else { cur += ch; }
} }
result.push(cur.trim());
return result;
}; };
const parseCSV = (content: string): ParsedTransaction[] => { const handleFile = (file: File) => {
const lines = content.split('\n'); const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
const transactions: ParsedTransaction[] = []; const isOfx = ['ofx', 'qfx', 'qbo'].includes(ext);
setFileType(isOfx ? 'ofx' : 'csv');
setFileName(file.name);
// Try to detect header row and column positions const reader = new FileReader();
const firstLine = lines[0]?.trim(); reader.onload = e => {
if (!firstLine) return transactions; const text = e.target?.result as string;
if (isOfx) {
const headers = firstLine.split(',').map(h => h.trim().toLowerCase()); const txns = parseOFX(text);
const dateIndex = headers.findIndex(h => h.includes('date') || h.includes('dt')); onTransactionsParsed(txns.map(t => ({ ...t, accountId: selectedAccountId || undefined })));
const amountIndex = headers.findIndex(h => h.includes('amount') || h.includes('amt') || h.includes('value')); return;
const descIndex = headers.findIndex(h => h.includes('description') || h.includes('desc') || h.includes('memo') || h.includes('reference'));
const categoryIndex = headers.findIndex(h => h.includes('category') || h.includes('cat'));
// Skip header row
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const columns = line.split(',');
if (columns.length >= 3) {
const dateStr = columns[dateIndex]?.trim();
const amountStr = columns[amountIndex]?.trim();
const descStr = columns[descIndex]?.trim();
const categoryStr = columns[categoryIndex]?.trim();
if (!dateStr || !amountStr || !descStr) continue;
// Try multiple date formats
let date: Date | null = null;
const dateFormats = [
// MM/DD/YYYY
() => {
const parts = dateStr.split('/');
if (parts.length === 3) {
return new Date(parseInt(parts[2]), parseInt(parts[0]) - 1, parseInt(parts[1]));
} }
return null; // CSV flow
}, const lines = text.split(/\r?\n/).filter(l => l.trim());
// YYYY-MM-DD if (lines.length < 2) return;
() => new Date(dateStr + 'T00:00:00'), const headers = parseRow(lines[0]);
// MM-DD-YYYY const rows: CsvRow[] = lines.slice(1).map(line => {
() => { const vals = parseRow(line);
const parts = dateStr.split('-'); const row: CsvRow = {};
if (parts.length === 3) { headers.forEach((h, i) => { row[h] = vals[i] ?? ''; });
return new Date(parseInt(parts[2]), parseInt(parts[0]) - 1, parseInt(parts[1])); return row;
}
return null;
},
// DD/MM/YYYY
() => {
const parts = dateStr.split('/');
if (parts.length === 3) {
return new Date(parseInt(parts[2]), parseInt(parts[1]) - 1, parseInt(parts[0]));
}
return null;
}
];
for (const formatFn of dateFormats) {
date = formatFn();
if (date && !isNaN(date.getTime())) break;
}
const amount = parseFloat(amountStr.replace(/[$,]/g, ''));
if (date && !isNaN(date.getTime()) && !isNaN(amount)) {
transactions.push({
date,
amount,
description: descStr,
category: categoryStr || undefined,
accountId: selectedAccountId || undefined,
}); });
} setCsvHeaders(headers);
} setCsvRows(rows);
}
return transactions; // Auto-detect columns
const find = (...candidates: string[]) =>
headers.find(h => candidates.some(c => h.toLowerCase().includes(c))) ?? '';
const d = find('date', 'posted', 'transaction date');
const desc = find('description', 'desc', 'memo', 'payee', 'name');
const amt = find('amount', 'amt', 'transaction amount');
const inC = find('credit', 'deposit', 'money in');
const outC = find('debit', 'withdrawal', 'money out');
const hasSplit = !amt && !!(inC || outC);
setDateCol(d); setDescCol(desc); setAmtCol(amt);
setAmtInCol(inC); setAmtOutCol(outC); setSplitAmounts(hasSplit);
};
reader.readAsText(file);
}; };
const parseOFX = (content: string): ParsedTransaction[] => { const parseOFX = (text: string): ParsedTransaction[] => {
// Basic OFX parsing - this would need to be more robust for production const txns: ParsedTransaction[] = [];
const transactions: ParsedTransaction[] = []; const matches = text.match(/<STMTTRN>[\s\S]*?<\/STMTTRN>/gi) ?? [];
const transactionRegex = /<STMTTRN>[\s\S]*?<\/STMTTRN>/g; matches.forEach(m => {
const matches = content.match(transactionRegex); const dateM = m.match(/<DTPOSTED>(.*?)</i);
const amtM = m.match(/<TRNAMT>(.*?)</i);
const descM = m.match(/<MEMO>(.*?)</i) ?? m.match(/<NAME>(.*?)</i);
if (!dateM || !amtM || !descM) return;
const ds = dateM[1].substring(0, 8);
txns.push({
date: new Date(parseInt(ds.slice(0,4)), parseInt(ds.slice(4,6))-1, parseInt(ds.slice(6,8))),
amount: parseFloat(amtM[1]),
description: descM[1],
});
});
return txns;
};
if (matches) { const parseAmt = (s: string) => parseFloat((s ?? '').replace(/[$,\s]/g, '').replace(/\((.+)\)/, '-$1')) || 0;
matches.forEach(match => {
const dateMatch = match.match(/<DTPOSTED>(.*?)<\/DTPOSTED>/);
const amountMatch = match.match(/<TRNAMT>(.*?)<\/TRNAMT>/);
const descMatch = match.match(/<MEMO>(.*?)<\/MEMO>/) || match.match(/<NAME>(.*?)<\/NAME>/);
if (dateMatch && amountMatch && descMatch) { const buildPreview = (): { date: string; desc: string; amt: number }[] =>
const dateStr = dateMatch[1].substring(0, 8); // YYYYMMDD format csvRows.slice(0, 5).map(row => {
const date = new Date( const amt = splitAmounts
parseInt(dateStr.substring(0, 4)), ? parseAmt(row[amtInCol] ?? '') - Math.abs(parseAmt(row[amtOutCol] ?? ''))
parseInt(dateStr.substring(4, 6)) - 1, : negateAmt ? -parseAmt(row[amtCol]) : parseAmt(row[amtCol]);
parseInt(dateStr.substring(6, 8)) return { date: row[dateCol] ?? '', desc: row[descCol] ?? '', amt };
});
const isDuplicate = (desc: string, dateStr: string, amt: number) => {
if (!skipDuplicates) return false;
const d = new Date(dateStr).toDateString();
return existingTransactions.some(t =>
t.description === desc &&
Math.abs(t.amount - amt) < 0.01 &&
new Date(t.date).toDateString() === d
); );
const amount = parseFloat(amountMatch[1]);
const description = descMatch[1];
transactions.push({
date,
amount,
description,
});
}
});
}
return transactions;
}; };
const handleUpload = async () => { const handleImport = () => {
if (!selectedFile) return; if (!dateCol || !descCol || (!splitAmounts && !amtCol) || (splitAmounts && !amtInCol && !amtOutCol)) {
alert('Please map Date, Description, and Amount columns.');
setIsProcessing(true);
try {
const content = await selectedFile.text();
let transactions: ParsedTransaction[] = [];
switch (fileType) {
case 'csv':
transactions = parseCSV(content);
break;
case 'ofx':
case 'qfx':
transactions = parseOFX(content);
break;
case 'qbo':
// QBO parsing would be similar to OFX
transactions = parseOFX(content);
break;
case 'pdf':
// PDF parsing would require a PDF parsing library
alert('PDF parsing is not yet implemented. Please use CSV or OFX format.');
setIsProcessing(false);
return;
default:
alert('Unsupported file format');
setIsProcessing(false);
return; return;
} }
let skipped = 0;
onTransactionsParsed(transactions); const txns: ParsedTransaction[] = [];
setSelectedFile(null); csvRows.forEach(row => {
// Reset file input const amt = splitAmounts
const fileInput = document.getElementById('file-upload') as HTMLInputElement; ? parseAmt(row[amtInCol] ?? '') - Math.abs(parseAmt(row[amtOutCol] ?? ''))
if (fileInput) fileInput.value = ''; : negateAmt ? -parseAmt(row[amtCol]) : parseAmt(row[amtCol]);
if (amt === 0) return;
} catch (error) { const rawDate = row[dateCol] ?? '';
console.error('Error parsing file:', error); let date: Date;
alert('Error parsing file. Please check the format and try again.'); try { date = new Date(rawDate); if (isNaN(date.getTime())) return; }
} finally { catch { return; }
setIsProcessing(false); const desc = row[descCol] ?? 'Imported transaction';
} if (isDuplicate(desc, rawDate, amt)) { skipped++; return; }
txns.push({ date, amount: amt, description: desc, accountId: selectedAccountId || undefined });
});
onTransactionsParsed(txns);
if (skipped > 0) alert(`Imported ${txns.length} transactions. Skipped ${skipped} duplicates.`);
setCsvHeaders([]); setCsvRows([]); setFileName('');
}; };
const colOpts = ['', ...csvHeaders];
const preview = csvHeaders.length && dateCol && descCol ? buildPreview() : [];
return ( return (
<div className="bg-white rounded-lg shadow-sm border p-6"> <div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Import Bank Transactions</h3> <h3 className="text-lg font-semibold text-gray-900">Import Bank Transactions</h3>
<div className="space-y-4"> {/* Account selector */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-1">Import to Account</label>
File Format <select value={selectedAccountId} onChange={e => setSelectedAccountId(e.target.value)}
</label> className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<select <option value="">Select an account…</option>
value={fileType} {accounts.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
onChange={(e) => setFileType(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{supportedFormats.map((format) => (
<option key={format.value} value={format.value}>
{format.label}
</option>
))}
</select> </select>
</div> </div>
<div> {/* File upload */}
<label className="block text-sm font-medium text-gray-700 mb-2"> {!csvHeaders.length ? (
Select File <label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:border-blue-400 bg-gray-50 transition-colors">
<div className="text-3xl mb-1">📁</div>
<p className="text-sm font-medium text-gray-600">Click to upload CSV / OFX / QFX</p>
<input type="file" accept=".csv,.ofx,.qfx,.qbo,text/csv" className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) handleFile(f); }} />
</label> </label>
<input ) : (
id="file-upload" <div className="space-y-4">
type="file" <div className="flex items-center justify-between p-3 bg-green-50 border border-green-200 rounded-lg">
accept={supportedFormats.find(f => f.value === fileType)?.extensions} <span className="text-sm font-medium text-green-800">✓ {fileName} — {csvRows.length} rows</span>
onChange={handleFileChange} <button onClick={() => { setCsvHeaders([]); setCsvRows([]); setFileName(''); }}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" className="text-xs text-red-600 hover:text-red-800">Remove</button>
/> </div>
{selectedFile && (
<p className="mt-2 text-sm text-gray-600"> {/* Column mapping */}
Selected: {selectedFile.name} <div>
</p> <p className="text-sm font-semibold text-gray-800 mb-2">Column Mapping</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Date <span className="text-red-500">*</span></label>
<select value={dateCol} onChange={e => setDateCol(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 <span className="text-red-500">*</span></label>
<select value={descCol} onChange={e => setDescCol(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 className="mt-3 flex items-center gap-3">
<span className="text-xs font-medium text-gray-700">Amount:</span>
{(['single', 'split'] as const).map(m => (
<button key={m} onClick={() => setSplitAmounts(m === 'split')}
className={`px-3 py-1 text-xs rounded-full border ${(m === 'split') === splitAmounts ? 'bg-blue-600 text-white border-blue-600' : 'bg-white text-gray-600 border-gray-300'}`}>
{m === 'single' ? 'Single column' : 'Debit/Credit columns'}
</button>
))}
</div>
{!splitAmounts ? (
<div className="mt-2 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={amtCol} onChange={e => setAmtCol(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={negateAmt} onChange={e => setNegateAmt(e.target.checked)} className="rounded" />
Negate (flip +/−)
</label>
</div>
</div>
) : (
<div className="mt-2 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={amtInCol} onChange={e => setAmtInCol(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={amtOutCol} onChange={e => setAmtOutCol(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> </div>
<div> {/* Options */}
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
Import to Account <input type="checkbox" checked={skipDuplicates} onChange={e => setSkipDuplicates(e.target.checked)} className="rounded" />
Skip duplicates (same date, description, and amount)
</label> </label>
<select
value={selectedAccountId}
onChange={(e) => setSelectedAccountId(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<option value="">Select an account...</option>
{accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.name}
</option>
))}
</select>
</div>
<button {/* Preview */}
onClick={handleUpload} {preview.length > 0 && (
disabled={!selectedFile || isProcessing || !selectedAccountId} <div>
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed" <p className="text-sm font-semibold text-gray-800 mb-2">Preview (first 5 rows)</p>
> <div className="overflow-x-auto rounded border border-gray-200">
{isProcessing ? 'Processing...' : 'Upload & Import'} <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>
{preview.map((row, i) => (
<tr key={i} className={i % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-2 py-1.5 text-gray-700">{row.date}</td>
<td className="px-2 py-1.5 text-gray-700 max-w-[200px] truncate">{row.desc}</td>
<td className={`px-2 py-1.5 text-right font-medium ${row.amt >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{row.amt >= 0 ? '+' : ''}${Math.abs(row.amt).toFixed(2)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-gray-500 mt-1">{csvRows.length} total transactions</p>
</div>
)}
<button onClick={handleImport}
className="w-full bg-blue-600 text-white py-2.5 px-4 rounded-lg hover:bg-blue-700 font-medium text-sm">
Import {csvRows.length} Transactions
</button> </button>
</div> </div>
)}
<div className="mt-4 text-sm text-gray-600">
<p className="font-medium mb-2">Supported formats:</p>
<ul className="list-disc list-inside space-y-1">
<li><strong>CSV:</strong> Date, Amount, Description columns</li>
<li><strong>OFX/QFX:</strong> Standard bank export formats</li>
<li><strong>QBO:</strong> QuickBooks Online format</li>
<li><strong>PDF:</strong> Coming soon</li>
</ul>
</div>
</div> </div>
); );
} }
+24
View File
@@ -22,6 +22,8 @@ interface ManualEntryProps {
date: Date; date: Date;
envelopeId?: string; envelopeId?: string;
accountId: string; accountId: string;
isRecurring?: boolean;
recurringFrequency?: 'weekly' | 'biweekly' | 'monthly' | 'yearly';
}) => void; }) => void;
envelopes: Envelope[]; envelopes: Envelope[];
accounts: { id: string; name: string }[]; accounts: { id: string; name: string }[];
@@ -35,6 +37,8 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts, t
const [selectedEnvelope, setSelectedEnvelope] = useState(''); const [selectedEnvelope, setSelectedEnvelope] = useState('');
const [selectedAccount, setSelectedAccount] = useState(accounts?.[0]?.id || ''); const [selectedAccount, setSelectedAccount] = useState(accounts?.[0]?.id || '');
const [isExpense, setIsExpense] = useState(true); const [isExpense, setIsExpense] = useState(true);
const [isRecurring, setIsRecurring] = useState(false);
const [recurringFrequency, setRecurringFrequency] = useState<'weekly' | 'biweekly' | 'monthly' | 'yearly'>('monthly');
// Update selected account when accounts change // Update selected account when accounts change
useEffect(() => { useEffect(() => {
@@ -88,6 +92,8 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts, t
date: new Date(date), date: new Date(date),
envelopeId: selectedEnvelope || undefined, envelopeId: selectedEnvelope || undefined,
accountId: selectedAccount, accountId: selectedAccount,
isRecurring: isRecurring || undefined,
recurringFrequency: isRecurring ? recurringFrequency : undefined,
}); });
// Reset form // Reset form
@@ -96,6 +102,7 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts, t
setDate(new Date().toISOString().split('T')[0]); setDate(new Date().toISOString().split('T')[0]);
setSelectedEnvelope(''); setSelectedEnvelope('');
setSelectedAccount(accounts?.[0]?.id || ''); setSelectedAccount(accounts?.[0]?.id || '');
setIsRecurring(false);
}; };
if (!accounts || accounts.length === 0) { if (!accounts || accounts.length === 0) {
@@ -237,6 +244,23 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts, t
</label> </label>
</div> </div>
{/* Recurring toggle */}
<div className="p-3 bg-gray-50 rounded-md space-y-2">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={isRecurring} onChange={e => setIsRecurring(e.target.checked)} className="rounded" />
<span className="text-sm font-medium text-gray-700">↻ Make this a recurring transaction</span>
</label>
{isRecurring && (
<select value={recurringFrequency} onChange={e => setRecurringFrequency(e.target.value as typeof recurringFrequency)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white">
<option value="weekly">Weekly</option>
<option value="biweekly">Bi-weekly (every 2 weeks)</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
)}
</div>
<button <button
type="submit" type="submit"
className="w-full bg-green-600 text-white py-2 px-4 rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500" className="w-full bg-green-600 text-white py-2 px-4 rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500"
+54 -67
View File
@@ -15,40 +15,57 @@ export default function Settings({ user, onAccountDeleted }: SettingsProps) {
const [showClearDataConfirm, setShowClearDataConfirm] = useState(false); const [showClearDataConfirm, setShowClearDataConfirm] = useState(false);
const [deleteInput, setDeleteInput] = useState(''); const [deleteInput, setDeleteInput] = useState('');
const [clearDataInput, setClearDataInput] = useState(''); const [clearDataInput, setClearDataInput] = useState('');
const [resetEmail, setResetEmail] = useState(''); const [currentPassword, setCurrentPassword] = useState('');
const [resetCode, setResetCode] = useState('');
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const handlePasswordReset = async (e: React.FormEvent) => { const handlePasswordChange = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setMessage(null); setMessage(null);
if (newPassword !== confirmPassword) {
setMessage({ type: 'error', text: 'New passwords do not match.' });
return;
}
if (newPassword.length < 8) {
setMessage({ type: 'error', text: 'New password must be at least 8 characters.' });
return;
}
setLoading(true); setLoading(true);
try { try {
// In a real app, this would send an email with a reset code // Verify current password by attempting login
// For now, we'll show a simplified flow const loginRes = await fetch('/api/auth/login', {
const response = await fetch('/api/auth/reset-password', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: resetEmail || user.email, code: resetCode, newPassword }), body: JSON.stringify({ email: user.email, password: currentPassword }),
}); });
if (!loginRes.ok) {
if (!response.ok) { setMessage({ type: 'error', text: 'Current password is incorrect.' });
const error = await response.json(); return;
throw new Error(error.error || 'Password reset failed');
} }
// Generate a reset token and use it to set the new password
setMessage({ type: 'success', text: 'Password reset successfully! Please sign in again.' }); const tokenRes = await fetch('/api/auth/reset-password', {
setResetCode(''); method: 'POST',
setNewPassword(''); headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'request', email: user.email }),
});
if (!tokenRes.ok) throw new Error('Failed to initiate password change');
const { token } = await tokenRes.json();
const resetRes = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'reset', token, newPassword }),
});
if (!resetRes.ok) {
const err = await resetRes.json();
throw new Error(err.error || 'Password change failed');
}
setMessage({ type: 'success', text: 'Password changed successfully! Signing out...' });
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('');
setTimeout(() => AuthService.signOut(), 2000); setTimeout(() => AuthService.signOut(), 2000);
} catch (error) { } catch (error) {
setMessage({ setMessage({ type: 'error', text: error instanceof Error ? error.message : 'Password change failed' });
type: 'error',
text: error instanceof Error ? error.message : 'Password reset failed',
});
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -106,11 +123,12 @@ export default function Settings({ user, onAccountDeleted }: SettingsProps) {
setLoading(true); setLoading(true);
try { try {
const token = AuthService.getSessionToken();
const response = await fetch('/api/auth/delete-account', { const response = await fetch('/api/auth/delete-account', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-user-id': user.userId, 'x-session-token': token ?? '',
}, },
}); });
@@ -206,61 +224,30 @@ export default function Settings({ user, onAccountDeleted }: SettingsProps) {
{/* Password Tab */} {/* Password Tab */}
{activeTab === 'password' && ( {activeTab === 'password' && (
<div className="bg-white rounded-lg shadow-sm border p-6 max-w-md"> <div className="bg-white rounded-lg shadow-sm border p-6 max-w-md">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Reset Password</h3> <h3 className="text-lg font-semibold text-gray-900 mb-1">Change Password</h3>
<form onSubmit={handlePasswordReset} className="space-y-4"> <p className="text-sm text-gray-500 mb-4">Signed in as <strong>{user.email}</strong></p>
<form onSubmit={handlePasswordChange} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">Current Password</label>
Email Address <input type="password" value={currentPassword} onChange={e => setCurrentPassword(e.target.value)}
</label>
<input
type="email"
value={resetEmail || user.email}
onChange={(e) => setResetEmail(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
placeholder="your@email.com" placeholder="••••••••" required />
/>
</div> </div>
<div className="bg-blue-50 border border-blue-200 rounded-md p-3 text-sm text-blue-800">
<p className="font-medium mb-1">Reset Code</p>
<p>
In a production app, a reset code would be sent to your email. For testing, enter any code and your new password.
</p>
</div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">New Password</label>
Reset Code <input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)}
</label>
<input
type="text"
value={resetCode}
onChange={(e) => setResetCode(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
placeholder="Enter code from email" placeholder="Min. 8 characters" required />
/>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">Confirm New Password</label>
New Password <input type="password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)}
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
placeholder="••••••••" placeholder="••••••••" required />
/>
</div> </div>
<button type="submit" disabled={loading || !currentPassword || !newPassword || !confirmPassword}
<button className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 transition-opacity">
type="submit" {loading ? 'Changing...' : 'Change Password'}
disabled={loading || !resetCode || !newPassword}
className="w-full text-white py-2 px-4 rounded-md hover:opacity-90 disabled:opacity-50 transition-opacity"
style={{ backgroundColor: 'var(--color-primary-blue)' }}
>
{loading ? 'Resetting...' : 'Reset Password'}
</button> </button>
</form> </form>
</div> </div>
+1 -1
View File
@@ -461,7 +461,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
const amtCol = find('amount', 'amt', 'debit/credit', 'transaction amount'); const amtCol = find('amount', 'amt', 'debit/credit', 'transaction amount');
const inCol = find('credit', 'deposit', 'money in', 'additions'); const inCol = find('credit', 'deposit', 'money in', 'additions');
const outCol = find('debit', 'withdrawal', 'money out', 'subtractions'); const outCol = find('debit', 'withdrawal', 'money out', 'subtractions');
const hasSplit = !amtCol && (inCol || outCol); const hasSplit = !amtCol && !!(inCol || outCol);
setCsvMapping(m => ({ setCsvMapping(m => ({
...m, ...m,