Fix envelope updates, balance persistence, Get Paid duplication, form readability, and income allocation carryover
This commit is contained in:
1 parent
caa6020ae8
commit
1b269dedd9
24 files changed
+2265
-472
No files matched your search
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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'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'}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user