Major feature release: security hardening + Goals, Rules, search, charts
Security: - Replace SHA-256 password hashing with bcrypt (cost 12) + timing-safe compare - Introduce server-side session tokens (x-session-token header); drop x-user-id - Add /api/auth/logout endpoint to invalidate sessions - Enforce 8-char minimum password server-side - Fix password reset: now uses real signed tokens with 1-hour TTL Features: - Goals: savings goals with progress bars, target dates, add/withdraw funds - Auto-categorization Rules: keyword → envelope mapping applied on transaction add - Transaction search/filter: description, account, envelope, date range - Transaction table: shows envelope color dot, running balance column - Monthly Spending History chart (last 6 months income vs expenses bar chart) - Spending Insights: month-over-month comparison + overspent envelope callouts - Budget progress expanded: per-envelope bars with rollover indicator - Envelope rollover toggle: unspent funds can carry forward - Recurring transactions auto-applied on login - Save indicator in nav (Saving… / Saved ✓ / Save failed) - Nav overflow fixed: all tabs scroll horizontally instead of clipping - PWA manifest added for home-screen install support Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
8814423fa6
commit
20ea25f4de
19 files changed
+1685
-1486
No files matched your search
@@ -301,6 +301,56 @@ export default function DashboardCharts({ accounts, envelopes, transactions, vis
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monthly Spending History (Last 6 Months) */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6 lg:col-span-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Monthly Spending History (Last 6 Months)</h3>
|
||||
{(() => {
|
||||
const months = Array.from({ length: 6 }, (_, i) => {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - (5 - i), 1);
|
||||
return { year: d.getFullYear(), month: d.getMonth(), label: d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }) };
|
||||
});
|
||||
const monthlyData = months.map(m => {
|
||||
const start = new Date(m.year, m.month, 1);
|
||||
const end = new Date(m.year, m.month + 1, 0, 23, 59, 59);
|
||||
const income = transactions.filter(t => {
|
||||
const d = t.date instanceof Date ? t.date : new Date(t.date);
|
||||
return t.amount > 0 && !t.envelopeId && d >= start && d <= end;
|
||||
}).reduce((s, t) => s + t.amount, 0);
|
||||
const spent = transactions.filter(t => {
|
||||
const d = t.date instanceof Date ? t.date : new Date(t.date);
|
||||
return t.amount < 0 && d >= start && d <= end;
|
||||
}).reduce((s, t) => s + Math.abs(t.amount), 0);
|
||||
return { ...m, income, spent };
|
||||
});
|
||||
const maxVal = Math.max(...monthlyData.map(m => Math.max(m.income, m.spent)), 1);
|
||||
return monthlyData.some(m => m.income > 0 || m.spent > 0) ? (
|
||||
<div>
|
||||
<div className="flex items-end justify-between h-40 gap-3">
|
||||
{monthlyData.map(m => (
|
||||
<div key={m.label} className="flex-1 flex flex-col items-center gap-1">
|
||||
<div className="w-full flex items-end gap-0.5 h-32">
|
||||
<div className="flex-1 bg-green-400 rounded-t hover:bg-green-500 transition-colors relative group"
|
||||
style={{ height: `${(m.income / maxVal) * 100}%`, minHeight: m.income > 0 ? '4px' : '0' }}>
|
||||
{m.income > 0 && <div className="absolute -top-6 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-xs py-0.5 px-1 rounded opacity-0 group-hover:opacity-100 whitespace-nowrap">${m.income.toFixed(0)}</div>}
|
||||
</div>
|
||||
<div className="flex-1 bg-red-400 rounded-t hover:bg-red-500 transition-colors relative group"
|
||||
style={{ height: `${(m.spent / maxVal) * 100}%`, minHeight: m.spent > 0 ? '4px' : '0' }}>
|
||||
{m.spent > 0 && <div className="absolute -top-6 left-1/2 -translate-x-1/2 bg-gray-800 text-white text-xs py-0.5 px-1 rounded opacity-0 group-hover:opacity-100 whitespace-nowrap">${m.spent.toFixed(0)}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{m.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-4 mt-3 justify-center text-xs text-gray-600">
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded bg-green-400 inline-block" />Income</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded bg-red-400 inline-block" />Expenses</span>
|
||||
</div>
|
||||
</div>
|
||||
) : <p className="text-gray-500 text-sm">No data yet. Add transactions to see history.</p>;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Daily Spending Chart (Last 7 Days) */}
|
||||
{visibleCharts.dailySpending && (
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6 lg:col-span-2">
|
||||
|
||||
+88
-164
@@ -2,41 +2,35 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface Envelope {
|
||||
id: string;
|
||||
name: string;
|
||||
allocated: number;
|
||||
spent: number;
|
||||
color: string;
|
||||
accountId: string;
|
||||
incomeAllocation?: number;
|
||||
incomeAllocationType?: 'percentage' | 'fixed';
|
||||
rollover?: boolean;
|
||||
}
|
||||
|
||||
interface EnvelopeEditProps {
|
||||
envelope: {
|
||||
id: string;
|
||||
name: string;
|
||||
allocated: number;
|
||||
spent: number;
|
||||
color: string;
|
||||
accountId: string;
|
||||
incomeAllocation?: number;
|
||||
incomeAllocationType?: 'percentage' | 'fixed';
|
||||
};
|
||||
envelope: Envelope;
|
||||
accounts: { id: string; name: string }[];
|
||||
onEnvelopeUpdated: (envelope: {
|
||||
id: string;
|
||||
name: string;
|
||||
allocated: number;
|
||||
spent: number;
|
||||
color: string;
|
||||
accountId: string;
|
||||
incomeAllocation?: number;
|
||||
incomeAllocationType?: 'percentage' | 'fixed';
|
||||
}) => void;
|
||||
onEnvelopeUpdated: (envelope: Envelope) => void;
|
||||
onEnvelopeDeleted?: (envelopeId: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const colorOptions = [
|
||||
{ value: 'bg-green-500', label: 'Green', color: '#10B981' },
|
||||
{ value: 'bg-blue-500', label: 'Blue', color: '#3B82F6' },
|
||||
{ value: 'bg-purple-500', label: 'Purple', color: '#8B5CF6' },
|
||||
{ value: 'bg-orange-500', label: 'Orange', color: '#F97316' },
|
||||
{ value: 'bg-red-500', label: 'Red', color: '#EF4444' },
|
||||
{ value: 'bg-yellow-500', label: 'Yellow', color: '#EAB308' },
|
||||
{ value: 'bg-pink-500', label: 'Pink', color: '#EC4899' },
|
||||
{ value: 'bg-indigo-500', label: 'Indigo', color: '#6366F1' },
|
||||
{ label: 'Green', color: '#10B981' },
|
||||
{ label: 'Blue', color: '#3B82F6' },
|
||||
{ label: 'Purple', color: '#8B5CF6' },
|
||||
{ label: 'Orange', color: '#F97316' },
|
||||
{ label: 'Red', color: '#EF4444' },
|
||||
{ label: 'Yellow', color: '#EAB308' },
|
||||
{ label: 'Pink', color: '#EC4899' },
|
||||
{ label: 'Indigo', color: '#6366F1' },
|
||||
];
|
||||
|
||||
export default function EnvelopeEdit({ envelope, onEnvelopeUpdated, onEnvelopeDeleted, onCancel, accounts }: EnvelopeEditProps) {
|
||||
@@ -46,8 +40,8 @@ export default function EnvelopeEdit({ envelope, onEnvelopeUpdated, onEnvelopeDe
|
||||
const [selectedAccount, setSelectedAccount] = useState(envelope.accountId);
|
||||
const [incomeAllocationType, setIncomeAllocationType] = useState<'percentage' | 'fixed'>(envelope.incomeAllocationType || 'percentage');
|
||||
const [incomeAllocation, setIncomeAllocation] = useState(envelope.incomeAllocation?.toString() || '');
|
||||
const [rollover, setRollover] = useState(envelope.rollover ?? false);
|
||||
|
||||
// Sync form fields when a different envelope is opened for editing
|
||||
useEffect(() => {
|
||||
setName(envelope.name);
|
||||
setAllocated(envelope.allocated.toString());
|
||||
@@ -55,40 +49,29 @@ export default function EnvelopeEdit({ envelope, onEnvelopeUpdated, onEnvelopeDe
|
||||
setSelectedAccount(envelope.accountId);
|
||||
setIncomeAllocationType(envelope.incomeAllocationType || 'percentage');
|
||||
setIncomeAllocation(envelope.incomeAllocation?.toString() || '');
|
||||
setRollover(envelope.rollover ?? false);
|
||||
}, [envelope]);
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm(`Are you sure you want to delete the envelope "${envelope.name}"? Transactions linked to this envelope will be unassigned.`)) {
|
||||
if (onEnvelopeDeleted) {
|
||||
onEnvelopeDeleted(envelope.id);
|
||||
}
|
||||
if (confirm(`Delete envelope "${envelope.name}"? Transactions will be unassigned.`)) {
|
||||
onEnvelopeDeleted?.(envelope.id);
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const numAllocated = parseFloat(allocated);
|
||||
if (!name.trim() || isNaN(numAllocated) || numAllocated <= 0) {
|
||||
alert('Please enter a valid envelope name and positive allocated amount');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate income allocation
|
||||
if (incomeAllocation) {
|
||||
const allocationValue = parseFloat(incomeAllocation);
|
||||
if (isNaN(allocationValue) || allocationValue < 0) {
|
||||
alert('Please enter a valid income allocation amount');
|
||||
return;
|
||||
}
|
||||
if (incomeAllocationType === 'percentage' && allocationValue > 100) {
|
||||
alert('Percentage allocation cannot exceed 100%');
|
||||
return;
|
||||
}
|
||||
const v = parseFloat(incomeAllocation);
|
||||
if (isNaN(v) || v < 0) { alert('Please enter a valid income allocation amount'); return; }
|
||||
if (incomeAllocationType === 'percentage' && v > 100) { alert('Percentage allocation cannot exceed 100%'); return; }
|
||||
}
|
||||
|
||||
const updatedEnvelope = {
|
||||
onEnvelopeUpdated({
|
||||
...envelope,
|
||||
name: name.trim(),
|
||||
allocated: numAllocated,
|
||||
@@ -96,163 +79,104 @@ export default function EnvelopeEdit({ envelope, onEnvelopeUpdated, onEnvelopeDe
|
||||
accountId: selectedAccount,
|
||||
incomeAllocation: incomeAllocation ? parseFloat(incomeAllocation) : undefined,
|
||||
incomeAllocationType: incomeAllocation ? incomeAllocationType : undefined,
|
||||
};
|
||||
|
||||
onEnvelopeUpdated(updatedEnvelope);
|
||||
rollover,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Edit Envelope</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Envelope Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Envelope Name</label>
|
||||
<input type="text" value={name} onChange={e => setName(e.target.value)}
|
||||
placeholder="e.g., Groceries, Transportation"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Allocated Amount
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Allocated Amount</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={allocated}
|
||||
onChange={(e) => setAllocated(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="w-full pl-8 pr-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
|
||||
<input type="number" step="0.01" min="0" value={allocated} onChange={e => setAllocated(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Color
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{colorOptions.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
onClick={() => setSelectedColor(color.value)}
|
||||
className={`w-12 h-12 rounded-full border-2 ${
|
||||
selectedColor === color.value ? 'border-gray-800' : 'border-gray-300'
|
||||
}`}
|
||||
style={{ backgroundColor: color.color }}
|
||||
title={color.label}
|
||||
/>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Color</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{colorOptions.map(c => (
|
||||
<button key={c.color} type="button" onClick={() => setSelectedColor(c.color)}
|
||||
className={`w-10 h-10 rounded-full border-2 transition-transform ${selectedColor === c.color ? 'border-gray-800 scale-110' : 'border-gray-200'}`}
|
||||
style={{ backgroundColor: c.color }} title={c.label} />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Selected: {colorOptions.find(c => c.value === selectedColor)?.label}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Account
|
||||
</label>
|
||||
<select
|
||||
value={selectedAccount}
|
||||
onChange={(e) => setSelectedAccount(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
|
||||
>
|
||||
{accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.name}
|
||||
</option>
|
||||
))}
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Account</label>
|
||||
<select value={selectedAccount} onChange={e => setSelectedAccount(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>
|
||||
{accounts.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Rollover toggle */}
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700">Roll over unspent funds</p>
|
||||
<p className="text-xs text-gray-500">Unspent money carries into next month instead of resetting.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setRollover(v => !v)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${rollover ? 'bg-blue-600' : 'bg-gray-300'}`}>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${rollover ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Income Allocation (Optional)
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Income Allocation (Optional)</label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex space-x-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="allocationType"
|
||||
value="percentage"
|
||||
checked={incomeAllocationType === 'percentage'}
|
||||
onChange={(e) => setIncomeAllocationType(e.target.value as 'percentage' | 'fixed')}
|
||||
className="mr-2"
|
||||
/>
|
||||
Percentage
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="allocationType"
|
||||
value="fixed"
|
||||
checked={incomeAllocationType === 'fixed'}
|
||||
onChange={(e) => setIncomeAllocationType(e.target.value as 'percentage' | 'fixed')}
|
||||
className="mr-2"
|
||||
/>
|
||||
Fixed Amount
|
||||
</label>
|
||||
{(['percentage', 'fixed'] as const).map(type => (
|
||||
<label key={type} className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="allocationType" value={type}
|
||||
checked={incomeAllocationType === type} onChange={() => setIncomeAllocationType(type)} />
|
||||
{type === 'percentage' ? 'Percentage' : 'Fixed Amount'}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">
|
||||
{incomeAllocationType === 'percentage' ? '%' : '$'}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
step={incomeAllocationType === 'percentage' ? '1' : '0.01'}
|
||||
min="0"
|
||||
max={incomeAllocationType === 'percentage' ? '100' : undefined}
|
||||
value={incomeAllocation}
|
||||
onChange={(e) => setIncomeAllocation(e.target.value)}
|
||||
<input type="number" step={incomeAllocationType === 'percentage' ? '1' : '0.01'}
|
||||
min="0" max={incomeAllocationType === 'percentage' ? '100' : undefined}
|
||||
value={incomeAllocation} onChange={e => setIncomeAllocation(e.target.value)}
|
||||
placeholder={incomeAllocationType === 'percentage' ? 'e.g., 25' : 'e.g., 100.00'}
|
||||
className="w-full pl-8 pr-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
className="w-full pl-8 pr-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{incomeAllocationType === 'percentage'
|
||||
? 'Percentage of income to allocate to this envelope automatically'
|
||||
: 'Fixed dollar amount to allocate to this envelope automatically'
|
||||
}
|
||||
? 'Percentage of income to auto-allocate here'
|
||||
: 'Fixed dollar amount to auto-allocate here'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className="flex-1 bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
>
|
||||
Delete Envelope
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={handleDelete}
|
||||
className="flex-1 bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 text-sm font-medium">
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
Update Envelope
|
||||
<button type="submit"
|
||||
className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 text-sm font-medium">
|
||||
Update
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 bg-gray-300 text-gray-700 py-2 px-4 rounded-md hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
>
|
||||
<button type="button" onClick={onCancel}
|
||||
className="flex-1 bg-gray-200 text-gray-700 py-2 px-4 rounded-md hover:bg-gray-300 text-sm font-medium">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
@@ -260,4 +184,4 @@ export default function EnvelopeEdit({ envelope, onEnvelopeUpdated, onEnvelopeDe
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Goal } from '../app/page';
|
||||
|
||||
interface Account {
|
||||
id: string;
|
||||
name: string;
|
||||
balance: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface GoalsManagerProps {
|
||||
goals: Goal[];
|
||||
accounts: Account[];
|
||||
onGoalsChange: (goals: Goal[]) => void;
|
||||
}
|
||||
|
||||
const COLORS = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4', '#84CC16'];
|
||||
|
||||
export default function GoalsManager({ goals, accounts, onGoalsChange }: GoalsManagerProps) {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingGoal, setEditingGoal] = useState<Goal | null>(null);
|
||||
const [form, setForm] = useState({ name: '', targetAmount: '', currentAmount: '', accountId: '', targetDate: '', color: COLORS[0] });
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const openCreate = () => {
|
||||
setForm({ name: '', targetAmount: '', currentAmount: '', accountId: accounts[0]?.id ?? '', targetDate: '', color: COLORS[0] });
|
||||
setEditingGoal(null);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const openEdit = (g: Goal) => {
|
||||
setForm({
|
||||
name: g.name, targetAmount: g.targetAmount.toString(), currentAmount: g.currentAmount.toString(),
|
||||
accountId: g.accountId, targetDate: g.targetDate ?? '', color: g.color,
|
||||
});
|
||||
setEditingGoal(g);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!form.name.trim() || !form.targetAmount) { setError('Name and target amount are required.'); return; }
|
||||
const goal: Goal = {
|
||||
id: editingGoal?.id ?? crypto.randomUUID(),
|
||||
name: form.name.trim(),
|
||||
targetAmount: parseFloat(form.targetAmount),
|
||||
currentAmount: parseFloat(form.currentAmount || '0'),
|
||||
accountId: form.accountId,
|
||||
targetDate: form.targetDate || undefined,
|
||||
color: form.color,
|
||||
};
|
||||
onGoalsChange(editingGoal
|
||||
? goals.map(g => g.id === editingGoal.id ? goal : g)
|
||||
: [...goals, goal]);
|
||||
setShowForm(false);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (confirm('Delete this goal?')) onGoalsChange(goals.filter(g => g.id !== id));
|
||||
};
|
||||
|
||||
const updateAmount = (goal: Goal, delta: number) => {
|
||||
const next = Math.max(0, goal.currentAmount + delta);
|
||||
onGoalsChange(goals.map(g => g.id === goal.id ? { ...g, currentAmount: next } : g));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Goals</h2>
|
||||
<button onClick={openCreate}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium">
|
||||
+ Add Goal
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{goals.length === 0 && !showForm && (
|
||||
<div className="text-center py-16 bg-white rounded-lg shadow">
|
||||
<div className="text-4xl mb-3">🎯</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-1">No goals yet</h3>
|
||||
<p className="text-gray-500 text-sm mb-4">Create a savings goal and track your progress.</p>
|
||||
<button onClick={openCreate} className="bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium">Add your first goal</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">{editingGoal ? 'Edit Goal' : 'New Goal'}</h3>
|
||||
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Goal name</label>
|
||||
<input type="text" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
placeholder="e.g. Emergency Fund, Vacation"
|
||||
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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Target amount ($)</label>
|
||||
<input type="number" min="0" step="0.01" value={form.targetAmount} onChange={e => setForm(f => ({ ...f, targetAmount: 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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Current amount ($)</label>
|
||||
<input type="number" min="0" step="0.01" value={form.currentAmount} onChange={e => setForm(f => ({ ...f, currentAmount: 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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Linked account (optional)</label>
|
||||
<select value={form.accountId} onChange={e => setForm(f => ({ ...f, accountId: 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="">None</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-1">Target date (optional)</label>
|
||||
<input type="date" value={form.targetDate} onChange={e => setForm(f => ({ ...f, targetDate: 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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Color</label>
|
||||
<div className="flex gap-2 flex-wrap mt-1">
|
||||
{COLORS.map(c => (
|
||||
<button key={c} type="button" onClick={() => setForm(f => ({ ...f, color: c }))}
|
||||
className={`w-6 h-6 rounded-full border-2 ${form.color === c ? 'border-gray-800 scale-110' : 'border-transparent'}`}
|
||||
style={{ backgroundColor: c }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-5">
|
||||
<button onClick={handleSave} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium">Save</button>
|
||||
<button onClick={() => { setShowForm(false); setError(''); }} className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{goals.map(g => {
|
||||
const pct = g.targetAmount > 0 ? Math.min(100, (g.currentAmount / g.targetAmount) * 100) : 0;
|
||||
const remaining = Math.max(0, g.targetAmount - g.currentAmount);
|
||||
const linkedAcc = accounts.find(a => a.id === g.accountId);
|
||||
const daysLeft = g.targetDate ? Math.ceil((new Date(g.targetDate).getTime() - Date.now()) / 86400000) : null;
|
||||
|
||||
return (
|
||||
<div key={g.id} className="bg-white shadow rounded-lg overflow-hidden">
|
||||
<div className="h-1.5" style={{ backgroundColor: g.color }} />
|
||||
<div className="p-5">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<h3 className="font-semibold text-gray-900">{g.name}</h3>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => openEdit(g)} className="text-indigo-600 hover:text-indigo-900 text-xs">Edit</button>
|
||||
<button onClick={() => handleDelete(g.id)} className="text-red-500 hover:text-red-800 text-xs">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between text-xs text-gray-500 mb-1">
|
||||
<span>${g.currentAmount.toFixed(2)} saved</span>
|
||||
<span>{Math.round(pct)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-100 rounded-full h-3">
|
||||
<div className="h-3 rounded-full transition-all" style={{ width: `${pct}%`, backgroundColor: g.color }} />
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>${remaining.toFixed(2)} to go</span>
|
||||
<span>Goal: ${g.targetAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{linkedAcc && (
|
||||
<p className="text-xs text-gray-400 mb-2">Linked to: {linkedAcc.name}</p>
|
||||
)}
|
||||
{daysLeft !== null && (
|
||||
<p className={`text-xs mb-3 ${daysLeft < 30 ? 'text-red-500' : 'text-gray-400'}`}>
|
||||
{daysLeft > 0 ? `${daysLeft} days until target` : 'Target date passed'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => {
|
||||
const v = parseFloat(prompt('Add amount ($):', '0') ?? '0');
|
||||
if (!isNaN(v) && v > 0) updateAmount(g, v);
|
||||
}}
|
||||
className="flex-1 text-xs py-1.5 bg-green-50 border border-green-200 text-green-700 rounded hover:bg-green-100">
|
||||
+ Add funds
|
||||
</button>
|
||||
<button onClick={() => {
|
||||
const v = parseFloat(prompt('Withdraw amount ($):', '0') ?? '0');
|
||||
if (!isNaN(v) && v > 0) updateAmount(g, -v);
|
||||
}}
|
||||
className="flex-1 text-xs py-1.5 bg-gray-50 border border-gray-200 text-gray-600 rounded hover:bg-gray-100">
|
||||
− Withdraw
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { TransactionRule } from '../app/page';
|
||||
|
||||
interface Envelope { id: string; name: string; color: string }
|
||||
|
||||
interface Props {
|
||||
rules: TransactionRule[];
|
||||
envelopes: Envelope[];
|
||||
onRulesChange: (rules: TransactionRule[]) => void;
|
||||
}
|
||||
|
||||
export default function TransactionRulesManager({ rules, envelopes, onRulesChange }: Props) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [envelopeId, setEnvelopeId] = useState(envelopes[0]?.id ?? '');
|
||||
const [matchCase, setMatchCase] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!keyword.trim()) { setError('Keyword is required.'); return; }
|
||||
if (!envelopeId) { setError('Select an envelope.'); return; }
|
||||
onRulesChange([...rules, { id: crypto.randomUUID(), keyword: keyword.trim(), envelopeId, matchCase }]);
|
||||
setKeyword(''); setError('');
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => onRulesChange(rules.filter(r => r.id !== id));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Auto-Categorization Rules</h2>
|
||||
<p className="text-gray-500 text-sm mt-1">
|
||||
When a new expense is added, if its description contains a keyword below, it's automatically assigned to the linked envelope.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h3 className="text-base font-medium text-gray-900 mb-4">Add a rule</h3>
|
||||
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
<div className="flex-1 min-w-40">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Keyword</label>
|
||||
<input type="text" value={keyword} onChange={e => setKeyword(e.target.value)}
|
||||
placeholder='e.g. "Whole Foods", "Netflix"'
|
||||
onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
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" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-40">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Assign to envelope</label>
|
||||
<select value={envelopeId} onChange={e => setEnvelopeId(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">
|
||||
{envelopes.map(e => <option key={e.id} value={e.id}>{e.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pb-0.5">
|
||||
<input type="checkbox" id="matchCase" checked={matchCase} onChange={e => setMatchCase(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 rounded" />
|
||||
<label htmlFor="matchCase" className="text-sm text-gray-600 whitespace-nowrap">Match case</label>
|
||||
</div>
|
||||
<button onClick={handleAdd}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium whitespace-nowrap">
|
||||
Add Rule
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rules.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white rounded-lg shadow">
|
||||
<div className="text-3xl mb-2">⚡</div>
|
||||
<p className="text-gray-500 text-sm">No rules yet. Add one above to auto-categorize transactions.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Keyword</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Envelope</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Case</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{rules.map(r => {
|
||||
const env = envelopes.find(e => e.id === r.envelopeId);
|
||||
return (
|
||||
<tr key={r.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-mono text-gray-900">"{r.keyword}"</td>
|
||||
<td className="px-4 py-3">
|
||||
{env ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: env.color }} />
|
||||
{env.name}
|
||||
</span>
|
||||
) : <span className="text-red-400 italic">Deleted envelope</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500">{r.matchCase ? 'Sensitive' : 'Insensitive'}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button onClick={() => handleDelete(r.id)} className="text-red-500 hover:text-red-800 text-xs font-medium">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 p-4 bg-blue-50 rounded-lg text-sm text-blue-800">
|
||||
<strong>How rules work:</strong> When you add a new expense, its description is checked against all rules in order.
|
||||
The first matching rule wins. Rules only apply to expenses (negative amounts) without an already-assigned envelope.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user