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
+330
-295
No files matched your search
+5
-1
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user