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:
1 parent
8f9e9269ec
commit
18fe6b5141
6 files changed
+346
-311
No files matched your search
+5
-1
@@ -332,9 +332,13 @@ export default function BudgetDashboard() {
|
||||
const base = normalizeBalanceByType(a.type, a.startingBalance ?? a.balance);
|
||||
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);
|
||||
setEnvelopes(userData.envelopes ?? envs);
|
||||
setTransactions(userData.transactions ?? []);
|
||||
setTransactions(txnsWithDates);
|
||||
setGoals(userData.goals ?? []);
|
||||
setTransactionRules(userData.transactionRules ?? []);
|
||||
} else {
|
||||
|
||||
@@ -55,6 +55,8 @@ export default function DataInput({ onTransactionsAdded, envelopes, accounts, tr
|
||||
date: Date;
|
||||
envelopeId?: string;
|
||||
accountId: string;
|
||||
isRecurring?: boolean;
|
||||
recurringFrequency?: 'weekly' | 'biweekly' | 'monthly' | 'yearly';
|
||||
}) => {
|
||||
const newTransaction = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -103,7 +105,7 @@ export default function DataInput({ onTransactionsAdded, envelopes, accounts, tr
|
||||
transactions={transactions}
|
||||
/>
|
||||
) : (
|
||||
<FileUpload onTransactionsParsed={handleFileTransactionsParsed} accounts={accounts} />
|
||||
<FileUpload onTransactionsParsed={handleFileTransactionsParsed} accounts={accounts} existingTransactions={transactions as any} />
|
||||
)}
|
||||
|
||||
{/* Help Text */}
|
||||
|
||||
+259
-241
@@ -5,6 +5,7 @@ import { useState } from 'react';
|
||||
interface FileUploadProps {
|
||||
onTransactionsParsed: (transactions: ParsedTransaction[]) => void;
|
||||
accounts: { id: string; name: string }[];
|
||||
existingTransactions?: { description: string; date: Date | string; amount: number }[];
|
||||
}
|
||||
|
||||
export interface ParsedTransaction {
|
||||
@@ -15,265 +16,282 @@ export interface ParsedTransaction {
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
export default function FileUpload({ onTransactionsParsed, accounts }: FileUploadProps) {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [fileType, setFileType] = useState<string>('csv');
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [selectedAccountId, setSelectedAccountId] = useState<string>('');
|
||||
interface CsvRow { [key: string]: string }
|
||||
|
||||
const supportedFormats = [
|
||||
{ value: 'csv', label: 'CSV (Comma Separated Values)', extensions: '.csv' },
|
||||
{ value: 'ofx', label: 'OFX (Open Financial Exchange)', extensions: '.ofx' },
|
||||
{ value: 'qfx', label: 'QFX (Quicken Financial Exchange)', extensions: '.qfx' },
|
||||
{ value: 'qbo', label: 'QBO (QuickBooks Online)', extensions: '.qbo' },
|
||||
{ value: 'pdf', label: 'PDF Statement', extensions: '.pdf' },
|
||||
];
|
||||
export default function FileUpload({ onTransactionsParsed, accounts, existingTransactions = [] }: FileUploadProps) {
|
||||
const [selectedAccountId, setSelectedAccountId] = useState(accounts?.[0]?.id || '');
|
||||
const [csvHeaders, setCsvHeaders] = useState<string[]>([]);
|
||||
const [csvRows, setCsvRows] = useState<CsvRow[]>([]);
|
||||
const [fileName, setFileName] = useState('');
|
||||
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 file = event.target.files?.[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
const parseRow = (line: string): string[] => {
|
||||
const result: string[] = [];
|
||||
let cur = '';
|
||||
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 lines = content.split('\n');
|
||||
const transactions: ParsedTransaction[] = [];
|
||||
const handleFile = (file: File) => {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
|
||||
const isOfx = ['ofx', 'qfx', 'qbo'].includes(ext);
|
||||
setFileType(isOfx ? 'ofx' : 'csv');
|
||||
setFileName(file.name);
|
||||
|
||||
// Try to detect header row and column positions
|
||||
const firstLine = lines[0]?.trim();
|
||||
if (!firstLine) return transactions;
|
||||
|
||||
const headers = firstLine.split(',').map(h => h.trim().toLowerCase());
|
||||
const dateIndex = headers.findIndex(h => h.includes('date') || h.includes('dt'));
|
||||
const amountIndex = headers.findIndex(h => h.includes('amount') || h.includes('amt') || h.includes('value'));
|
||||
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;
|
||||
},
|
||||
// YYYY-MM-DD
|
||||
() => new Date(dateStr + 'T00:00:00'),
|
||||
// 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;
|
||||
},
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
const text = e.target?.result as string;
|
||||
if (isOfx) {
|
||||
const txns = parseOFX(text);
|
||||
onTransactionsParsed(txns.map(t => ({ ...t, accountId: selectedAccountId || undefined })));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return transactions;
|
||||
};
|
||||
|
||||
const parseOFX = (content: string): ParsedTransaction[] => {
|
||||
// Basic OFX parsing - this would need to be more robust for production
|
||||
const transactions: ParsedTransaction[] = [];
|
||||
const transactionRegex = /<STMTTRN>[\s\S]*?<\/STMTTRN>/g;
|
||||
const matches = content.match(transactionRegex);
|
||||
|
||||
if (matches) {
|
||||
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 dateStr = dateMatch[1].substring(0, 8); // YYYYMMDD format
|
||||
const date = new Date(
|
||||
parseInt(dateStr.substring(0, 4)),
|
||||
parseInt(dateStr.substring(4, 6)) - 1,
|
||||
parseInt(dateStr.substring(6, 8))
|
||||
);
|
||||
const amount = parseFloat(amountMatch[1]);
|
||||
const description = descMatch[1];
|
||||
|
||||
transactions.push({
|
||||
date,
|
||||
amount,
|
||||
description,
|
||||
});
|
||||
}
|
||||
// CSV flow
|
||||
const lines = text.split(/\r?\n/).filter(l => l.trim());
|
||||
if (lines.length < 2) return;
|
||||
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);
|
||||
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 handleUpload = async () => {
|
||||
if (!selectedFile) return;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
onTransactionsParsed(transactions);
|
||||
setSelectedFile(null);
|
||||
// Reset file input
|
||||
const fileInput = document.getElementById('file-upload') as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = '';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error parsing file:', error);
|
||||
alert('Error parsing file. Please check the format and try again.');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
const parseOFX = (text: string): ParsedTransaction[] => {
|
||||
const txns: ParsedTransaction[] = [];
|
||||
const matches = text.match(/<STMTTRN>[\s\S]*?<\/STMTTRN>/gi) ?? [];
|
||||
matches.forEach(m => {
|
||||
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;
|
||||
};
|
||||
|
||||
const parseAmt = (s: string) => parseFloat((s ?? '').replace(/[$,\s]/g, '').replace(/\((.+)\)/, '-$1')) || 0;
|
||||
|
||||
const buildPreview = (): { date: string; desc: string; amt: number }[] =>
|
||||
csvRows.slice(0, 5).map(row => {
|
||||
const amt = splitAmounts
|
||||
? parseAmt(row[amtInCol] ?? '') - Math.abs(parseAmt(row[amtOutCol] ?? ''))
|
||||
: negateAmt ? -parseAmt(row[amtCol]) : parseAmt(row[amtCol]);
|
||||
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 handleImport = () => {
|
||||
if (!dateCol || !descCol || (!splitAmounts && !amtCol) || (splitAmounts && !amtInCol && !amtOutCol)) {
|
||||
alert('Please map Date, Description, and Amount columns.');
|
||||
return;
|
||||
}
|
||||
let skipped = 0;
|
||||
const txns: ParsedTransaction[] = [];
|
||||
csvRows.forEach(row => {
|
||||
const amt = splitAmounts
|
||||
? parseAmt(row[amtInCol] ?? '') - Math.abs(parseAmt(row[amtOutCol] ?? ''))
|
||||
: negateAmt ? -parseAmt(row[amtCol]) : parseAmt(row[amtCol]);
|
||||
if (amt === 0) return;
|
||||
const rawDate = row[dateCol] ?? '';
|
||||
let date: Date;
|
||||
try { date = new Date(rawDate); if (isNaN(date.getTime())) return; }
|
||||
catch { return; }
|
||||
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 (
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Import Bank Transactions</h3>
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Import Bank Transactions</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
File Format
|
||||
</label>
|
||||
<select
|
||||
value={fileType}
|
||||
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>
|
||||
</div>
|
||||
{/* Account selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Import to Account</label>
|
||||
<select value={selectedAccountId} onChange={e => setSelectedAccountId(e.target.value)}
|
||||
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">
|
||||
<option value="">Select an account…</option>
|
||||
{accounts.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Select File
|
||||
{/* File upload */}
|
||||
{!csvHeaders.length ? (
|
||||
<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>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<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">✓ {fileName} — {csvRows.length} rows</span>
|
||||
<button onClick={() => { setCsvHeaders([]); setCsvRows([]); setFileName(''); }}
|
||||
className="text-xs text-red-600 hover:text-red-800">Remove</button>
|
||||
</div>
|
||||
|
||||
{/* Column mapping */}
|
||||
<div>
|
||||
<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>
|
||||
|
||||
{/* Options */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
|
||||
<input type="checkbox" checked={skipDuplicates} onChange={e => setSkipDuplicates(e.target.checked)} className="rounded" />
|
||||
Skip duplicates (same date, description, and amount)
|
||||
</label>
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
accept={supportedFormats.find(f => f.value === fileType)?.extensions}
|
||||
onChange={handleFileChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
{selectedFile && (
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Selected: {selectedFile.name}
|
||||
</p>
|
||||
|
||||
{/* Preview */}
|
||||
{preview.length > 0 && (
|
||||
<div>
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Import to Account
|
||||
</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
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile || isProcessing || !selectedAccountId}
|
||||
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"
|
||||
>
|
||||
{isProcessing ? 'Processing...' : 'Upload & Import'}
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ interface ManualEntryProps {
|
||||
date: Date;
|
||||
envelopeId?: string;
|
||||
accountId: string;
|
||||
isRecurring?: boolean;
|
||||
recurringFrequency?: 'weekly' | 'biweekly' | 'monthly' | 'yearly';
|
||||
}) => void;
|
||||
envelopes: Envelope[];
|
||||
accounts: { id: string; name: string }[];
|
||||
@@ -35,6 +37,8 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts, t
|
||||
const [selectedEnvelope, setSelectedEnvelope] = useState('');
|
||||
const [selectedAccount, setSelectedAccount] = useState(accounts?.[0]?.id || '');
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -88,6 +92,8 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts, t
|
||||
date: new Date(date),
|
||||
envelopeId: selectedEnvelope || undefined,
|
||||
accountId: selectedAccount,
|
||||
isRecurring: isRecurring || undefined,
|
||||
recurringFrequency: isRecurring ? recurringFrequency : undefined,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
@@ -96,6 +102,7 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts, t
|
||||
setDate(new Date().toISOString().split('T')[0]);
|
||||
setSelectedEnvelope('');
|
||||
setSelectedAccount(accounts?.[0]?.id || '');
|
||||
setIsRecurring(false);
|
||||
};
|
||||
|
||||
if (!accounts || accounts.length === 0) {
|
||||
@@ -237,6 +244,23 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts, t
|
||||
</label>
|
||||
</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
|
||||
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"
|
||||
|
||||
+54
-67
@@ -15,40 +15,57 @@ export default function Settings({ user, onAccountDeleted }: SettingsProps) {
|
||||
const [showClearDataConfirm, setShowClearDataConfirm] = useState(false);
|
||||
const [deleteInput, setDeleteInput] = useState('');
|
||||
const [clearDataInput, setClearDataInput] = useState('');
|
||||
const [resetEmail, setResetEmail] = useState('');
|
||||
const [resetCode, setResetCode] = useState('');
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handlePasswordReset = async (e: React.FormEvent) => {
|
||||
const handlePasswordChange = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
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);
|
||||
|
||||
try {
|
||||
// In a real app, this would send an email with a reset code
|
||||
// For now, we'll show a simplified flow
|
||||
const response = await fetch('/api/auth/reset-password', {
|
||||
// Verify current password by attempting login
|
||||
const loginRes = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: resetEmail || user.email, code: resetCode, newPassword }),
|
||||
body: JSON.stringify({ email: user.email, password: currentPassword }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Password reset failed');
|
||||
if (!loginRes.ok) {
|
||||
setMessage({ type: 'error', text: 'Current password is incorrect.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setMessage({ type: 'success', text: 'Password reset successfully! Please sign in again.' });
|
||||
setResetCode('');
|
||||
setNewPassword('');
|
||||
// Generate a reset token and use it to set the new password
|
||||
const tokenRes = await fetch('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
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);
|
||||
} catch (error) {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: error instanceof Error ? error.message : 'Password reset failed',
|
||||
});
|
||||
setMessage({ type: 'error', text: error instanceof Error ? error.message : 'Password change failed' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -106,11 +123,12 @@ export default function Settings({ user, onAccountDeleted }: SettingsProps) {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = AuthService.getSessionToken();
|
||||
const response = await fetch('/api/auth/delete-account', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'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 */}
|
||||
{activeTab === 'password' && (
|
||||
<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>
|
||||
<form onSubmit={handlePasswordReset} className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">Change Password</h3>
|
||||
<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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={resetEmail || user.email}
|
||||
onChange={(e) => setResetEmail(e.target.value)}
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Current Password</label>
|
||||
<input type="password" value={currentPassword} onChange={e => setCurrentPassword(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"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
placeholder="••••••••" required />
|
||||
</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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Reset Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={resetCode}
|
||||
onChange={(e) => setResetCode(e.target.value)}
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">New Password</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"
|
||||
placeholder="Enter code from email"
|
||||
/>
|
||||
placeholder="Min. 8 characters" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm New Password</label>
|
||||
<input type="password" value={confirmPassword} onChange={e => setConfirmPassword(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"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
placeholder="••••••••" required />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
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 type="submit" disabled={loading || !currentPassword || !newPassword || !confirmPassword}
|
||||
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 transition-opacity">
|
||||
{loading ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -461,7 +461,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
|
||||
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);
|
||||
const hasSplit = !amtCol && !!(inCol || outCol);
|
||||
|
||||
setCsvMapping(m => ({
|
||||
...m,
|
||||
|
||||
Reference in new issue
Block a user