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:
nmemmertandClaude Sonnet 4.6 committed 2026-07-14 08:22:24 -04:00
1 parent 8814423fa6
commit 20ea25f4de
19 files changed
+1685 -1486

No files matched your search

+50
View File
@@ -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
View File
@@ -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>
);
}
}
+205
View File
@@ -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>
);
}
+116
View File
@@ -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>
);
}