Fix envelope updates, balance persistence, Get Paid duplication, form readability, and income allocation carryover

This commit is contained in:
nmemmert committed 2026-01-13 13:38:27 -05:00
1 parent caa6020ae8
commit 1b269dedd9
24 files changed
+2265 -472

No files matched your search

+8 -6
View File
@@ -153,9 +153,9 @@ export default function AccountManagement({ accounts, onAccountAdd, onAccountUpd
) : (
<div className="divide-y divide-gray-200">
{(accounts || []).map((account) => (
<div key={account.id} className="px-6 py-4 flex items-center justify-between hover:bg-gray-50">
<div key={account.id} className="px-6 py-4 flex items-center justify-between hover:bg-gray-50" role="article" aria-label={`Account: ${account.name}`}>
<div className="flex items-center space-x-4">
<div className={`w-4 h-4 rounded-full ${account.color}`}></div>
<div className={`w-4 h-4 rounded-full ${account.color}`} role="img" aria-label={`Color indicator: ${account.color}`}></div>
<div>
<h4 className="font-medium text-gray-900">{account.name}</h4>
<p className="text-sm text-gray-500">
@@ -176,8 +176,9 @@ export default function AccountManagement({ accounts, onAccountAdd, onAccountUpd
<div className="flex space-x-2">
<button
onClick={() => handleEdit(account)}
className="text-gray-400 hover:text-blue-600 p-1 rounded"
title="Edit account"
className="text-gray-400 hover:text-blue-600 p-1 rounded transition-colors"
title={`Edit ${account.name}`}
aria-label={`Edit account ${account.name}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
@@ -185,8 +186,9 @@ export default function AccountManagement({ accounts, onAccountAdd, onAccountUpd
</button>
<button
onClick={() => handleDelete(account.id)}
className="text-gray-400 hover:text-red-600 p-1 rounded"
title="Delete account"
className="text-gray-400 hover:text-red-600 p-1 rounded transition-colors"
title={`Delete ${account.name}`}
aria-label={`Delete account ${account.name}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
+3 -3
View File
@@ -78,7 +78,7 @@ export default function AuthModal({ isOpen, onClose, onAuthSuccess }: AuthModalP
type="email"
value={email}
onChange={(e) => setEmail(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"
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
@@ -91,7 +91,7 @@ export default function AuthModal({ isOpen, onClose, onAuthSuccess }: AuthModalP
type="password"
value={password}
onChange={(e) => setPassword(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"
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
required
minLength={6}
/>
@@ -106,7 +106,7 @@ export default function AuthModal({ isOpen, onClose, onAuthSuccess }: AuthModalP
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
required
minLength={6}
/>
+177
View File
@@ -0,0 +1,177 @@
'use client';
interface Account {
id: string;
name: string;
balance: number;
color: string;
}
interface Envelope {
id: string;
name: string;
allocated: number;
spent: number;
color: string;
}
interface Transaction {
id: string;
amount: number;
date: Date;
envelopeId?: string;
}
interface DashboardChartsProps {
accounts: Account[];
envelopes: Envelope[];
transactions: Transaction[];
}
export default function DashboardCharts({ accounts, envelopes, transactions }: DashboardChartsProps) {
// Calculate spending by envelope
const envelopeSpending = envelopes.map(env => ({
name: env.name,
spent: Math.abs(env.spent),
allocated: env.allocated,
color: env.color,
percentage: env.allocated > 0 ? (Math.abs(env.spent) / env.allocated) * 100 : 0
})).sort((a, b) => b.spent - a.spent);
// Calculate total balance across accounts
const totalBalance = accounts.reduce((sum, acc) => sum + acc.balance, 0);
const accountBalances = accounts.map(acc => ({
name: acc.name,
balance: acc.balance,
color: acc.color,
percentage: totalBalance > 0 ? (acc.balance / totalBalance) * 100 : 0
}));
// Calculate spending over time (last 30 days)
const now = new Date();
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const recentTransactions = transactions.filter(t => {
const txDate = t.date instanceof Date ? t.date : new Date(t.date);
return txDate >= thirtyDaysAgo;
});
const dailySpending: { [key: string]: number } = {};
recentTransactions.forEach(t => {
const txDate = t.date instanceof Date ? t.date : new Date(t.date);
const dateKey = txDate.toISOString().split('T')[0];
if (t.amount < 0) {
dailySpending[dateKey] = (dailySpending[dateKey] || 0) + Math.abs(t.amount);
}
});
const spendingData = Object.entries(dailySpending)
.sort((a, b) => a[0].localeCompare(b[0]))
.slice(-7); // Last 7 days
const maxDailySpending = Math.max(...spendingData.map(([_, amount]) => amount), 1);
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{/* Envelope Spending Chart */}
<div className="bg-white rounded-lg shadow-sm border p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Envelope Spending</h3>
{envelopeSpending.length > 0 ? (
<div className="space-y-3">
{envelopeSpending.slice(0, 5).map((env) => (
<div key={env.name}>
<div className="flex justify-between text-sm mb-1">
<span className="font-medium text-gray-700">{env.name}</span>
<span className="text-gray-600">
${env.spent.toFixed(2)} / ${env.allocated.toFixed(2)}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full transition-all ${env.color} ${
env.percentage > 100 ? 'bg-red-500' : ''
}`}
style={{ width: `${Math.min(env.percentage, 100)}%` }}
></div>
</div>
{env.percentage > 100 && (
<p className="text-xs text-red-600 mt-1">Over budget by ${(env.spent - env.allocated).toFixed(2)}</p>
)}
</div>
))}
</div>
) : (
<p className="text-gray-500 text-sm">No envelope spending data yet.</p>
)}
</div>
{/* Account Balances Chart */}
<div className="bg-white rounded-lg shadow-sm border p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Account Balances</h3>
{accountBalances.length > 0 ? (
<div className="space-y-3">
{accountBalances.map((acc) => (
<div key={acc.name}>
<div className="flex justify-between text-sm mb-1">
<span className="font-medium text-gray-700">{acc.name}</span>
<span className={`font-semibold ${acc.balance >= 0 ? 'text-green-600' : 'text-red-600'}`}>
${acc.balance.toFixed(2)}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full ${acc.color}`}
style={{ width: `${Math.abs(acc.percentage)}%` }}
></div>
</div>
</div>
))}
<div className="pt-3 border-t border-gray-200 mt-4">
<div className="flex justify-between">
<span className="font-semibold text-gray-900">Total Balance</span>
<span className={`font-bold text-lg ${totalBalance >= 0 ? 'text-green-600' : 'text-red-600'}`}>
${totalBalance.toFixed(2)}
</span>
</div>
</div>
</div>
) : (
<p className="text-gray-500 text-sm">No account data yet.</p>
)}
</div>
{/* Daily Spending Chart (Last 7 Days) */}
<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">Daily Spending (Last 7 Days)</h3>
{spendingData.length > 0 ? (
<div className="flex items-end justify-between h-48 gap-2">
{spendingData.map(([date, amount]) => {
const height = (amount / maxDailySpending) * 100;
const dateObj = new Date(date);
const dayName = dateObj.toLocaleDateString('en-US', { weekday: 'short' });
return (
<div key={date} className="flex-1 flex flex-col items-center">
<div className="w-full flex items-end justify-center h-40">
<div
className="w-full bg-blue-500 rounded-t hover:bg-blue-600 transition-colors relative group"
style={{ height: `${height}%`, minHeight: '8px' }}
>
<div className="absolute -top-8 left-1/2 transform -translate-x-1/2 bg-gray-900 text-white text-xs py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
${amount.toFixed(2)}
</div>
</div>
</div>
<div className="text-xs text-gray-600 mt-2">{dayName}</div>
<div className="text-xs text-gray-400">{dateObj.getDate()}</div>
</div>
);
})}
</div>
) : (
<p className="text-gray-500 text-sm">No spending data for the last 7 days.</p>
)}
</div>
</div>
);
}
+19 -4
View File
@@ -4,6 +4,19 @@ import { useState } from 'react';
import FileUpload, { ParsedTransaction } from './FileUpload';
import ManualEntry from './ManualEntry';
interface Envelope {
id: string;
name: string;
allocated?: number;
spent?: number;
}
interface Transaction {
id: string;
envelopeId?: string;
amount: number;
}
interface DataInputProps {
onTransactionsAdded: (transactions: Array<{
id: string;
@@ -13,17 +26,18 @@ interface DataInputProps {
date: Date;
accountId: string;
}>) => void;
envelopes: { id: string; name: string }[];
envelopes: Envelope[];
accounts: { id: string; name: string }[];
transactions?: Transaction[];
}
type InputMode = 'file' | 'manual';
export default function DataInput({ onTransactionsAdded, envelopes, accounts }: DataInputProps) {
export default function DataInput({ onTransactionsAdded, envelopes, accounts, transactions = [] }: DataInputProps) {
const [inputMode, setInputMode] = useState<InputMode>('manual');
const handleFileTransactionsParsed = (parsedTransactions: ParsedTransaction[]) => {
const transactions = parsedTransactions.map((t) => ({
const txns = parsedTransactions.map((t) => ({
id: crypto.randomUUID(),
amount: t.amount,
description: t.description,
@@ -32,7 +46,7 @@ export default function DataInput({ onTransactionsAdded, envelopes, accounts }:
accountId: accounts?.[0]?.id || 'default-account', // Default to first account
}));
onTransactionsAdded(transactions);
onTransactionsAdded(txns);
};
const handleManualTransactionAdded = (transaction: {
@@ -86,6 +100,7 @@ export default function DataInput({ onTransactionsAdded, envelopes, accounts }:
onTransactionAdded={handleManualTransactionAdded}
envelopes={envelopes}
accounts={accounts}
transactions={transactions}
/>
) : (
<FileUpload onTransactionsParsed={handleFileTransactionsParsed} accounts={accounts} />
+162
View File
@@ -0,0 +1,162 @@
'use client';
import { useState, useEffect } from 'react';
interface Account {
id: string;
name: string;
type: 'checking' | 'savings' | 'credit_card' | 'mortgage' | 'investment' | 'loan';
balance: number;
color: string;
isActive: boolean;
}
interface Envelope {
id: string;
name: string;
allocated: number;
spent: number;
color: string;
accountId: string;
isRecurring?: boolean;
recurringFrequency?: 'weekly' | 'monthly' | 'yearly';
lastRecurringDate?: string;
}
interface DemoWizardProps {
onApplyDemo: (accounts: Account[], envelopes: Envelope[]) => void;
onSkip: () => void;
accountId?: string;
}
const DEMO_DATA = {
accounts: [
{
id: crypto.randomUUID(),
name: 'Main Checking',
type: 'checking' as const,
balance: 5000,
color: 'bg-blue-500',
isActive: true,
},
{
id: crypto.randomUUID(),
name: 'Emergency Fund',
type: 'savings' as const,
balance: 10000,
color: 'bg-green-500',
isActive: true,
},
],
envelopes: [
{ name: 'Rent', allocated: 1500, color: 'bg-red-500', isRecurring: true, recurringFrequency: 'monthly' as const },
{ name: 'Groceries', allocated: 400, color: 'bg-green-500', isRecurring: true, recurringFrequency: 'monthly' as const },
{ name: 'Utilities', allocated: 150, color: 'bg-yellow-500', isRecurring: true, recurringFrequency: 'monthly' as const },
{ name: 'Entertainment', allocated: 200, color: 'bg-purple-500', isRecurring: false },
{ name: 'Dining Out', allocated: 250, color: 'bg-pink-500', isRecurring: false },
{ name: 'Car Payment', allocated: 350, color: 'bg-indigo-500', isRecurring: true, recurringFrequency: 'monthly' as const },
],
};
export default function DemoWizard({ onApplyDemo, onSkip, accountId }: DemoWizardProps) {
const [showDemo, setShowDemo] = useState(false);
const handleApplyDemo = () => {
// Use first account as default, or create new ones
const firstAccountId = accountId || DEMO_DATA.accounts[0].id;
const envelopes = DEMO_DATA.envelopes.map((env) => ({
id: crypto.randomUUID(),
...env,
accountId: firstAccountId,
spent: 0,
}));
onApplyDemo(DEMO_DATA.accounts, envelopes);
};
if (!showDemo) {
return (
<div className="text-center space-y-4">
<div className="text-6xl mb-4">📊</div>
<h2 className="text-2xl font-bold text-gray-900">Want to See a Demo?</h2>
<p className="text-gray-600 max-w-md mx-auto">
We can populate your budget with sample accounts and envelopes to help you get started and explore the app's features.
</p>
<div className="flex gap-3 justify-center pt-4">
<button
onClick={() => setShowDemo(true)}
className="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors"
>
Show Me Demo Data
</button>
<button
onClick={onSkip}
className="bg-gray-200 text-gray-900 px-6 py-3 rounded-lg hover:bg-gray-300 transition-colors"
>
Skip for Now
</button>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-4">Demo Budget Setup</h2>
<p className="text-gray-600 mb-6">
Here's what we'll create for you:
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-blue-50 rounded-lg p-4 border border-blue-200">
<h3 className="font-semibold text-blue-900 mb-3">🏦 Accounts</h3>
<ul className="space-y-2">
{DEMO_DATA.accounts.map((acc) => (
<li key={acc.name} className="text-sm text-blue-800">
<span className="font-medium">{acc.name}</span> - ${acc.balance.toLocaleString()}
</li>
))}
</ul>
</div>
<div className="bg-green-50 rounded-lg p-4 border border-green-200">
<h3 className="font-semibold text-green-900 mb-3">📦 Envelopes</h3>
<ul className="space-y-2">
{DEMO_DATA.envelopes.map((env) => (
<li key={env.name} className="text-sm text-green-800">
<span className="font-medium">{env.name}</span> - ${env.allocated}/mo
{env.isRecurring && (
<span className="ml-2 text-xs bg-green-200 px-2 py-1 rounded">Recurring</span>
)}
</li>
))}
</ul>
</div>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<p className="text-sm text-amber-800">
<strong>💡 Tip:</strong> You can always edit or delete these envelopes and accounts later. This is just a starting point!
</p>
</div>
<div className="flex gap-3">
<button
onClick={() => setShowDemo(false)}
className="flex-1 bg-gray-200 text-gray-900 py-3 px-4 rounded-lg hover:bg-gray-300 transition-colors"
>
← Back
</button>
<button
onClick={handleApplyDemo}
className="flex-1 bg-green-600 text-white py-3 px-4 rounded-lg hover:bg-green-700 transition-colors font-semibold"
>
Apply Demo Data →
</button>
</div>
</div>
);
}
+10
View File
@@ -47,6 +47,16 @@ export default function EnvelopeEdit({ envelope, onEnvelopeUpdated, onEnvelopeDe
const [incomeAllocationType, setIncomeAllocationType] = useState<'percentage' | 'fixed'>(envelope.incomeAllocationType || 'percentage');
const [incomeAllocation, setIncomeAllocation] = useState(envelope.incomeAllocation?.toString() || '');
// Sync form fields when a different envelope is opened for editing
useEffect(() => {
setName(envelope.name);
setAllocated(envelope.allocated.toString());
setSelectedColor(envelope.color);
setSelectedAccount(envelope.accountId);
setIncomeAllocationType(envelope.incomeAllocationType || 'percentage');
setIncomeAllocation(envelope.incomeAllocation?.toString() || '');
}, [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) {
+351 -106
View File
@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
interface Account {
id: string;
@@ -32,69 +32,106 @@ interface Transaction {
accountId: string;
}
type DistributionMethod = 'proportional' | 'equal' | 'custom';
type AllocationMap = Record<string, number>;
interface PaycheckTemplate {
amount: number;
selectedAccountId: string;
description: string;
distributionMethod: DistributionMethod;
customAllocations: AllocationMap;
}
interface GetPaidProps {
accounts: Account[];
envelopes: Envelope[];
onIncomeAdded: (transactions: Transaction[]) => void;
onAccountUpdate?: (account: Account) => void;
autoOpen?: boolean;
}
export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountUpdate }: GetPaidProps) {
const [isOpen, setIsOpen] = useState(false);
export default function GetPaid({
accounts,
envelopes,
onIncomeAdded,
onAccountUpdate,
autoOpen = false,
}: GetPaidProps) {
const [isOpen, setIsOpen] = useState(autoOpen);
const [showPreview, setShowPreview] = useState(false);
const [amount, setAmount] = useState('');
const [selectedAccountId, setSelectedAccountId] = useState('');
const [description, setDescription] = useState('Paycheck');
const [distributionMethod, setDistributionMethod] = useState<'proportional' | 'equal' | 'custom'>('proportional');
const [customAllocations, setCustomAllocations] = useState<{ [envelopeId: string]: number }>({});
const [distributionMethod, setDistributionMethod] = useState<DistributionMethod>('proportional');
const [customAllocations, setCustomAllocations] = useState<AllocationMap>({});
const [previewAllocations, setPreviewAllocations] = useState<AllocationMap>({});
const [showDefaultAmountField, setShowDefaultAmountField] = useState(false);
const [defaultAmountInput, setDefaultAmountInput] = useState('');
const [saveTemplate, setSaveTemplate] = useState(false);
const [savedTemplate, setSavedTemplate] = useState<PaycheckTemplate | null>(null);
if (!accounts || accounts.length === 0) {
return (
<div className="bg-white rounded-lg shadow-sm border p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Get Paid</h3>
<p className="text-gray-600">Please add an account first to record paychecks.</p>
</div>
);
}
useEffect(() => {
if (typeof window === 'undefined') return;
const template = localStorage.getItem('paycheckTemplate');
if (!template) return;
if (!envelopes) {
return (
<div className="bg-white rounded-lg shadow-sm border p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Get Paid</h3>
<p className="text-gray-600">Loading...</p>
</div>
);
}
try {
const parsed = JSON.parse(template) as PaycheckTemplate;
setSavedTemplate(parsed);
} catch (error) {
console.error('Error loading saved template', error);
}
}, []);
const accountEnvelopes = selectedAccountId
? envelopes.filter((env) => env.accountId === selectedAccountId)
: [];
const accountEnvelopes = selectedAccountId ? envelopes.filter(env => env.accountId === selectedAccountId) : [];
const totalAllocated = accountEnvelopes.reduce((sum, env) => sum + env.allocated, 0);
const calculateDistribution = () => {
const calculateDistribution = (): AllocationMap => {
const paycheckAmount = parseFloat(amount);
if (!paycheckAmount || accountEnvelopes.length === 0) return {};
const distribution: { [envelopeId: string]: number } = {};
if (distributionMethod === 'custom') {
const sanitized: AllocationMap = {};
accountEnvelopes.forEach((env) => {
sanitized[env.id] = Math.max(0, customAllocations[env.id] || 0);
});
return sanitized;
}
const distribution: AllocationMap = {};
if (distributionMethod === 'equal') {
const equalAmount = paycheckAmount / accountEnvelopes.length;
accountEnvelopes.forEach(env => {
accountEnvelopes.forEach((env) => {
distribution[env.id] = equalAmount;
});
} else if (distributionMethod === 'proportional') {
accountEnvelopes.forEach(env => {
const proportion = env.allocated / totalAllocated;
distribution[env.id] = paycheckAmount * proportion;
});
} else if (distributionMethod === 'custom') {
return customAllocations;
return distribution;
}
accountEnvelopes.forEach((env) => {
const proportion = totalAllocated > 0 ? env.allocated / totalAllocated : 0;
distribution[env.id] = paycheckAmount * proportion;
});
return distribution;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const applySavedTemplate = () => {
if (!savedTemplate) return;
setAmount(savedTemplate.amount?.toString() || '');
setSelectedAccountId(savedTemplate.selectedAccountId || '');
setDescription(savedTemplate.description || 'Paycheck');
setDistributionMethod(savedTemplate.distributionMethod || 'proportional');
setCustomAllocations(savedTemplate.customAllocations || {});
};
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
const paycheckAmount = parseFloat(amount);
if (!paycheckAmount || !selectedAccountId) {
@@ -102,10 +139,22 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU
return;
}
if (paycheckAmount <= 0 || paycheckAmount > 999999999) {
alert('Please enter a valid paycheck amount ($0 - $999,999,999)');
return;
}
const distribution = calculateDistribution();
setPreviewAllocations(distribution);
setShowPreview(true);
};
const handleConfirmPaycheck = () => {
const paycheckAmount = parseFloat(amount);
if (!paycheckAmount || !selectedAccountId) return;
const transactions: Transaction[] = [];
// Add the main paycheck transaction to the account
transactions.push({
id: crypto.randomUUID(),
amount: paycheckAmount,
@@ -114,27 +163,53 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU
accountId: selectedAccountId,
});
// Add allocation transactions to envelopes
Object.entries(distribution).forEach(([envelopeId, allocatedAmount]) => {
Object.entries(previewAllocations).forEach(([envelopeId, allocatedAmount]) => {
if (allocatedAmount > 0) {
transactions.push({
id: crypto.randomUUID(),
envelopeId,
amount: allocatedAmount,
description: `Paycheck allocation - ${envelopes.find(e => e.id === envelopeId)?.name}`,
description: `${description} → ${envelopes.find((env) => env.id === envelopeId)?.name || 'Envelope'}`,
date: new Date(),
accountId: selectedAccountId,
});
}
});
if (saveTemplate && typeof window !== 'undefined') {
const template: PaycheckTemplate = {
amount: paycheckAmount,
selectedAccountId,
description,
distributionMethod,
customAllocations: previewAllocations,
};
localStorage.setItem('paycheckTemplate', JSON.stringify(template));
setSavedTemplate(template);
}
onIncomeAdded(transactions);
setIsOpen(false);
setShowPreview(false);
setAmount('');
setSelectedAccountId('');
setDescription('Paycheck');
setCustomAllocations({});
setPreviewAllocations({});
setShowDefaultAmountField(false);
setDefaultAmountInput('');
setSaveTemplate(false);
};
const handleEditAllocation = (envelopeId: string, newAmount: string) => {
const numAmount = parseFloat(newAmount);
if (Number.isNaN(numAmount) || numAmount < 0) return;
setPreviewAllocations((prev) => ({
...prev,
[envelopeId]: numAmount,
}));
};
const handleSetDefaultAmount = () => {
@@ -144,13 +219,10 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU
return;
}
const selectedAccount = accounts.find(acc => acc.id === selectedAccountId);
const selectedAccount = accounts.find((acc) => acc.id === selectedAccountId);
if (!selectedAccount) return;
const updatedAccount = {
...selectedAccount,
defaultPaycheckAmount: defaultAmount
};
const updatedAccount = { ...selectedAccount, defaultPaycheckAmount: defaultAmount };
if (onAccountUpdate) {
onAccountUpdate(updatedAccount);
@@ -163,35 +235,62 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU
};
const handleUseDefaultAmount = () => {
const selectedAccount = accounts.find(acc => acc.id === selectedAccountId);
const selectedAccount = accounts.find((acc) => acc.id === selectedAccountId);
if (selectedAccount?.defaultPaycheckAmount) {
setAmount(selectedAccount.defaultPaycheckAmount.toString());
}
};
const distribution = calculateDistribution();
const totalDistributed = Object.values(distribution).reduce((sum, amt) => sum + amt, 0);
const remaining = parseFloat(amount) - totalDistributed;
const numericAmount = parseFloat(amount || '0') || 0;
const totalDistributed = Object.values(distribution).reduce((sum, value) => sum + value, 0);
const remaining = numericAmount - totalDistributed;
return (
<>
<button
onClick={() => setIsOpen(true)}
className="bg-green-600 text-white px-4 py-2 rounded-md hover:bg-green-700 flex items-center space-x-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
</svg>
<span>Get Paid</span>
</button>
{!autoOpen && (
<button
onClick={() => setIsOpen(true)}
className="bg-green-600 text-white px-4 py-2 rounded-md hover:bg-green-700 flex items-center space-x-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
/>
</svg>
<span>Get Paid</span>
</button>
)}
{isOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<h3 className="text-xl font-bold text-gray-900 mb-4">Record Paycheck</h3>
{savedTemplate && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900">Saved Template Available</p>
<p className="text-xs text-gray-600">
Amount: ${savedTemplate.amount?.toFixed(2)} • {savedTemplate.description}
</p>
</div>
<button
type="button"
onClick={applySavedTemplate}
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 whitespace-nowrap"
>
Use Template
</button>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Paycheck Amount</label>
@@ -201,21 +300,25 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU
type="number"
step="0.01"
value={amount}
onChange={(e) => setAmount(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"
onChange={(event) => setAmount(event.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 bg-white text-gray-900 placeholder-gray-400"
placeholder="0.00"
required
/>
</div>
{selectedAccountId && accounts.find(acc => acc.id === selectedAccountId)?.defaultPaycheckAmount && (
<button
type="button"
onClick={handleUseDefaultAmount}
className="mt-1 text-sm text-blue-600 hover:text-blue-800"
>
Use default: ${accounts.find(acc => acc.id === selectedAccountId)?.defaultPaycheckAmount?.toFixed(2)}
</button>
)}
{selectedAccountId &&
accounts.find((acc) => acc.id === selectedAccountId)?.defaultPaycheckAmount && (
<button
type="button"
onClick={handleUseDefaultAmount}
className="mt-1 text-sm text-blue-600 hover:text-blue-800"
>
Use default: $
{accounts
.find((acc) => acc.id === selectedAccountId)
?.defaultPaycheckAmount?.toFixed(2)}
</button>
)}
{selectedAccountId && (
<button
type="button"
@@ -233,8 +336,8 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU
type="number"
step="0.01"
value={defaultAmountInput}
onChange={(e) => setDefaultAmountInput(e.target.value)}
className="w-full pl-8 pr-3 py-1 text-sm border border-gray-300 rounded"
onChange={(event) => setDefaultAmountInput(event.target.value)}
className="w-full pl-8 pr-3 py-1 text-sm border border-gray-300 rounded bg-white text-gray-900 placeholder-gray-400"
placeholder="0.00"
/>
</div>
@@ -253,16 +356,23 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU
<label className="block text-sm font-medium text-gray-700 mb-1">Deposit Account</label>
<select
value={selectedAccountId}
onChange={(e) => setSelectedAccountId(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
onChange={(event) => setSelectedAccountId(event.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"
required
>
<option value="">Select account...</option>
{accounts.filter(acc => acc.type !== 'credit_card' && acc.type !== 'mortgage' && acc.type !== 'loan').map((account) => (
<option key={account.id} value={account.id}>
{account.name} ({account.type.replace('_', ' ')})
</option>
))}
{accounts
.filter(
(account) =>
account.type !== 'credit_card' &&
account.type !== 'mortgage' &&
account.type !== 'loan',
)
.map((account) => (
<option key={account.id} value={account.id}>
{account.name} ({account.type.replace('_', ' ')})
</option>
))}
</select>
</div>
</div>
@@ -272,58 +382,71 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU
<input
type="text"
value={description}
onChange={(e) => setDescription(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"
onChange={(event) => setDescription(event.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900 placeholder-gray-400"
placeholder="Paycheck description"
/>
</div>
{/* Distribution Method */}
<div className="flex items-center">
<input
type="checkbox"
id="saveTemplate"
checked={saveTemplate}
onChange={(event) => setSaveTemplate(event.target.checked)}
className="mr-2"
/>
<label htmlFor="saveTemplate" className="text-sm text-gray-700">
Save this information to use next time
</label>
</div>
{selectedAccountId && accountEnvelopes.length > 0 && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Income Distribution Method</label>
<div className="space-y-2">
<label className="flex items-center">
<label className="block text-sm font-medium text-gray-800 mb-2">Income Distribution Method</label>
<div className="space-y-2 text-gray-900">
<label className="flex items-center gap-2">
<input
type="radio"
value="proportional"
checked={distributionMethod === 'proportional'}
onChange={(e) => setDistributionMethod(e.target.value as 'proportional')}
className="mr-2"
onChange={(event) =>
setDistributionMethod(event.target.value as DistributionMethod)
}
className="accent-blue-600"
/>
<span className="text-sm">Proportional to envelope allocations</span>
<span className="text-sm font-medium">Proportional to envelope allocations</span>
</label>
<label className="flex items-center">
<label className="flex items-center gap-2">
<input
type="radio"
value="equal"
checked={distributionMethod === 'equal'}
onChange={(e) => setDistributionMethod(e.target.value as 'equal')}
className="mr-2"
onChange={(event) => setDistributionMethod(event.target.value as DistributionMethod)}
className="accent-blue-600"
/>
<span className="text-sm">Equal distribution to all envelopes</span>
<span className="text-sm font-medium">Equal distribution to all envelopes</span>
</label>
<label className="flex items-center">
<label className="flex items-center gap-2">
<input
type="radio"
value="custom"
checked={distributionMethod === 'custom'}
onChange={(e) => setDistributionMethod(e.target.value as 'custom')}
className="mr-2"
onChange={(event) => setDistributionMethod(event.target.value as DistributionMethod)}
className="accent-blue-600"
/>
<span className="text-sm">Custom amounts</span>
<span className="text-sm font-medium">Custom amounts</span>
</label>
</div>
</div>
{/* Distribution Preview */}
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="font-medium text-gray-900 mb-3">Income Distribution Preview</h4>
<div className="space-y-2">
{accountEnvelopes.map((envelope) => {
const allocatedAmount = distribution[envelope.id] || 0;
const percentage = parseFloat(amount) > 0 ? (allocatedAmount / parseFloat(amount)) * 100 : 0;
const percentage = numericAmount > 0 ? (allocatedAmount / numericAmount) * 100 : 0;
return (
<div key={envelope.id} className="flex items-center justify-between">
@@ -336,18 +459,20 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU
<input
type="number"
step="0.01"
value={customAllocations[envelope.id] || ''}
onChange={(e) => setCustomAllocations(prev => ({
...prev,
[envelope.id]: parseFloat(e.target.value) || 0
}))}
className="w-20 px-2 py-1 text-sm border border-gray-300 rounded"
value={customAllocations[envelope.id] ?? ''}
onChange={(event) =>
setCustomAllocations((prev) => ({
...prev,
[envelope.id]: parseFloat(event.target.value) || 0,
}))
}
className="w-20 px-2 py-1 text-sm border border-gray-300 rounded bg-white text-gray-900 placeholder-gray-400"
placeholder="0.00"
/>
) : (
<span className="text-sm font-medium">${allocatedAmount.toFixed(2)}</span>
<span className="text-sm font-semibold text-gray-900">${allocatedAmount.toFixed(2)}</span>
)}
<span className="text-xs text-gray-500">({percentage.toFixed(1)}%)</span>
<span className="text-xs font-semibold text-gray-700">({percentage.toFixed(1)}%)</span>
</div>
</div>
);
@@ -374,7 +499,6 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU
</>
)}
{/* Actions */}
<div className="flex justify-end space-x-3 pt-4 border-t">
<button
type="button"
@@ -388,13 +512,134 @@ export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountU
disabled={!amount || !selectedAccountId || (accountEnvelopes.length > 0 && Math.abs(remaining) >= 0.01)}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
Record Paycheck
Review & Confirm
</button>
</div>
</form>
</div>
</div>
)}
{showPreview && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<h3 className="text-xl font-bold text-gray-900 mb-4">Confirm Paycheck Allocation</h3>
<div className="mb-6">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-gray-700">Paycheck Amount:</span>
<span className="text-2xl font-bold text-blue-600">${numericAmount.toFixed(2)}</span>
</div>
<div className="flex justify-between items-center mt-2">
<span className="text-sm text-gray-600">Deposit to:</span>
<span className="text-sm font-medium text-gray-900">
{accounts.find((acc) => acc.id === selectedAccountId)?.name}
</span>
</div>
{description && (
<div className="flex justify-between items-center mt-1">
<span className="text-sm text-gray-600">Description:</span>
<span className="text-sm text-gray-900">{description}</span>
</div>
)}
</div>
</div>
<h4 className="font-semibold text-gray-900 mb-3">Envelope Allocations</h4>
<p className="text-sm text-gray-600 mb-4">Review and adjust how this paycheck will be distributed:</p>
<div className="space-y-3 mb-6">
{accountEnvelopes.map((envelope) => {
const allocatedAmount = previewAllocations[envelope.id] || 0;
const percentage = numericAmount > 0 ? (allocatedAmount / numericAmount) * 100 : 0;
return (
<div key={envelope.id} className="bg-gray-50 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
<div className={`w-4 h-4 rounded-full ${envelope.color}`}></div>
<span className="font-medium text-gray-900">{envelope.name}</span>
</div>
<span className="text-xs text-gray-500">{percentage.toFixed(1)}% of paycheck</span>
</div>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-700 font-medium">Amount:</span>
<div className="relative flex-1">
<span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-600 font-semibold">$</span>
<input
type="number"
step="0.01"
value={allocatedAmount.toFixed(2)}
onChange={(event) => handleEditAllocation(envelope.id, event.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 bg-white text-gray-900"
/>
</div>
</div>
</div>
);
})}
</div>
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<div className="flex justify-between items-center">
<span className="font-semibold text-gray-900">Total Allocated:</span>
<span className="text-xl font-bold text-green-600">
${Object.values(previewAllocations).reduce((sum, value) => sum + value, 0).toFixed(2)}
</span>
</div>
{(() => {
const totalAllocatedPreview = Object.values(previewAllocations).reduce(
(sum, value) => sum + value,
0,
);
const remainingAmount = numericAmount - totalAllocatedPreview;
if (Math.abs(remainingAmount) >= 0.01) {
return (
<div className="flex justify-between items-center mt-2">
<span className="text-sm text-gray-600">Unallocated:</span>
<span
className={`text-sm font-medium ${remainingAmount >= 0 ? 'text-orange-600' : 'text-red-600'}`}
>
${remainingAmount.toFixed(2)} {remainingAmount < 0 && '(over allocated!)'}
</span>
</div>
);
}
return null;
})()}
</div>
<div className="flex justify-end space-x-3 pt-4 border-t">
<button
type="button"
onClick={() => setShowPreview(false)}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
>
Go Back
</button>
<button
type="button"
onClick={handleConfirmPaycheck}
disabled={(() => {
const totalAllocatedPreview = Object.values(previewAllocations).reduce(
(sum, value) => sum + value,
0,
);
const remainingAmount = numericAmount - totalAllocatedPreview;
return Math.abs(remainingAmount) >= 0.01;
})()}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
Confirm & Apply Paycheck
</button>
</div>
</div>
</div>
)}
</>
);
}
}
+73 -20
View File
@@ -2,6 +2,19 @@
import { useState, useEffect } from 'react';
interface Envelope {
id: string;
name: string;
allocated?: number;
spent?: number;
}
interface Transaction {
id: string;
envelopeId?: string;
amount: number;
}
interface ManualEntryProps {
onTransactionAdded: (transaction: {
amount: number;
@@ -10,11 +23,12 @@ interface ManualEntryProps {
envelopeId?: string;
accountId: string;
}) => void;
envelopes: { id: string; name: string }[];
envelopes: Envelope[];
accounts: { id: string; name: string }[];
transactions?: Transaction[];
}
export default function ManualEntry({ onTransactionAdded, envelopes, accounts }: ManualEntryProps) {
export default function ManualEntry({ onTransactionAdded, envelopes, accounts, transactions = [] }: ManualEntryProps) {
const [amount, setAmount] = useState('');
const [description, setDescription] = useState('');
const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
@@ -33,8 +47,36 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts }:
e.preventDefault();
const numAmount = parseFloat(amount);
if (isNaN(numAmount) || !description.trim()) {
alert('Please enter a valid amount and description');
const descriptionTrimmed = description.trim();
// Validation
if (isNaN(numAmount)) {
alert('Please enter a valid amount');
return;
}
if (numAmount <= 0) {
alert('Amount must be greater than 0');
return;
}
if (numAmount > 999999999) {
alert('Amount is too large (max: $999,999,999)');
return;
}
if (!descriptionTrimmed) {
alert('Please enter a description');
return;
}
if (descriptionTrimmed.length < 3) {
alert('Description must be at least 3 characters');
return;
}
if (descriptionTrimmed.length > 100) {
alert('Description cannot exceed 100 characters');
return;
}
@@ -42,7 +84,7 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts }:
onTransactionAdded({
amount: transactionAmount,
description: description.trim(),
description: descriptionTrimmed,
date: new Date(date),
envelopeId: selectedEnvelope || undefined,
accountId: selectedAccount,
@@ -92,7 +134,7 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts }:
value={amount}
onChange={(e) => setAmount(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"
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 bg-white text-gray-900 placeholder-gray-400"
required
/>
</div>
@@ -106,7 +148,7 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts }:
type="date"
value={date}
onChange={(e) => setDate(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"
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"
required
/>
</div>
@@ -121,7 +163,7 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts }:
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter transaction description"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
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-gray-400"
required
/>
</div>
@@ -133,14 +175,23 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts }:
<select
value={selectedEnvelope}
onChange={(e) => setSelectedEnvelope(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"
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"
>
<option value="">Select an envelope...</option>
{envelopes.map((envelope) => (
<option key={envelope.id} value={envelope.id}>
{envelope.name}
</option>
))}
{envelopes.map((envelope) => {
// Calculate remaining balance
const incomeAllocated = transactions
.filter(t => t.envelopeId === envelope.id && t.amount > 0)
.reduce((sum, t) => sum + t.amount, 0);
const totalAllocated = (envelope.allocated || 0) + incomeAllocated;
const remaining = totalAllocated - (envelope.spent || 0);
return (
<option key={envelope.id} value={envelope.id}>
{envelope.name} — ${remaining.toFixed(2)} remaining
</option>
);
})}
</select>
</div>
@@ -151,7 +202,7 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts }:
<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"
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"
required
>
<option value="">Select an account...</option>
@@ -163,24 +214,26 @@ export default function ManualEntry({ onTransactionAdded, envelopes, accounts }:
</select>
</div>
<div className="flex items-center space-x-4">
<label className="flex items-center">
<div className="flex items-center space-x-4 p-3 bg-gray-50 rounded-md">
<label className="flex items-center cursor-pointer">
<input
type="radio"
checked={isExpense}
onChange={() => setIsExpense(true)}
className="mr-2"
aria-label="Transaction type: Expense"
/>
<span className="text-sm text-gray-700">Expense</span>
<span className="text-sm font-medium text-red-600">💸 Expense (Negative)</span>
</label>
<label className="flex items-center">
<label className="flex items-center cursor-pointer">
<input
type="radio"
checked={!isExpense}
onChange={() => setIsExpense(false)}
className="mr-2"
aria-label="Transaction type: Income"
/>
<span className="text-sm text-gray-700">Income</span>
<span className="text-sm font-medium text-green-600">💰 Income (Positive)</span>
</label>
</div>
@@ -0,0 +1,101 @@
'use client';
import { useState } from 'react';
interface RecurringTransactionManagerProps {
transactions: Array<{
id: string;
description: string;
amount: number;
isRecurring?: boolean;
recurringFrequency?: 'weekly' | 'monthly' | 'yearly';
lastAppliedDate?: string;
}>;
onApplyRecurring: (transactions: any[]) => void;
}
export default function RecurringTransactionManager({ transactions, onApplyRecurring }: RecurringTransactionManagerProps) {
const [expanded, setExpanded] = useState(false);
const getNextRecurringDate = (lastDate: string | undefined, frequency: string | undefined): Date | null => {
if (!lastDate || !frequency) return null;
const last = new Date(lastDate);
const now = new Date();
switch (frequency) {
case 'weekly':
const nextWeek = new Date(last);
nextWeek.setDate(nextWeek.getDate() + 7);
return nextWeek <= now ? nextWeek : null;
case 'monthly':
const nextMonth = new Date(last);
nextMonth.setMonth(nextMonth.getMonth() + 1);
return nextMonth <= now ? nextMonth : null;
case 'yearly':
const nextYear = new Date(last);
nextYear.setFullYear(nextYear.getFullYear() + 1);
return nextYear <= now ? nextYear : null;
default:
return null;
}
};
const recurringTransactions = transactions.filter(t => t.isRecurring);
const upcomingTransactions = recurringTransactions.filter(t => getNextRecurringDate(t.lastAppliedDate, t.recurringFrequency));
if (recurringTransactions.length === 0) {
return null;
}
return (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex justify-between items-center font-semibold text-blue-900 hover:text-blue-700"
>
<span>🔄 Recurring Transactions ({recurringTransactions.length})</span>
<span>{expanded ? '▲' : '▼'}</span>
</button>
{expanded && (
<div className="mt-4 space-y-3">
<p className="text-sm text-blue-800">
You have <strong>{upcomingTransactions.length}</strong> recurring transaction(s) that are due to be applied.
</p>
{upcomingTransactions.map(tx => (
<div key={tx.id} className="bg-white p-3 rounded border border-blue-100">
<div className="flex justify-between items-center">
<div>
<p className="font-medium text-gray-900">{tx.description}</p>
<p className="text-xs text-gray-500">
Frequency: <span className="capitalize font-semibold">{tx.recurringFrequency}</span>
</p>
</div>
<p className={`font-semibold ${tx.amount >= 0 ? 'text-green-600' : 'text-red-600'}`}>
${Math.abs(tx.amount).toFixed(2)}
</p>
</div>
</div>
))}
{upcomingTransactions.length > 0 && (
<button
onClick={() => {
const newTransactions = upcomingTransactions.map(tx => ({
...tx,
lastAppliedDate: new Date().toISOString().split('T')[0],
}));
onApplyRecurring(newTransactions);
setExpanded(false);
}}
className="w-full mt-3 bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700 transition-colors"
>
Apply Now
</button>
)}
</div>
)}
</div>
);
}
+412
View File
@@ -0,0 +1,412 @@
'use client';
import { useState } from 'react';
import { AuthService } from '../lib/authService';
import { DataService } from '../lib/dataService';
interface SettingsProps {
user: { userId: string; email: string };
onAccountDeleted: () => void;
}
export default function Settings({ user, onAccountDeleted }: SettingsProps) {
const [activeTab, setActiveTab] = useState<'password' | 'export' | 'delete'>('password');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showClearDataConfirm, setShowClearDataConfirm] = useState(false);
const [deleteInput, setDeleteInput] = useState('');
const [clearDataInput, setClearDataInput] = useState('');
const [resetEmail, setResetEmail] = useState('');
const [resetCode, setResetCode] = useState('');
const [newPassword, setNewPassword] = useState('');
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [loading, setLoading] = useState(false);
const handlePasswordReset = async (e: React.FormEvent) => {
e.preventDefault();
setMessage(null);
setLoading(true);
try {
// In a real app, this would send an email with a reset code
// For now, we'll show a simplified flow
const response = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: resetEmail || user.email, code: resetCode, newPassword }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Password reset failed');
}
setMessage({ type: 'success', text: 'Password reset successfully! Please sign in again.' });
setResetCode('');
setNewPassword('');
setTimeout(() => AuthService.signOut(), 2000);
} catch (error) {
setMessage({
type: 'error',
text: error instanceof Error ? error.message : 'Password reset failed',
});
} finally {
setLoading(false);
}
};
const handleExportData = async (format: 'json' | 'csv') => {
try {
const userData = await DataService.loadUserData();
if (!userData) throw new Error('No data to export');
let content: string;
let filename: string;
if (format === 'json') {
content = JSON.stringify(userData, null, 2);
filename = `capsule-backup-${new Date().toISOString().split('T')[0]}.json`;
} else {
// CSV export for transactions
const rows = [['Date', 'Description', 'Amount', 'Type', 'Account']];
userData.transactions?.forEach((t: any) => {
rows.push([
new Date(t.date).toLocaleDateString(),
t.description,
Math.abs(t.amount).toString(),
t.amount < 0 ? 'Expense' : 'Income',
userData.accounts?.find((a: any) => a.id === t.accountId)?.name || 'Unknown',
]);
});
content = rows.map(row => row.map(cell => `"${cell}"`).join(',')).join('\n');
filename = `capsule-transactions-${new Date().toISOString().split('T')[0]}.csv`;
}
const blob = new Blob([content], { type: format === 'json' ? 'application/json' : 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setMessage({ type: 'success', text: `Data exported successfully as ${format.toUpperCase()}` });
} catch (error) {
setMessage({
type: 'error',
text: error instanceof Error ? error.message : 'Export failed',
});
}
};
const handleDeleteAccount = async () => {
if (deleteInput !== 'DELETE' || !showDeleteConfirm) return;
setLoading(true);
try {
const response = await fetch('/api/auth/delete-account', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-id': user.userId,
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Account deletion failed');
}
setMessage({ type: 'success', text: 'Account deleted successfully. Signing out...' });
setTimeout(async () => {
await AuthService.signOut();
onAccountDeleted();
}, 2000);
} catch (error) {
setMessage({
type: 'error',
text: error instanceof Error ? error.message : 'Account deletion failed',
});
} finally {
setLoading(false);
}
};
const handleClearData = async () => {
if (clearDataInput !== 'CLEAR ALL' || !showClearDataConfirm) return;
setLoading(true);
try {
await DataService.saveUserData({
accounts: [],
envelopes: [],
transactions: [],
setupCompleted: false,
});
setMessage({ type: 'success', text: 'All data cleared successfully. Reloading app...' });
setClearDataInput('');
setShowClearDataConfirm(false);
setTimeout(() => window.location.reload(), 1500);
} catch (error) {
setMessage({
type: 'error',
text: error instanceof Error ? error.message : 'Failed to clear data',
});
} finally {
setLoading(false);
}
};
return (
<div className="px-4 py-6 sm:px-0">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Settings</h2>
{/* Tab Navigation */}
<div className="border-b border-gray-200 mb-6">
<nav className="flex space-x-8" aria-label="Settings tabs">
{[
{ id: 'password', label: 'Password & Security' },
{ id: 'export', label: 'Data & Export' },
{ id: 'delete', label: 'Account' },
].map((tab) => (
<button
key={tab.id}
onClick={() => {
setActiveTab(tab.id as any);
setMessage(null);
}}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === tab.id
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
{/* Messages */}
{message && (
<div
className={`mb-6 p-4 rounded-lg ${
message.type === 'success'
? 'bg-green-50 text-green-800 border border-green-200'
: 'bg-red-50 text-red-800 border border-red-200'
}`}
>
{message.text}
</div>
)}
{/* Password Tab */}
{activeTab === 'password' && (
<div className="bg-white rounded-lg shadow-sm border p-6 max-w-md">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Reset Password</h3>
<form onSubmit={handlePasswordReset} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email Address
</label>
<input
type="email"
value={resetEmail || user.email}
onChange={(e) => setResetEmail(e.target.value)}
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"
/>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-md p-3 text-sm text-blue-800">
<p className="font-medium mb-1">Reset Code</p>
<p>
In a production app, a reset code would be sent to your email. For testing, enter any code and your new password.
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Reset Code
</label>
<input
type="text"
value={resetCode}
onChange={(e) => setResetCode(e.target.value)}
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
New Password
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
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="••••••••"
/>
</div>
<button
type="submit"
disabled={loading || !resetCode || !newPassword}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{loading ? 'Resetting...' : 'Reset Password'}
</button>
</form>
</div>
)}
{/* Export Tab */}
{activeTab === 'export' && (
<div className="space-y-6">
<div className="bg-white rounded-lg shadow-sm border p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Export Your Data</h3>
<p className="text-gray-600 mb-4">
Download a backup of your budget data in your preferred format.
</p>
<div className="space-y-3">
<button
onClick={() => handleExportData('json')}
className="w-full bg-green-600 text-white py-3 px-4 rounded-md hover:bg-green-700 transition-colors text-left flex justify-between items-center"
>
<span>📋 Export as JSON (Full Backup)</span>
<span className="text-sm">Recommended</span>
</button>
<button
onClick={() => handleExportData('csv')}
className="w-full bg-blue-600 text-white py-3 px-4 rounded-md hover:bg-blue-700 transition-colors text-left"
>
📊 Export Transactions as CSV
</button>
</div>
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-md">
<p className="text-sm text-blue-800">
<strong>💡 Tip:</strong> Regularly export your data as a backup. You can import it later if needed.
</p>
</div>
</div>
<div className="bg-orange-50 border border-orange-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-orange-900 mb-4">⚠️ Clear All Data</h3>
{!showClearDataConfirm ? (
<div className="space-y-4">
<p className="text-sm text-orange-800">
This will delete all your accounts, envelopes, and transactions, but your account will remain active. You can set up new data after clearing.
</p>
<button
onClick={() => setShowClearDataConfirm(true)}
className="w-full bg-orange-600 text-white py-2 px-4 rounded-md hover:bg-orange-700 transition-colors"
>
I understand, Clear All Data
</button>
</div>
) : (
<div className="space-y-4">
<div className="bg-orange-100 border border-orange-300 rounded-md p-3">
<p className="text-sm font-medium text-orange-900 mb-2">
To confirm clearing all data, type "<strong>CLEAR ALL</strong>" below:
</p>
<input
type="text"
value={clearDataInput}
onChange={(e) => setClearDataInput(e.target.value.toUpperCase())}
className="w-full px-3 py-2 border border-orange-300 rounded-md focus:outline-none focus:ring-2 focus:ring-orange-500 bg-white text-gray-900"
placeholder="Type CLEAR ALL"
autoFocus
/>
</div>
<div className="flex gap-3">
<button
onClick={() => {
setShowClearDataConfirm(false);
setClearDataInput('');
}}
className="flex-1 bg-gray-200 text-gray-900 py-2 px-4 rounded-md hover:bg-gray-300 transition-colors"
>
Cancel
</button>
<button
onClick={handleClearData}
disabled={clearDataInput !== 'CLEAR ALL' || loading}
className="flex-1 bg-orange-600 text-white py-2 px-4 rounded-md hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Clearing...' : 'Clear All Data'}
</button>
</div>
</div>
)}
</div>
</div>
)}
{/* Delete Tab */}
{activeTab === 'delete' && (
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md">
<h3 className="text-lg font-semibold text-red-900 mb-4">⚠️ Delete Account</h3>
{!showDeleteConfirm ? (
<div className="space-y-4">
<p className="text-sm text-red-800">
Deleting your account is permanent and cannot be undone. All your budget data, transactions, and envelopes will be permanently deleted.
</p>
<button
onClick={() => setShowDeleteConfirm(true)}
className="w-full bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 transition-colors"
>
I understand, Delete My Account
</button>
</div>
) : (
<div className="space-y-4">
<div className="bg-red-100 border border-red-300 rounded-md p-3">
<p className="text-sm font-medium text-red-900 mb-2">
To confirm deletion, type "<strong>DELETE</strong>" below:
</p>
<input
type="text"
value={deleteInput}
onChange={(e) => setDeleteInput(e.target.value.toUpperCase())}
className="w-full px-3 py-2 border border-red-300 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500 bg-white text-gray-900"
placeholder="Type DELETE"
autoFocus
/>
</div>
<div className="flex gap-3">
<button
onClick={() => {
setShowDeleteConfirm(false);
setDeleteInput('');
}}
className="flex-1 bg-gray-200 text-gray-900 py-2 px-4 rounded-md hover:bg-gray-300 transition-colors"
>
Cancel
</button>
<button
onClick={handleDeleteAccount}
disabled={deleteInput !== 'DELETE' || loading}
className="flex-1 bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Deleting...' : 'Permanently Delete'}
</button>
</div>
</div>
)}
</div>
)}
</div>
);
}
+156 -53
View File
@@ -104,6 +104,98 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
'bg-red-500', 'bg-yellow-500', 'bg-pink-500', 'bg-indigo-500'
];
const generateDemoData = () => {
const demoAccounts: Account[] = [
{
id: 'checking-demo',
name: 'Main Checking',
type: 'checking',
balance: 5000,
color: 'bg-blue-500',
isActive: true,
institution: 'Demo Bank',
},
{
id: 'savings-demo',
name: 'Emergency Fund',
type: 'savings',
balance: 10000,
color: 'bg-green-500',
isActive: true,
institution: 'Demo Bank',
},
];
const demoEnvelopes: Envelope[] = [
{
id: 'env-groceries',
name: 'Groceries',
allocated: 500,
spent: 0,
color: 'bg-orange-500',
accountId: 'checking-demo',
},
{
id: 'env-dining',
name: 'Dining & Entertainment',
allocated: 300,
spent: 0,
color: 'bg-pink-500',
accountId: 'checking-demo',
},
{
id: 'env-utilities',
name: 'Utilities',
allocated: 200,
spent: 0,
color: 'bg-red-500',
accountId: 'checking-demo',
},
{
id: 'env-transportation',
name: 'Transportation',
allocated: 400,
spent: 0,
color: 'bg-purple-500',
accountId: 'checking-demo',
},
{
id: 'env-personal',
name: 'Personal Care',
allocated: 150,
spent: 0,
color: 'bg-indigo-500',
accountId: 'checking-demo',
},
];
return { demoAccounts, demoEnvelopes };
};
const handleSkipWithDemo = async () => {
if (!userId) {
setErrorMessage('No user ID provided');
return;
}
setIsSaving(true);
setErrorMessage(null);
try {
const { demoAccounts, demoEnvelopes } = generateDemoData();
DataService.setUserId(userId);
await DataService.saveUserData({
accounts: demoAccounts,
envelopes: demoEnvelopes,
transactions: [],
setupCompleted: true,
});
onComplete(demoAccounts, demoEnvelopes);
} catch (error) {
console.error('Error creating demo data:', error);
setErrorMessage(error instanceof Error ? error.message : 'Failed to create demo data');
setIsSaving(false);
}
};
const handleAddAccount = () => {
if (!newAccount.name || !newAccount.type) return;
@@ -206,7 +298,13 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
transactions: [],
setupCompleted: true,
});
onComplete(accountsWithDefaults, envelopesWithAllocations);
try {
onComplete(accountsWithDefaults, envelopesWithAllocations);
} catch (callbackError) {
console.error('Error in setup completion callback:', callbackError);
setErrorMessage('Setup completed but failed to update display. Please refresh.');
}
} catch (error) {
console.error('Error saving setup data:', error);
setErrorMessage('Failed to save setup data. Please try again.');
@@ -219,7 +317,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
<div className="text-center">
<div className="mb-8">
<div className="text-6xl mb-4">💰</div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Welcome to Envelope Budgeting</h1>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Welcome to Capsule</h1>
<p className="text-gray-600 mb-4">Let&apos;s set up your budget to get started with secure, organized financial tracking.</p>
</div>
@@ -231,10 +329,11 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
Get Started
</button>
<button
onClick={onSkip}
className="w-full text-gray-600 hover:text-gray-800 py-2"
onClick={handleSkipWithDemo}
disabled={isSaving}
className="w-full text-gray-600 hover:text-gray-800 py-2 disabled:opacity-50"
>
Skip Setup (Use Demo Data)
{isSaving ? 'Creating demo data...' : 'Skip Setup (Use Demo Data)'}
</button>
</div>
</div>
@@ -289,7 +388,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
{/* Existing Accounts */}
<div className="mb-6">
<h3 className="font-semibold text-gray-900 dark:text-white mb-3">Your Accounts</h3>
<h3 className="font-semibold text-gray-900 mb-3">Your Accounts</h3>
{(accounts || []).length === 0 ? (
<p className="text-gray-500 dark:text-gray-400 text-center py-8">No accounts added yet</p>
) : (
@@ -299,11 +398,11 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
<div className="flex items-center space-x-3">
<div className={`w-4 h-4 rounded-full ${account.color}`}></div>
<div>
<p className="font-medium text-gray-900 dark:text-white">{account.name}</p>
<p className="font-medium text-gray-900 ">{account.name}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">{account.type.replace('_', ' ').toUpperCase()}</p>
</div>
</div>
<p className="font-semibold text-gray-900 dark:text-white">${account.balance.toFixed(2)}</p>
<p className="font-semibold text-gray-900 ">${account.balance.toFixed(2)}</p>
</div>
))}
</div>
@@ -343,27 +442,27 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
{/* Add Account Modal */}
{showAddAccount && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full">
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-4">Add Account</h3>
<div className="bg-white rounded-lg p-6 max-w-md w-full">
<h3 className="text-xl font-bold text-gray-900 mb-4">Add Account</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Account Name</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Account Name</label>
<input
type="text"
value={newAccount.name || ''}
onChange={(e) => setNewAccount(prev => ({ ...prev, name: 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"
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="e.g., Main Checking"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Account Type</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Account Type</label>
<select
value={newAccount.type || 'checking'}
onChange={(e) => setNewAccount(prev => ({ ...prev, type: e.target.value as Account['type'] }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
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"
>
{accountTypes.map((type) => (
<option key={type.value} value={type.value}>
@@ -374,30 +473,30 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Current Balance</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Current Balance</label>
<input
type="number"
step="0.01"
value={newAccount.balance || ''}
onChange={(e) => setNewAccount(prev => ({ ...prev, balance: parseFloat(e.target.value) || 0 }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
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="0.00"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Institution (Optional)</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Institution (Optional)</label>
<input
type="text"
value={newAccount.institution || ''}
onChange={(e) => setNewAccount(prev => ({ ...prev, institution: 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"
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="e.g., Bank of America"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Color</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Color</label>
<div className="flex space-x-2">
{envelopeColors.slice(0, 6).map((color) => (
<button
@@ -440,7 +539,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
{/* Existing Envelopes */}
<div className="mb-6">
<h3 className="font-semibold text-gray-900 dark:text-white mb-3">Your Envelopes</h3>
<h3 className="font-semibold text-gray-900 mb-3">Your Envelopes</h3>
{envelopes.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400 text-center py-8">No envelopes created yet</p>
) : (
@@ -452,11 +551,11 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
<div className="flex items-center space-x-3">
<div className={`w-4 h-4 rounded-full ${envelope.color}`}></div>
<div>
<p className="font-medium text-gray-900 dark:text-white">{envelope.name}</p>
<p className="font-medium text-gray-900 ">{envelope.name}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">{account?.name}</p>
</div>
</div>
<p className="font-semibold text-gray-900 dark:text-white">${envelope.allocated.toFixed(2)}</p>
<p className="font-semibold text-gray-900 ">${envelope.allocated.toFixed(2)}</p>
</div>
);
})}
@@ -473,7 +572,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
checked={includeStandardEnvelopes}
onChange={(e) => setIncludeStandardEnvelopes(e.target.checked)}
/>
<span className="font-medium text-gray-900 dark:text-white">Add standard envelopes</span>
<span className="font-medium text-gray-900 ">Add standard envelopes</span>
</label>
<div className="mt-3">
@@ -551,27 +650,27 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
{/* Add Envelope Modal */}
{showAddEnvelope && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full">
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-4">Add Envelope</h3>
<div className="bg-white rounded-lg p-6 max-w-md w-full">
<h3 className="text-xl font-bold text-gray-900 mb-4">Add Envelope</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Envelope Name</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Envelope Name</label>
<input
type="text"
value={newEnvelope.name || ''}
onChange={(e) => setNewEnvelope(prev => ({ ...prev, name: 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"
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="e.g., Groceries"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Account</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Account</label>
<select
value={newEnvelope.accountId || ''}
onChange={(e) => setNewEnvelope(prev => ({ ...prev, accountId: 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"
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"
>
<option value="">Select an account</option>
{accounts.map((account) => (
@@ -583,19 +682,19 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Monthly Budget</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Monthly Budget</label>
<input
type="number"
step="0.01"
value={newEnvelope.allocated || ''}
onChange={(e) => setNewEnvelope(prev => ({ ...prev, allocated: parseFloat(e.target.value) || 0 }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
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="0.00"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Color</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Color</label>
<div className="flex space-x-2">
{envelopeColors.map((color) => (
<button
@@ -638,8 +737,8 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
{/* Income Allocation Settings */}
<div className="mb-6">
<h3 className="font-semibold text-gray-900 dark:text-white mb-3">Envelope Income Allocations</h3>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-4">
<h3 className="font-semibold text-gray-900 mb-3">Envelope Income Allocations</h3>
<p className="text-sm text-gray-600 mb-4">
Configure how much of your income goes to each envelope automatically when you get paid.
You can set percentages or fixed amounts.
</p>
@@ -666,7 +765,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Allocation Type</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Allocation Type</label>
<select
value={allocation?.type || 'percentage'}
onChange={(e) => setIncomeAllocations(prev => ({
@@ -676,7 +775,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
value: allocation?.value || 0
}
}))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
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-gray-400"
>
<option value="percentage">Percentage</option>
<option value="fixed">Fixed Amount</option>
@@ -684,7 +783,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<label className="block text-sm font-medium text-gray-700 mb-1">
{allocation?.type === 'fixed' ? 'Amount ($)' : 'Percentage (%)'}
</label>
<input
@@ -701,7 +800,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
}
}))}
placeholder={allocation?.type === 'fixed' ? '0.00' : '0'}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
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-gray-400"
/>
</div>
@@ -772,8 +871,8 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
{/* Default Paycheck Amounts */}
<div className="mb-6">
<h3 className="font-semibold text-gray-900 dark:text-white mb-3">Default Paycheck Amounts</h3>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-4">
<h3 className="font-semibold text-gray-900 mb-3">Default Paycheck Amounts</h3>
<p className="text-sm text-gray-600 mb-4">
Set default amounts for each account to speed up paycheck entry. You can always change these later.
</p>
@@ -795,7 +894,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
<div className="flex items-center space-x-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Default Paycheck Amount</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Default Paycheck Amount</label>
<div className="relative">
<span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500">$</span>
<input
@@ -808,7 +907,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
[account.id]: parseFloat(e.target.value) || 0
}))}
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"
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 bg-white text-gray-900 placeholder-gray-400"
/>
</div>
</div>
@@ -854,7 +953,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
<div className="mb-8">
<div className="text-6xl mb-4">🎉</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Setup Complete!</h2>
<p className="text-gray-600">Your envelope budgeting system is ready to use.</p>
<p className="text-gray-600">Your Capsule budget is ready to use.</p>
</div>
<div className="bg-gray-50 rounded-lg p-6 mb-8">
@@ -908,15 +1007,19 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" onClick={handleBackdropClick}>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-8 max-w-2xl w-full max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
{/* Close button */}
<button
onClick={onSkip}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
aria-label="Close setup wizard"
>
✕
</button>
<div className="bg-white rounded-lg shadow-xl p-8 max-w-2xl w-full max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
{/* Close button - only on welcome step, or if closing from welcome, create demo data */}
{currentStep === 'welcome' && (
<button
onClick={handleSkipWithDemo}
disabled={isSaving}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 p-2 hover:bg-gray-100 rounded transition-colors disabled:opacity-50"
aria-label="Close setup wizard"
title="Close setup wizard and use demo data"
>
✕
</button>
)}
{/* Progress Indicator */}
<div className="mb-8">
@@ -940,7 +1043,7 @@ export default function SetupWizard({ onComplete, onSkip, userId }: SetupWizardP
</div>
))}
</div>
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
<div className="text-center text-sm text-gray-600">
{currentStep === 'welcome' && 'Welcome'}
{currentStep === 'data-source' && 'Choose Setup Method'}
{currentStep === 'accounts' && 'Add Your Accounts'}
+136
View File
@@ -0,0 +1,136 @@
'use client';
import { useState } from 'react';
interface SharedAccountManagerProps {
userId: string;
accountId: string;
accountName: string;
}
export default function SharedAccountManager({ userId, accountId, accountName }: SharedAccountManagerProps) {
const [expanded, setExpanded] = useState(false);
const [shareEmail, setShareEmail] = useState('');
const [shareRole, setShareRole] = useState<'view' | 'edit'>('view');
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [sharedUsers, setSharedUsers] = useState<Array<{ email: string; role: string }>>([]);
const [loading, setLoading] = useState(false);
const handleShareAccount = async (e: React.FormEvent) => {
e.preventDefault();
setMessage(null);
setLoading(true);
try {
const response = await fetch('/api/share/account', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-id': userId,
},
body: JSON.stringify({ accountId, email: shareEmail, role: shareRole }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to share account');
}
setMessage({ type: 'success', text: `Account shared with ${shareEmail}` });
setShareEmail('');
setSharedUsers([...sharedUsers, { email: shareEmail, role: shareRole }]);
} catch (error) {
setMessage({
type: 'error',
text: error instanceof Error ? error.message : 'Failed to share account',
});
} finally {
setLoading(false);
}
};
return (
<div className="mt-4 pt-4 border-t border-gray-200">
<button
onClick={() => setExpanded(!expanded)}
className="flex justify-between items-center w-full font-semibold text-gray-900 hover:text-gray-700"
>
<span>👥 Share Account</span>
<span>{expanded ? '▲' : '▼'}</span>
</button>
{expanded && (
<div className="mt-4 space-y-4">
<form onSubmit={handleShareAccount} className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email Address
</label>
<input
type="email"
value={shareEmail}
onChange={(e) => setShareEmail(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
placeholder="user@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Permission Level
</label>
<select
value={shareRole}
onChange={(e) => setShareRole(e.target.value as 'view' | 'edit')}
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"
>
<option value="view">View Only</option>
<option value="edit">Can Edit</option>
</select>
</div>
<button
type="submit"
disabled={loading || !shareEmail}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{loading ? 'Sharing...' : 'Share Account'}
</button>
</form>
{message && (
<div
className={`p-3 rounded-md text-sm ${
message.type === 'success'
? 'bg-green-50 text-green-800 border border-green-200'
: 'bg-red-50 text-red-800 border border-red-200'
}`}
>
{message.text}
</div>
)}
{sharedUsers.length > 0 && (
<div className="mt-4 pt-4 border-t border-gray-200">
<p className="text-sm font-medium text-gray-900 mb-2">Shared With:</p>
<ul className="space-y-2">
{sharedUsers.map((user) => (
<li key={user.email} className="flex justify-between items-center text-sm">
<span className="text-gray-700">{user.email}</span>
<span className="text-xs bg-gray-100 px-2 py-1 rounded capitalize">
{user.role}
</span>
</li>
))}
</ul>
</div>
)}
<p className="text-xs text-gray-600 bg-gray-50 p-2 rounded">
💡 <strong>Note:</strong> Shared accounts will appear in the recipient's Capsule app. They can view or edit based on the permissions you set.
</p>
</div>
)}
</div>
);
}
+5 -5
View File
@@ -134,24 +134,24 @@ export default function TransactionEdit({ transaction, envelopes, accounts, onSa
</select>
</div>
<div className="flex items-center space-x-4">
<label className="flex items-center">
<div className="flex items-center space-x-4 p-3 bg-gray-50 rounded-md">
<label className="flex items-center cursor-pointer">
<input
type="radio"
checked={isExpense}
onChange={() => setIsExpense(true)}
className="mr-2"
/>
<span className="text-sm text-gray-700">Expense</span>
<span className="text-sm font-medium text-red-600">💸 Expense (Negative)</span>
</label>
<label className="flex items-center">
<label className="flex items-center cursor-pointer">
<input
type="radio"
checked={!isExpense}
onChange={() => setIsExpense(false)}
className="mr-2"
/>
<span className="text-sm text-gray-700">Income</span>
<span className="text-sm font-medium text-green-600">💰 Income (Positive)</span>
</label>
</div>