the start
This commit is contained in:
1 parent
e41e78f9a3
commit
a79dddff60
22 files changed
+4856
-99
No files matched your search
+2
-2
@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Envelope Budgeting App",
|
||||
description: "A modern envelope budgeting application to help you manage your finances effectively",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
+373
-59
@@ -1,65 +1,379 @@
|
||||
import Image from "next/image";
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { onAuthStateChanged, signOut, User } from 'firebase/auth';
|
||||
import { auth } from '../lib/firebase';
|
||||
import { DataService } from '../lib/dataService';
|
||||
import DataInput from '../components/DataInput';
|
||||
import TransactionEdit from '../components/TransactionEdit';
|
||||
import EnvelopeCreate from '../components/EnvelopeCreate';
|
||||
import EnvelopeEdit from '../components/EnvelopeEdit';
|
||||
import DataExport from '../components/DataExport';
|
||||
import AuthModal from '../components/AuthModal';
|
||||
import SetupWizard from '../components/SetupWizard';
|
||||
import AccountManagement from '../components/AccountManagement';
|
||||
import GetPaid from '../components/GetPaid';
|
||||
|
||||
interface Account {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'checking' | 'savings' | 'credit_card' | 'mortgage' | 'investment' | 'loan';
|
||||
balance: number;
|
||||
institution?: string;
|
||||
accountNumber?: string;
|
||||
color: string;
|
||||
isActive: boolean;
|
||||
defaultPaycheckAmount?: number;
|
||||
}
|
||||
|
||||
interface Envelope {
|
||||
id: string;
|
||||
name: string;
|
||||
allocated: number;
|
||||
spent: number;
|
||||
color: string;
|
||||
accountId: string;
|
||||
incomeAllocation?: number; // Percentage (0-100) or fixed amount if negative
|
||||
incomeAllocationType?: 'percentage' | 'fixed'; // How to interpret incomeAllocation
|
||||
}
|
||||
|
||||
interface Transaction {
|
||||
id: string;
|
||||
envelopeId?: string;
|
||||
amount: number;
|
||||
description: string;
|
||||
date: Date;
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
type ViewMode = 'dashboard' | 'accounts' | 'transactions' | 'envelopes' | 'settings';
|
||||
|
||||
export default function BudgetDashboard() {
|
||||
const [currentView, setCurrentView] = useState<ViewMode>('dashboard');
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||
const [showSetupWizard, setShowSetupWizard] = useState(false);
|
||||
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [envelopes, setEnvelopes] = useState<Envelope[]>([]);
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [setupCompleted, setSetupCompleted] = useState(false);
|
||||
|
||||
// Modal states
|
||||
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
|
||||
const [showCreateEnvelope, setShowCreateEnvelope] = useState(false);
|
||||
const [editingEnvelope, setEditingEnvelope] = useState<Envelope | null>(null);
|
||||
|
||||
// Initialize Firebase auth listener
|
||||
useEffect(() => {
|
||||
const unsubscribe = onAuthStateChanged(auth, async (user) => {
|
||||
setUser(user);
|
||||
if (user) {
|
||||
// Load user data from Firestore
|
||||
try {
|
||||
const userData = await DataService.loadUserData(user.uid);
|
||||
if (userData) {
|
||||
setAccounts(userData.accounts || []);
|
||||
setEnvelopes(userData.envelopes || []);
|
||||
setTransactions(userData.transactions || []);
|
||||
setSetupCompleted(userData.setupCompleted || false);
|
||||
setShowSetupWizard(!(userData.setupCompleted || false));
|
||||
} else {
|
||||
// New user - show setup wizard
|
||||
setShowSetupWizard(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading user data:', error);
|
||||
// On error, assume new user and show setup wizard
|
||||
setShowSetupWizard(true);
|
||||
}
|
||||
} else {
|
||||
// Clear data when user logs out
|
||||
setAccounts([]);
|
||||
setEnvelopes([]);
|
||||
setTransactions([]);
|
||||
setSetupCompleted(false);
|
||||
setShowSetupWizard(false);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
// Save data to Firestore whenever data changes
|
||||
useEffect(() => {
|
||||
if (user && (accounts.length > 0 || envelopes.length > 0 || transactions.length > 0 || setupCompleted)) {
|
||||
const saveData = async () => {
|
||||
try {
|
||||
await DataService.saveUserData(user.uid, { accounts, envelopes, transactions, setupCompleted });
|
||||
} catch (error) {
|
||||
console.error('Error saving user data:', error);
|
||||
}
|
||||
};
|
||||
saveData();
|
||||
}
|
||||
}, [user, accounts, envelopes, transactions, setupCompleted]);
|
||||
|
||||
const handleTransactionsAdded = (newTransactions: Transaction[]) => {
|
||||
const allTransactions = [...newTransactions];
|
||||
|
||||
// Process income transactions - automatically allocate to envelopes
|
||||
newTransactions.forEach(transaction => {
|
||||
if (transaction.amount > 0 && !transaction.envelopeId) {
|
||||
// This is income - allocate to envelopes based on custom settings or proportionally
|
||||
const accountEnvelopes = envelopes.filter(env => env.accountId === transaction.accountId);
|
||||
const envelopesWithAllocation = accountEnvelopes.filter(env => env.incomeAllocation && env.incomeAllocationType);
|
||||
|
||||
if (envelopesWithAllocation.length > 0) {
|
||||
// Validate allocations before processing
|
||||
const validation = validateIncomeAllocation(envelopesWithAllocation, transaction.amount);
|
||||
if (!validation.isValid) {
|
||||
console.warn('Invalid income allocation detected, falling back to proportional');
|
||||
// Fall back to proportional allocation if validation fails
|
||||
const totalAllocated = accountEnvelopes.reduce((sum, env) => sum + env.allocated, 0);
|
||||
if (accountEnvelopes.length > 0 && totalAllocated > 0) {
|
||||
accountEnvelopes.forEach(envelope => {
|
||||
const proportion = envelope.allocated / totalAllocated;
|
||||
const allocatedAmount = transaction.amount * proportion;
|
||||
if (allocatedAmount > 0) {
|
||||
allTransactions.push({
|
||||
id: `allocation-${envelope.id}-${Date.now()}-${Math.random()}`,
|
||||
envelopeId: envelope.id,
|
||||
amount: allocatedAmount,
|
||||
description: `Income allocation - ${envelope.name}`,
|
||||
date: transaction.date,
|
||||
accountId: transaction.accountId,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Use custom allocation settings
|
||||
envelopesWithAllocation.forEach(envelope => {
|
||||
let allocatedAmount = 0;
|
||||
if (envelope.incomeAllocationType === 'percentage') {
|
||||
allocatedAmount = transaction.amount * (envelope.incomeAllocation! / 100);
|
||||
} else if (envelope.incomeAllocationType === 'fixed') {
|
||||
allocatedAmount = Math.min(envelope.incomeAllocation!, transaction.amount);
|
||||
}
|
||||
|
||||
if (allocatedAmount > 0) {
|
||||
allTransactions.push({
|
||||
id: `allocation-${envelope.id}-${Date.now()}-${Math.random()}`,
|
||||
envelopeId: envelope.id,
|
||||
amount: allocatedAmount,
|
||||
description: `Income allocation - ${envelope.name}`,
|
||||
date: transaction.date,
|
||||
accountId: transaction.accountId,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No specific envelope allocation found, fall back to proportional allocation
|
||||
const totalAllocated = accountEnvelopes.reduce((sum, env) => sum + env.allocated, 0);
|
||||
if (accountEnvelopes.length > 0 && totalAllocated > 0) {
|
||||
accountEnvelopes.forEach(envelope => {
|
||||
const proportion = envelope.allocated / totalAllocated;
|
||||
const allocatedAmount = transaction.amount * proportion;
|
||||
if (allocatedAmount > 0) {
|
||||
allTransactions.push({
|
||||
id: `allocation-${envelope.id}-${Date.now()}-${Math.random()}`,
|
||||
envelopeId: envelope.id,
|
||||
amount: allocatedAmount,
|
||||
description: `Income allocation - ${envelope.name}`,
|
||||
date: transaction.date,
|
||||
accountId: transaction.accountId,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add all processed transactions at once
|
||||
setTransactions(prevTransactions => [...prevTransactions, ...allTransactions]);
|
||||
};
|
||||
|
||||
const validateIncomeAllocation = (envelopes: Envelope[], totalAmount: number) => {
|
||||
let fixedTotal = 0;
|
||||
let percentageTotal = 0;
|
||||
let hasFixed = false;
|
||||
let hasPercentage = false;
|
||||
|
||||
envelopes.forEach(envelope => {
|
||||
if (envelope.incomeAllocationType === 'fixed') {
|
||||
fixedTotal += envelope.incomeAllocation!;
|
||||
hasFixed = true;
|
||||
} else if (envelope.incomeAllocationType === 'percentage') {
|
||||
percentageTotal += envelope.incomeAllocation!;
|
||||
hasPercentage = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Check for conflicting allocation types
|
||||
if (hasFixed && hasPercentage) {
|
||||
return { isValid: false, message: 'Conflicting allocation types (fixed and percentage) detected.' };
|
||||
}
|
||||
|
||||
// Validate fixed allocations
|
||||
if (hasFixed && fixedTotal !== totalAmount) {
|
||||
return { isValid: false, message: 'Fixed allocations do not sum up to the total amount.' };
|
||||
}
|
||||
|
||||
// Validate percentage allocations
|
||||
if (hasPercentage && (percentageTotal < 0 || percentageTotal > 100)) {
|
||||
return { isValid: false, message: 'Percentage allocations must be between 0 and 100.' };
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
};
|
||||
|
||||
const handleSignOut = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await signOut(auth);
|
||||
} catch (error) {
|
||||
console.error('Error signing out:', error);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleAuthSuccess = () => {
|
||||
setShowAuthModal(false);
|
||||
};
|
||||
|
||||
const handleSetupComplete = (setupData: {
|
||||
accounts: Account[];
|
||||
envelopes: Envelope[];
|
||||
transactions: Transaction[];
|
||||
}) => {
|
||||
setAccounts(setupData.accounts);
|
||||
setEnvelopes(setupData.envelopes);
|
||||
setTransactions(setupData.transactions);
|
||||
setSetupCompleted(true);
|
||||
setShowSetupWizard(false);
|
||||
};
|
||||
|
||||
const handleAccountUpdate = (updatedAccount: Account) => {
|
||||
setAccounts(prevAccounts => prevAccounts.map(acc => acc.id === updatedAccount.id ? updatedAccount : acc));
|
||||
};
|
||||
|
||||
const handleAccountAdd = (newAccount: Account) => {
|
||||
setAccounts(prevAccounts => [...prevAccounts, newAccount]);
|
||||
};
|
||||
|
||||
const handleAccountDelete = (accountId: string) => {
|
||||
setAccounts(prevAccounts => prevAccounts.filter(acc => acc.id !== accountId));
|
||||
};
|
||||
|
||||
const handleEnvelopeUpdate = (updatedEnvelope: Envelope) => {
|
||||
setEnvelopes(prevEnvelopes => prevEnvelopes.map(env => env.id === updatedEnvelope.id ? updatedEnvelope : env));
|
||||
};
|
||||
|
||||
const handleTransactionEdit = (updatedTransaction: Transaction) => {
|
||||
setTransactions(prevTransactions => prevTransactions.map(tx => tx.id === updatedTransaction.id ? updatedTransaction : tx));
|
||||
};
|
||||
|
||||
const handleTransactionDelete = (transactionId: string) => {
|
||||
setTransactions(prevTransactions => prevTransactions.filter(tx => tx.id !== transactionId));
|
||||
};
|
||||
|
||||
const handleCreateEnvelope = (newEnvelope: Envelope) => {
|
||||
setEnvelopes(prevEnvelopes => [...prevEnvelopes, newEnvelope]);
|
||||
};
|
||||
|
||||
const handleDeleteEnvelope = (envelopeId: string) => {
|
||||
setEnvelopes(prevEnvelopes => prevEnvelopes.filter(env => env.id !== envelopeId));
|
||||
};
|
||||
|
||||
const handleDataExport = (exportData: { accounts: Account[]; envelopes: Envelope[]; transactions: Transaction[] }) => {
|
||||
// Implement data export logic here (e.g., download CSV, send to server, etc.)
|
||||
console.log('Exporting data:', exportData);
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
<div>
|
||||
{/* Render components based on current view */}
|
||||
{currentView === 'dashboard' && <div>Dashboard View</div>}
|
||||
{currentView === 'accounts' && <div>Accounts View</div>}
|
||||
{currentView === 'transactions' && <div>Transactions View</div>}
|
||||
{currentView === 'envelopes' && <div>Envelopes View</div>}
|
||||
{currentView === 'settings' && <div>Settings View</div>}
|
||||
|
||||
{/* Auth modal */}
|
||||
{showAuthModal && <AuthModal onClose={() => setShowAuthModal(false)} onSuccess={handleAuthSuccess} />}
|
||||
|
||||
{/* Setup wizard */}
|
||||
{showSetupWizard && <SetupWizard onComplete={handleSetupComplete} />}
|
||||
|
||||
{/* Data input component */}
|
||||
<DataInput
|
||||
onDataImported={(importedData) => {
|
||||
// Handle imported data (e.g., from CSV)
|
||||
console.log('Imported data:', importedData);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Transaction edit component */}
|
||||
{editingTransaction && (
|
||||
<TransactionEdit
|
||||
transaction={editingTransaction}
|
||||
onSave={(updatedTransaction) => {
|
||||
handleTransactionEdit(updatedTransaction);
|
||||
setEditingTransaction(null);
|
||||
}}
|
||||
onCancel={() => setEditingTransaction(null)}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
)}
|
||||
|
||||
{/* Envelope create/edit component */}
|
||||
{showCreateEnvelope && (
|
||||
<EnvelopeCreate
|
||||
onCreate={(newEnvelope) => {
|
||||
handleCreateEnvelope(newEnvelope);
|
||||
setShowCreateEnvelope(false);
|
||||
}}
|
||||
onCancel={() => setShowCreateEnvelope(false)}
|
||||
/>
|
||||
)}
|
||||
{editingEnvelope && (
|
||||
<EnvelopeEdit
|
||||
envelope={editingEnvelope}
|
||||
onSave={(updatedEnvelope) => {
|
||||
handleEnvelopeUpdate(updatedEnvelope);
|
||||
setEditingEnvelope(null);
|
||||
}}
|
||||
onCancel={() => setEditingEnvelope(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Data export component */}
|
||||
<DataExport
|
||||
accounts={accounts}
|
||||
envelopes={envelopes}
|
||||
transactions={transactions}
|
||||
onExport={handleDataExport}
|
||||
/>
|
||||
|
||||
{/* Account management component */}
|
||||
<AccountManagement
|
||||
accounts={accounts}
|
||||
onAccountAdd={handleAccountAdd}
|
||||
onAccountUpdate={handleAccountUpdate}
|
||||
onAccountDelete={handleAccountDelete}
|
||||
/>
|
||||
|
||||
{/* Get Paid component */}
|
||||
<GetPaid
|
||||
accounts={accounts}
|
||||
envelopes={envelopes}
|
||||
onIncomeAdded={handleTransactionsAdded}
|
||||
onAccountUpdate={handleAccountUpdate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Account {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'checking' | 'savings' | 'credit_card' | 'mortgage' | 'investment' | 'loan';
|
||||
balance: number;
|
||||
institution?: string;
|
||||
accountNumber?: string;
|
||||
color: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface AccountManagementProps {
|
||||
accounts: Account[];
|
||||
onAccountAdd: (account: Account) => void;
|
||||
onAccountUpdate: (account: Account) => void;
|
||||
onAccountDelete: (accountId: string) => void;
|
||||
}
|
||||
|
||||
const accountTypes = [
|
||||
{ value: 'checking', label: 'Checking Account', icon: '🏦' },
|
||||
{ value: 'savings', label: 'Savings Account', icon: '💰' },
|
||||
{ value: 'credit_card', label: 'Credit Card', icon: '💳' },
|
||||
{ value: 'mortgage', label: 'Mortgage', icon: '🏠' },
|
||||
{ value: 'investment', label: 'Investment', icon: '📈' },
|
||||
{ value: 'loan', label: 'Loan', icon: '📋' },
|
||||
];
|
||||
|
||||
const accountColors = [
|
||||
'bg-blue-500', 'bg-green-500', 'bg-purple-500', 'bg-orange-500',
|
||||
'bg-red-500', 'bg-yellow-500', 'bg-pink-500', 'bg-indigo-500'
|
||||
];
|
||||
|
||||
export default function AccountManagement({ accounts, onAccountAdd, onAccountUpdate, onAccountDelete }: AccountManagementProps) {
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [editingAccount, setEditingAccount] = useState<Account | null>(null);
|
||||
const [formData, setFormData] = useState<Partial<Account>>({
|
||||
type: 'checking',
|
||||
balance: 0,
|
||||
color: 'bg-blue-500',
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
type: 'checking',
|
||||
balance: 0,
|
||||
color: 'bg-blue-500',
|
||||
isActive: true,
|
||||
});
|
||||
setEditingAccount(null);
|
||||
setShowAddForm(false);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name || !formData.type) {
|
||||
alert('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
const account: Account = {
|
||||
id: editingAccount?.id || `${formData.type}-${Date.now()}`,
|
||||
name: formData.name,
|
||||
type: formData.type as Account['type'],
|
||||
balance: formData.balance || 0,
|
||||
institution: formData.institution,
|
||||
accountNumber: formData.accountNumber,
|
||||
color: formData.color || 'bg-blue-500',
|
||||
isActive: formData.isActive ?? true,
|
||||
};
|
||||
|
||||
if (editingAccount) {
|
||||
onAccountUpdate(account);
|
||||
} else {
|
||||
onAccountAdd(account);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const handleEdit = (account: Account) => {
|
||||
setFormData(account);
|
||||
setEditingAccount(account);
|
||||
setShowAddForm(true);
|
||||
};
|
||||
|
||||
const handleDelete = (accountId: string) => {
|
||||
if (confirm('Are you sure you want to delete this account? This action cannot be undone.')) {
|
||||
onAccountDelete(accountId);
|
||||
}
|
||||
};
|
||||
|
||||
const totalBalance = accounts.reduce((sum, acc) => sum + acc.balance, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900">Account Management</h2>
|
||||
<p className="text-gray-600">Manage your financial accounts and track balances</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-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 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
<span>Add Account</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border">
|
||||
<h3 className="text-sm font-medium text-gray-500">Total Accounts</h3>
|
||||
<p className="text-2xl font-bold text-gray-900">{accounts.length}</p>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border">
|
||||
<h3 className="text-sm font-medium text-gray-500">Total Balance</h3>
|
||||
<p className="text-2xl font-bold text-green-600">${totalBalance.toFixed(2)}</p>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border">
|
||||
<h3 className="text-sm font-medium text-gray-500">Active Accounts</h3>
|
||||
<p className="text-2xl font-bold text-blue-600">{accounts.filter(acc => acc.isActive).length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accounts List */}
|
||||
<div className="bg-white rounded-lg shadow-sm border overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">Your Accounts</h3>
|
||||
</div>
|
||||
|
||||
{accounts.length === 0 ? (
|
||||
<div className="px-6 py-8 text-center">
|
||||
<div className="text-4xl mb-4">🏦</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No accounts yet</h3>
|
||||
<p className="text-gray-600 mb-4">Add your first account to start tracking your finances</p>
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Add Your First Account
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<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 className="flex items-center space-x-4">
|
||||
<div className={`w-4 h-4 rounded-full ${account.color}`}></div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">{account.name}</h4>
|
||||
<p className="text-sm text-gray-500">
|
||||
{accountTypes.find(t => t.value === account.type)?.icon} {account.type.replace('_', ' ').toUpperCase()}
|
||||
{account.institution && ` • ${account.institution}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-gray-900">${account.balance.toFixed(2)}</p>
|
||||
<p className={`text-sm ${account.isActive ? 'text-green-600' : 'text-gray-500'}`}>
|
||||
{account.isActive ? 'Active' : 'Inactive'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleEdit(account)}
|
||||
className="text-gray-400 hover:text-blue-600 p-1 rounded"
|
||||
title="Edit account"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(account.id)}
|
||||
className="text-gray-400 hover:text-red-600 p-1 rounded"
|
||||
title="Delete account"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add/Edit Account Modal */}
|
||||
{showAddForm && (
|
||||
<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-md w-full">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4">
|
||||
{editingAccount ? 'Edit Account' : 'Add New Account'}
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Account Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name || ''}
|
||||
onChange={(e) => setFormData(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"
|
||||
placeholder="e.g., Main Checking"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Account Type *</label>
|
||||
<select
|
||||
value={formData.type || 'checking'}
|
||||
onChange={(e) => setFormData(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"
|
||||
required
|
||||
>
|
||||
{accountTypes.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.icon} {type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Current Balance</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.balance || ''}
|
||||
onChange={(e) => setFormData(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"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Institution (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.institution || ''}
|
||||
onChange={(e) => setFormData(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"
|
||||
placeholder="e.g., Bank of America"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Account Number (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.accountNumber || ''}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, accountNumber: 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"
|
||||
placeholder="Last 4 digits"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Color</label>
|
||||
<div className="flex space-x-2">
|
||||
{accountColors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => setFormData(prev => ({ ...prev, color }))}
|
||||
className={`w-8 h-8 rounded-full ${color} ${formData.color === color ? 'ring-2 ring-gray-400' : ''}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isActive"
|
||||
checked={formData.isActive ?? true}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, isActive: e.target.checked }))}
|
||||
className="mr-2"
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm text-gray-700">Active Account</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetForm}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
{editingAccount ? 'Update Account' : 'Add Account'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { signInWithEmailAndPassword, createUserWithEmailAndPassword, signOut } from 'firebase/auth';
|
||||
import { auth } from '../lib/firebase';
|
||||
|
||||
interface AuthModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onAuthSuccess: () => void;
|
||||
}
|
||||
|
||||
export default function AuthModal({ isOpen, onClose, onAuthSuccess }: AuthModalProps) {
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
if (isLogin) {
|
||||
await signInWithEmailAndPassword(auth, email, password);
|
||||
} else {
|
||||
if (password !== confirmPassword) {
|
||||
throw new Error('Passwords do not match');
|
||||
}
|
||||
await createUserWithEmailAndPassword(auth, email, password);
|
||||
}
|
||||
onAuthSuccess();
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<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-8 max-w-md w-full shadow-xl">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
{isLogin ? 'Sign In' : 'Create Account'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 p-1"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
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"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isLogin && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
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"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-red-600 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Please wait...' : (isLogin ? 'Sign In' : 'Create Account')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
onClick={() => setIsLogin(!isLogin)}
|
||||
className="text-blue-600 hover:text-blue-800 text-sm"
|
||||
>
|
||||
{isLogin ? "Don't have an account? Sign up" : 'Already have an account? Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Envelope {
|
||||
id: string;
|
||||
name: string;
|
||||
allocated: number;
|
||||
spent: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface Transaction {
|
||||
id: string;
|
||||
envelopeId?: string;
|
||||
amount: number;
|
||||
description: string;
|
||||
date: Date;
|
||||
}
|
||||
|
||||
interface DataExportProps {
|
||||
envelopes: Envelope[];
|
||||
transactions: Transaction[];
|
||||
}
|
||||
|
||||
export default function DataExport({ envelopes, transactions }: DataExportProps) {
|
||||
const [exportFormat, setExportFormat] = useState<'csv' | 'json'>('csv');
|
||||
|
||||
const exportToCSV = () => {
|
||||
// Export transactions
|
||||
const transactionHeaders = ['Date', 'Description', 'Amount', 'Envelope', 'Type'];
|
||||
const transactionRows = transactions.map(t => {
|
||||
const envelope = envelopes.find(e => e.id === t.envelopeId);
|
||||
return [
|
||||
t.date.toISOString().split('T')[0],
|
||||
t.description,
|
||||
t.amount.toFixed(2),
|
||||
envelope?.name || 'Unassigned',
|
||||
t.amount >= 0 ? 'Income' : 'Expense'
|
||||
];
|
||||
});
|
||||
|
||||
// Export envelopes
|
||||
const envelopeHeaders = ['Name', 'Allocated', 'Spent', 'Remaining', 'Color'];
|
||||
const envelopeRows = envelopes.map(e => [
|
||||
e.name,
|
||||
e.allocated.toFixed(2),
|
||||
e.spent.toFixed(2),
|
||||
(e.allocated - e.spent).toFixed(2),
|
||||
e.color
|
||||
]);
|
||||
|
||||
const csvContent = [
|
||||
'TRANSACTIONS',
|
||||
transactionHeaders.join(','),
|
||||
...transactionRows.map(row => row.map(cell => `"${cell}"`).join(',')),
|
||||
'',
|
||||
'ENVELOPES',
|
||||
envelopeHeaders.join(','),
|
||||
...envelopeRows.map(row => row.map(cell => `"${cell}"`).join(','))
|
||||
].join('\n');
|
||||
|
||||
downloadFile(csvContent, 'budget-data.csv', 'text/csv');
|
||||
};
|
||||
|
||||
const exportToJSON = () => {
|
||||
const data = {
|
||||
exportDate: new Date().toISOString(),
|
||||
envelopes,
|
||||
transactions: transactions.map(t => ({
|
||||
...t,
|
||||
date: t.date.toISOString()
|
||||
}))
|
||||
};
|
||||
|
||||
const jsonContent = JSON.stringify(data, null, 2);
|
||||
downloadFile(jsonContent, 'budget-data.json', 'application/json');
|
||||
};
|
||||
|
||||
const downloadFile = (content: string, filename: string, mimeType: string) => {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
if (exportFormat === 'csv') {
|
||||
exportToCSV();
|
||||
} else {
|
||||
exportToJSON();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Export Data</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Export Format
|
||||
</label>
|
||||
<div className="flex space-x-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
checked={exportFormat === 'csv'}
|
||||
onChange={() => setExportFormat('csv')}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">CSV (Spreadsheet)</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
checked={exportFormat === 'json'}
|
||||
onChange={() => setExportFormat('json')}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">JSON (Data)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 p-4 rounded-md">
|
||||
<h4 className="text-sm font-medium text-blue-800 mb-2">What gets exported:</h4>
|
||||
<ul className="text-sm text-blue-700 space-y-1">
|
||||
<li>• All transactions with dates, amounts, and descriptions</li>
|
||||
<li>• Envelope assignments for each transaction</li>
|
||||
<li>• All envelope details (allocated amounts, spending, colors)</li>
|
||||
<li>• Export timestamp for reference</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="w-full bg-purple-600 text-white py-2 px-4 rounded-md hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
Export Data ({exportFormat.toUpperCase()})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-sm text-gray-600">
|
||||
<p className="font-medium mb-2">File formats:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li><strong>CSV:</strong> Compatible with Excel, Google Sheets, and most spreadsheet applications</li>
|
||||
<li><strong>JSON:</strong> Structured data format for developers or data analysis tools</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import FileUpload, { ParsedTransaction } from './FileUpload';
|
||||
import ManualEntry from './ManualEntry';
|
||||
|
||||
interface DataInputProps {
|
||||
onTransactionsAdded: (transactions: Array<{
|
||||
id: string;
|
||||
envelopeId?: string;
|
||||
amount: number;
|
||||
description: string;
|
||||
date: Date;
|
||||
accountId: string;
|
||||
}>) => void;
|
||||
envelopes: { id: string; name: string }[];
|
||||
accounts: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
type InputMode = 'file' | 'manual';
|
||||
|
||||
export default function DataInput({ onTransactionsAdded, envelopes, accounts }: DataInputProps) {
|
||||
const [inputMode, setInputMode] = useState<InputMode>('manual');
|
||||
|
||||
const handleFileTransactionsParsed = (parsedTransactions: ParsedTransaction[]) => {
|
||||
const transactions = parsedTransactions.map((t, index) => ({
|
||||
id: `imported-${Date.now()}-${index}`,
|
||||
amount: t.amount,
|
||||
description: t.description,
|
||||
date: t.date,
|
||||
envelopeId: undefined, // User can assign envelopes later
|
||||
accountId: accounts?.[0]?.id || 'default-account', // Default to first account
|
||||
}));
|
||||
|
||||
onTransactionsAdded(transactions);
|
||||
};
|
||||
|
||||
const handleManualTransactionAdded = (transaction: {
|
||||
amount: number;
|
||||
description: string;
|
||||
date: Date;
|
||||
envelopeId?: string;
|
||||
accountId: string;
|
||||
}) => {
|
||||
const newTransaction = {
|
||||
id: `manual-${Date.now()}`,
|
||||
...transaction,
|
||||
};
|
||||
|
||||
onTransactionsAdded([newTransaction]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6 mb-8">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">Add Transactions</h2>
|
||||
|
||||
{/* Mode Selection */}
|
||||
<div className="flex space-x-4 mb-6">
|
||||
<button
|
||||
onClick={() => setInputMode('manual')}
|
||||
className={`px-4 py-2 rounded-md font-medium ${
|
||||
inputMode === 'manual'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
Manual Entry
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setInputMode('file')}
|
||||
className={`px-4 py-2 rounded-md font-medium ${
|
||||
inputMode === 'file'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
Import from File
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content based on selected mode */}
|
||||
{inputMode === 'manual' ? (
|
||||
<ManualEntry
|
||||
onTransactionAdded={handleManualTransactionAdded}
|
||||
envelopes={envelopes}
|
||||
accounts={accounts}
|
||||
/>
|
||||
) : (
|
||||
<FileUpload onTransactionsParsed={handleFileTransactionsParsed} accounts={accounts} />
|
||||
)}
|
||||
|
||||
{/* Help Text */}
|
||||
<div className="mt-6 p-4 bg-blue-50 rounded-md">
|
||||
<h4 className="text-sm font-medium text-blue-800 mb-2">Tips:</h4>
|
||||
<ul className="text-sm text-blue-700 space-y-1">
|
||||
<li>• <strong>Manual Entry:</strong> Perfect for quick transactions and cash expenses</li>
|
||||
<li>• <strong>File Import:</strong> Great for bulk transactions from your bank statements</li>
|
||||
<li>• You can assign transactions to envelopes immediately or categorize them later</li>
|
||||
<li>• Negative amounts are expenses, positive amounts are income</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface EnvelopeCreateProps {
|
||||
onEnvelopeCreated: (envelope: {
|
||||
id: string;
|
||||
name: string;
|
||||
allocated: number;
|
||||
spent: number;
|
||||
color: string;
|
||||
accountId: string;
|
||||
incomeAllocation?: number;
|
||||
incomeAllocationType?: 'percentage' | 'fixed';
|
||||
}) => void;
|
||||
onCancel: () => void;
|
||||
accounts: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
const colorOptions = [
|
||||
{ value: 'bg-green-500', label: 'Green', color: '#10B981' },
|
||||
{ value: 'bg-blue-500', label: 'Blue', color: '#3B82F6' },
|
||||
{ value: 'bg-purple-500', label: 'Purple', color: '#8B5CF6' },
|
||||
{ value: 'bg-orange-500', label: 'Orange', color: '#F97316' },
|
||||
{ value: 'bg-red-500', label: 'Red', color: '#EF4444' },
|
||||
{ value: 'bg-yellow-500', label: 'Yellow', color: '#EAB308' },
|
||||
{ value: 'bg-pink-500', label: 'Pink', color: '#EC4899' },
|
||||
{ value: 'bg-indigo-500', label: 'Indigo', color: '#6366F1' },
|
||||
];
|
||||
|
||||
export default function EnvelopeCreate({ onEnvelopeCreated, onCancel, accounts }: EnvelopeCreateProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [allocated, setAllocated] = useState('');
|
||||
const [selectedColor, setSelectedColor] = useState(colorOptions[0].value);
|
||||
const [selectedAccount, setSelectedAccount] = useState(accounts[0]?.id || '');
|
||||
const [incomeAllocationType, setIncomeAllocationType] = useState<'percentage' | 'fixed'>('percentage');
|
||||
const [incomeAllocation, setIncomeAllocation] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const numAllocated = parseFloat(allocated);
|
||||
if (!name.trim() || isNaN(numAllocated) || numAllocated <= 0) {
|
||||
alert('Please enter a valid envelope name and positive allocated amount');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate income allocation
|
||||
if (incomeAllocation) {
|
||||
const allocationValue = parseFloat(incomeAllocation);
|
||||
if (isNaN(allocationValue) || allocationValue < 0) {
|
||||
alert('Please enter a valid income allocation amount');
|
||||
return;
|
||||
}
|
||||
if (incomeAllocationType === 'percentage' && allocationValue > 100) {
|
||||
alert('Percentage allocation cannot exceed 100%');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const newEnvelope = {
|
||||
id: `envelope-${Date.now()}`,
|
||||
name: name.trim(),
|
||||
allocated: numAllocated,
|
||||
spent: 0,
|
||||
color: selectedColor,
|
||||
accountId: selectedAccount,
|
||||
incomeAllocation: incomeAllocation ? parseFloat(incomeAllocation) : undefined,
|
||||
incomeAllocationType: incomeAllocation ? incomeAllocationType : undefined,
|
||||
};
|
||||
|
||||
onEnvelopeCreated(newEnvelope);
|
||||
|
||||
// Reset form
|
||||
setName('');
|
||||
setAllocated('');
|
||||
setSelectedColor(colorOptions[0].value);
|
||||
setSelectedAccount(accounts[0]?.id);
|
||||
setIncomeAllocationType('percentage');
|
||||
setIncomeAllocation('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Create New Envelope</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Envelope Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Groceries, Transportation"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Allocated Amount
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={allocated}
|
||||
onChange={(e) => setAllocated(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="w-full pl-8 pr-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Color
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{colorOptions.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
onClick={() => setSelectedColor(color.value)}
|
||||
className={`w-12 h-12 rounded-full border-2 ${
|
||||
selectedColor === color.value ? 'border-gray-800' : 'border-gray-300'
|
||||
}`}
|
||||
style={{ backgroundColor: color.color }}
|
||||
title={color.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Selected: {colorOptions.find(c => c.value === selectedColor)?.label}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Account
|
||||
</label>
|
||||
<select
|
||||
value={selectedAccount}
|
||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
>
|
||||
{accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Income Allocation (Optional)
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex space-x-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="allocationType"
|
||||
value="percentage"
|
||||
checked={incomeAllocationType === 'percentage'}
|
||||
onChange={(e) => setIncomeAllocationType(e.target.value as 'percentage' | 'fixed')}
|
||||
className="mr-2"
|
||||
/>
|
||||
Percentage
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="allocationType"
|
||||
value="fixed"
|
||||
checked={incomeAllocationType === 'fixed'}
|
||||
onChange={(e) => setIncomeAllocationType(e.target.value as 'percentage' | 'fixed')}
|
||||
className="mr-2"
|
||||
/>
|
||||
Fixed Amount
|
||||
</label>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500">
|
||||
{incomeAllocationType === 'percentage' ? '%' : '$'}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
step={incomeAllocationType === 'percentage' ? '1' : '0.01'}
|
||||
min="0"
|
||||
max={incomeAllocationType === 'percentage' ? '100' : undefined}
|
||||
value={incomeAllocation}
|
||||
onChange={(e) => setIncomeAllocation(e.target.value)}
|
||||
placeholder={incomeAllocationType === 'percentage' ? 'e.g., 25' : 'e.g., 100.00'}
|
||||
className="w-full pl-8 pr-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{incomeAllocationType === 'percentage'
|
||||
? 'Percentage of income to allocate to this envelope automatically'
|
||||
: 'Fixed dollar amount to allocate to this envelope automatically'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 bg-green-600 text-white py-2 px-4 rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
Create Envelope
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 bg-gray-300 text-gray-700 py-2 px-4 rounded-md hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface EnvelopeEditProps {
|
||||
envelope: {
|
||||
id: string;
|
||||
name: string;
|
||||
allocated: number;
|
||||
spent: number;
|
||||
color: string;
|
||||
accountId: string;
|
||||
incomeAllocation?: number;
|
||||
incomeAllocationType?: 'percentage' | 'fixed';
|
||||
};
|
||||
accounts: { id: string; name: string }[];
|
||||
onEnvelopeUpdated: (envelope: {
|
||||
id: string;
|
||||
name: string;
|
||||
allocated: number;
|
||||
spent: number;
|
||||
color: string;
|
||||
accountId: string;
|
||||
incomeAllocation?: number;
|
||||
incomeAllocationType?: 'percentage' | 'fixed';
|
||||
}) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const colorOptions = [
|
||||
{ value: 'bg-green-500', label: 'Green', color: '#10B981' },
|
||||
{ value: 'bg-blue-500', label: 'Blue', color: '#3B82F6' },
|
||||
{ value: 'bg-purple-500', label: 'Purple', color: '#8B5CF6' },
|
||||
{ value: 'bg-orange-500', label: 'Orange', color: '#F97316' },
|
||||
{ value: 'bg-red-500', label: 'Red', color: '#EF4444' },
|
||||
{ value: 'bg-yellow-500', label: 'Yellow', color: '#EAB308' },
|
||||
{ value: 'bg-pink-500', label: 'Pink', color: '#EC4899' },
|
||||
{ value: 'bg-indigo-500', label: 'Indigo', color: '#6366F1' },
|
||||
];
|
||||
|
||||
export default function EnvelopeEdit({ envelope, onEnvelopeUpdated, onCancel, accounts }: EnvelopeEditProps) {
|
||||
const [name, setName] = useState(envelope.name);
|
||||
const [allocated, setAllocated] = useState(envelope.allocated.toString());
|
||||
const [selectedColor, setSelectedColor] = useState(envelope.color);
|
||||
const [selectedAccount, setSelectedAccount] = useState(envelope.accountId);
|
||||
const [incomeAllocationType, setIncomeAllocationType] = useState<'percentage' | 'fixed'>(envelope.incomeAllocationType || 'percentage');
|
||||
const [incomeAllocation, setIncomeAllocation] = useState(envelope.incomeAllocation?.toString() || '');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const numAllocated = parseFloat(allocated);
|
||||
if (!name.trim() || isNaN(numAllocated) || numAllocated <= 0) {
|
||||
alert('Please enter a valid envelope name and positive allocated amount');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate income allocation
|
||||
if (incomeAllocation) {
|
||||
const allocationValue = parseFloat(incomeAllocation);
|
||||
if (isNaN(allocationValue) || allocationValue < 0) {
|
||||
alert('Please enter a valid income allocation amount');
|
||||
return;
|
||||
}
|
||||
if (incomeAllocationType === 'percentage' && allocationValue > 100) {
|
||||
alert('Percentage allocation cannot exceed 100%');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const updatedEnvelope = {
|
||||
...envelope,
|
||||
name: name.trim(),
|
||||
allocated: numAllocated,
|
||||
color: selectedColor,
|
||||
accountId: selectedAccount,
|
||||
incomeAllocation: incomeAllocation ? parseFloat(incomeAllocation) : undefined,
|
||||
incomeAllocationType: incomeAllocation ? incomeAllocationType : undefined,
|
||||
};
|
||||
|
||||
onEnvelopeUpdated(updatedEnvelope);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Edit Envelope</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Envelope Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Groceries, Transportation"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Allocated Amount
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={allocated}
|
||||
onChange={(e) => setAllocated(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="w-full pl-8 pr-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Color
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{colorOptions.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
onClick={() => setSelectedColor(color.value)}
|
||||
className={`w-12 h-12 rounded-full border-2 ${
|
||||
selectedColor === color.value ? 'border-gray-800' : 'border-gray-300'
|
||||
}`}
|
||||
style={{ backgroundColor: color.color }}
|
||||
title={color.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Selected: {colorOptions.find(c => c.value === selectedColor)?.label}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Account
|
||||
</label>
|
||||
<select
|
||||
value={selectedAccount}
|
||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
>
|
||||
{accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Income Allocation (Optional)
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex space-x-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="allocationType"
|
||||
value="percentage"
|
||||
checked={incomeAllocationType === 'percentage'}
|
||||
onChange={(e) => setIncomeAllocationType(e.target.value as 'percentage' | 'fixed')}
|
||||
className="mr-2"
|
||||
/>
|
||||
Percentage
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="allocationType"
|
||||
value="fixed"
|
||||
checked={incomeAllocationType === 'fixed'}
|
||||
onChange={(e) => setIncomeAllocationType(e.target.value as 'percentage' | 'fixed')}
|
||||
className="mr-2"
|
||||
/>
|
||||
Fixed Amount
|
||||
</label>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500">
|
||||
{incomeAllocationType === 'percentage' ? '%' : '$'}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
step={incomeAllocationType === 'percentage' ? '1' : '0.01'}
|
||||
min="0"
|
||||
max={incomeAllocationType === 'percentage' ? '100' : undefined}
|
||||
value={incomeAllocation}
|
||||
onChange={(e) => setIncomeAllocation(e.target.value)}
|
||||
placeholder={incomeAllocationType === 'percentage' ? 'e.g., 25' : 'e.g., 100.00'}
|
||||
className="w-full pl-8 pr-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{incomeAllocationType === 'percentage'
|
||||
? 'Percentage of income to allocate to this envelope automatically'
|
||||
: 'Fixed dollar amount to allocate to this envelope automatically'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
Update Envelope
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 bg-gray-300 text-gray-700 py-2 px-4 rounded-md hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface FileUploadProps {
|
||||
onTransactionsParsed: (transactions: ParsedTransaction[]) => void;
|
||||
accounts: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export interface ParsedTransaction {
|
||||
date: Date;
|
||||
amount: number;
|
||||
description: string;
|
||||
category?: string;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
export default function FileUpload({ onTransactionsParsed, accounts }: FileUploadProps) {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [fileType, setFileType] = useState<string>('csv');
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [selectedAccountId, setSelectedAccountId] = useState<string>('');
|
||||
|
||||
const supportedFormats = [
|
||||
{ value: 'csv', label: 'CSV (Comma Separated Values)', extensions: '.csv' },
|
||||
{ value: 'ofx', label: 'OFX (Open Financial Exchange)', extensions: '.ofx' },
|
||||
{ value: 'qfx', label: 'QFX (Quicken Financial Exchange)', extensions: '.qfx' },
|
||||
{ value: 'qbo', label: 'QBO (QuickBooks Online)', extensions: '.qbo' },
|
||||
{ value: 'pdf', label: 'PDF Statement', extensions: '.pdf' },
|
||||
];
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const parseCSV = (content: string): ParsedTransaction[] => {
|
||||
const lines = content.split('\n');
|
||||
const transactions: ParsedTransaction[] = [];
|
||||
|
||||
// Try to detect header row and column positions
|
||||
const firstLine = lines[0]?.trim();
|
||||
if (!firstLine) return transactions;
|
||||
|
||||
const headers = firstLine.split(',').map(h => h.trim().toLowerCase());
|
||||
const dateIndex = headers.findIndex(h => h.includes('date') || h.includes('dt'));
|
||||
const amountIndex = headers.findIndex(h => h.includes('amount') || h.includes('amt') || h.includes('value'));
|
||||
const descIndex = headers.findIndex(h => h.includes('description') || h.includes('desc') || h.includes('memo') || h.includes('reference'));
|
||||
const categoryIndex = headers.findIndex(h => h.includes('category') || h.includes('cat'));
|
||||
|
||||
// Skip header row
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
|
||||
const columns = line.split(',');
|
||||
if (columns.length >= 3) {
|
||||
const dateStr = columns[dateIndex]?.trim();
|
||||
const amountStr = columns[amountIndex]?.trim();
|
||||
const descStr = columns[descIndex]?.trim();
|
||||
const categoryStr = columns[categoryIndex]?.trim();
|
||||
|
||||
if (!dateStr || !amountStr || !descStr) continue;
|
||||
|
||||
// Try multiple date formats
|
||||
let date: Date | null = null;
|
||||
const dateFormats = [
|
||||
// MM/DD/YYYY
|
||||
() => {
|
||||
const parts = dateStr.split('/');
|
||||
if (parts.length === 3) {
|
||||
return new Date(parseInt(parts[2]), parseInt(parts[0]) - 1, parseInt(parts[1]));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
// YYYY-MM-DD
|
||||
() => new Date(dateStr + 'T00:00:00'),
|
||||
// MM-DD-YYYY
|
||||
() => {
|
||||
const parts = dateStr.split('-');
|
||||
if (parts.length === 3) {
|
||||
return new Date(parseInt(parts[2]), parseInt(parts[0]) - 1, parseInt(parts[1]));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
// DD/MM/YYYY
|
||||
() => {
|
||||
const parts = dateStr.split('/');
|
||||
if (parts.length === 3) {
|
||||
return new Date(parseInt(parts[2]), parseInt(parts[1]) - 1, parseInt(parts[0]));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
];
|
||||
|
||||
for (const formatFn of dateFormats) {
|
||||
date = formatFn();
|
||||
if (date && !isNaN(date.getTime())) break;
|
||||
}
|
||||
|
||||
const amount = parseFloat(amountStr.replace(/[$,]/g, ''));
|
||||
|
||||
if (date && !isNaN(date.getTime()) && !isNaN(amount)) {
|
||||
transactions.push({
|
||||
date,
|
||||
amount,
|
||||
description: descStr,
|
||||
category: categoryStr || undefined,
|
||||
accountId: selectedAccountId || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return transactions;
|
||||
};
|
||||
|
||||
const parseOFX = (content: string): ParsedTransaction[] => {
|
||||
// Basic OFX parsing - this would need to be more robust for production
|
||||
const transactions: ParsedTransaction[] = [];
|
||||
const transactionRegex = /<STMTTRN>[\s\S]*?<\/STMTTRN>/g;
|
||||
const matches = content.match(transactionRegex);
|
||||
|
||||
if (matches) {
|
||||
matches.forEach(match => {
|
||||
const dateMatch = match.match(/<DTPOSTED>(.*?)<\/DTPOSTED>/);
|
||||
const amountMatch = match.match(/<TRNAMT>(.*?)<\/TRNAMT>/);
|
||||
const descMatch = match.match(/<MEMO>(.*?)<\/MEMO>/) || match.match(/<NAME>(.*?)<\/NAME>/);
|
||||
|
||||
if (dateMatch && amountMatch && descMatch) {
|
||||
const dateStr = dateMatch[1].substring(0, 8); // YYYYMMDD format
|
||||
const date = new Date(
|
||||
parseInt(dateStr.substring(0, 4)),
|
||||
parseInt(dateStr.substring(4, 6)) - 1,
|
||||
parseInt(dateStr.substring(6, 8))
|
||||
);
|
||||
const amount = parseFloat(amountMatch[1]);
|
||||
const description = descMatch[1];
|
||||
|
||||
transactions.push({
|
||||
date,
|
||||
amount,
|
||||
description,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return transactions;
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
const content = await selectedFile.text();
|
||||
let transactions: ParsedTransaction[] = [];
|
||||
|
||||
switch (fileType) {
|
||||
case 'csv':
|
||||
transactions = parseCSV(content);
|
||||
break;
|
||||
case 'ofx':
|
||||
case 'qfx':
|
||||
transactions = parseOFX(content);
|
||||
break;
|
||||
case 'qbo':
|
||||
// QBO parsing would be similar to OFX
|
||||
transactions = parseOFX(content);
|
||||
break;
|
||||
case 'pdf':
|
||||
// PDF parsing would require a PDF parsing library
|
||||
alert('PDF parsing is not yet implemented. Please use CSV or OFX format.');
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
default:
|
||||
alert('Unsupported file format');
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onTransactionsParsed(transactions);
|
||||
setSelectedFile(null);
|
||||
// Reset file input
|
||||
const fileInput = document.getElementById('file-upload') as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = '';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error parsing file:', error);
|
||||
alert('Error parsing file. Please check the format and try again.');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Import Bank Transactions</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
File Format
|
||||
</label>
|
||||
<select
|
||||
value={fileType}
|
||||
onChange={(e) => setFileType(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{supportedFormats.map((format) => (
|
||||
<option key={format.value} value={format.value}>
|
||||
{format.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Select File
|
||||
</label>
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
accept={supportedFormats.find(f => f.value === fileType)?.extensions}
|
||||
onChange={handleFileChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
{selectedFile && (
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Selected: {selectedFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Import to 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"
|
||||
required
|
||||
>
|
||||
<option value="">Select an account...</option>
|
||||
{accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile || isProcessing || !selectedAccountId}
|
||||
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isProcessing ? 'Processing...' : 'Upload & Import'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-sm text-gray-600">
|
||||
<p className="font-medium mb-2">Supported formats:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li><strong>CSV:</strong> Date, Amount, Description columns</li>
|
||||
<li><strong>OFX/QFX:</strong> Standard bank export formats</li>
|
||||
<li><strong>QBO:</strong> QuickBooks Online format</li>
|
||||
<li><strong>PDF:</strong> Coming soon</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Account {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'checking' | 'savings' | 'credit_card' | 'mortgage' | 'investment' | 'loan';
|
||||
balance: number;
|
||||
institution?: string;
|
||||
accountNumber?: string;
|
||||
color: string;
|
||||
isActive: boolean;
|
||||
defaultPaycheckAmount?: number;
|
||||
}
|
||||
|
||||
interface Envelope {
|
||||
id: string;
|
||||
name: string;
|
||||
allocated: number;
|
||||
spent: number;
|
||||
color: string;
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
interface Transaction {
|
||||
id: string;
|
||||
envelopeId?: string;
|
||||
amount: number;
|
||||
description: string;
|
||||
date: Date;
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
interface GetPaidProps {
|
||||
accounts: Account[];
|
||||
envelopes: Envelope[];
|
||||
onIncomeAdded: (transactions: Transaction[]) => void;
|
||||
onAccountUpdate?: (account: Account) => void;
|
||||
}
|
||||
|
||||
export default function GetPaid({ accounts, envelopes, onIncomeAdded, onAccountUpdate }: GetPaidProps) {
|
||||
const [isOpen, setIsOpen] = 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 [showDefaultAmountField, setShowDefaultAmountField] = useState(false);
|
||||
const [defaultAmountInput, setDefaultAmountInput] = useState('');
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const accountEnvelopes = selectedAccountId ? envelopes.filter(env => env.accountId === selectedAccountId) : [];
|
||||
const totalAllocated = accountEnvelopes.reduce((sum, env) => sum + env.allocated, 0);
|
||||
|
||||
const calculateDistribution = () => {
|
||||
const paycheckAmount = parseFloat(amount);
|
||||
if (!paycheckAmount || accountEnvelopes.length === 0) return {};
|
||||
|
||||
const distribution: { [envelopeId: string]: number } = {};
|
||||
|
||||
if (distributionMethod === 'equal') {
|
||||
const equalAmount = paycheckAmount / accountEnvelopes.length;
|
||||
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;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const paycheckAmount = parseFloat(amount);
|
||||
if (!paycheckAmount || !selectedAccountId) {
|
||||
alert('Please enter a valid amount and select an account');
|
||||
return;
|
||||
}
|
||||
|
||||
const distribution = calculateDistribution();
|
||||
const transactions: Transaction[] = [];
|
||||
|
||||
// Add the main paycheck transaction to the account
|
||||
transactions.push({
|
||||
id: `paycheck-${Date.now()}`,
|
||||
amount: paycheckAmount,
|
||||
description: description || 'Paycheck',
|
||||
date: new Date(),
|
||||
accountId: selectedAccountId,
|
||||
});
|
||||
|
||||
// Add allocation transactions to envelopes
|
||||
Object.entries(distribution).forEach(([envelopeId, allocatedAmount]) => {
|
||||
if (allocatedAmount > 0) {
|
||||
transactions.push({
|
||||
id: `allocation-${envelopeId}-${Date.now()}`,
|
||||
envelopeId,
|
||||
amount: allocatedAmount,
|
||||
description: `Paycheck allocation - ${envelopes.find(e => e.id === envelopeId)?.name}`,
|
||||
date: new Date(),
|
||||
accountId: selectedAccountId,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onIncomeAdded(transactions);
|
||||
setIsOpen(false);
|
||||
setAmount('');
|
||||
setDescription('Paycheck');
|
||||
setCustomAllocations({});
|
||||
setShowDefaultAmountField(false);
|
||||
setDefaultAmountInput('');
|
||||
};
|
||||
|
||||
const handleSetDefaultAmount = () => {
|
||||
const defaultAmount = parseFloat(defaultAmountInput);
|
||||
if (!defaultAmount || defaultAmount <= 0) {
|
||||
alert('Please enter a valid default amount');
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedAccount = accounts.find(acc => acc.id === selectedAccountId);
|
||||
if (!selectedAccount) return;
|
||||
|
||||
const updatedAccount = {
|
||||
...selectedAccount,
|
||||
defaultPaycheckAmount: defaultAmount
|
||||
};
|
||||
|
||||
if (onAccountUpdate) {
|
||||
onAccountUpdate(updatedAccount);
|
||||
alert(`Default paycheck amount set to $${defaultAmount.toFixed(2)} for this account`);
|
||||
setShowDefaultAmountField(false);
|
||||
setDefaultAmountInput('');
|
||||
} else {
|
||||
alert('Unable to save default amount - account update not available');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseDefaultAmount = () => {
|
||||
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;
|
||||
|
||||
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>
|
||||
|
||||
{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>
|
||||
|
||||
<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>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
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"
|
||||
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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDefaultAmountField(!showDefaultAmountField)}
|
||||
className="mt-1 ml-2 text-sm text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
{showDefaultAmountField ? 'Cancel' : 'Set default amount'}
|
||||
</button>
|
||||
)}
|
||||
{showDefaultAmountField && (
|
||||
<div className="mt-2 flex space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 text-sm">$</span>
|
||||
<input
|
||||
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"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSetDefaultAmount}
|
||||
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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"
|
||||
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>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<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"
|
||||
placeholder="Paycheck description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Distribution Method */}
|
||||
{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">
|
||||
<input
|
||||
type="radio"
|
||||
value="proportional"
|
||||
checked={distributionMethod === 'proportional'}
|
||||
onChange={(e) => setDistributionMethod(e.target.value as 'proportional')}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm">Proportional to envelope allocations</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
value="equal"
|
||||
checked={distributionMethod === 'equal'}
|
||||
onChange={(e) => setDistributionMethod(e.target.value as 'equal')}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm">Equal distribution to all envelopes</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
value="custom"
|
||||
checked={distributionMethod === 'custom'}
|
||||
onChange={(e) => setDistributionMethod(e.target.value as 'custom')}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm">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;
|
||||
|
||||
return (
|
||||
<div key={envelope.id} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`w-3 h-3 rounded-full ${envelope.color}`}></div>
|
||||
<span className="text-sm text-gray-700">{envelope.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{distributionMethod === 'custom' ? (
|
||||
<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"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm font-medium">${allocatedAmount.toFixed(2)}</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-500">({percentage.toFixed(1)}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium">Total Distributed:</span>
|
||||
<span className={`font-medium ${Math.abs(remaining) < 0.01 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
${totalDistributed.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
{Math.abs(remaining) >= 0.01 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium">Remaining:</span>
|
||||
<span className={`font-medium ${remaining >= 0 ? 'text-orange-600' : 'text-red-600'}`}>
|
||||
${remaining.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Default Amount */}
|
||||
{selectedAccountId && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDefaultAmountField(!showDefaultAmountField)}
|
||||
className="text-sm text-blue-600 hover:underline mb-2"
|
||||
>
|
||||
{showDefaultAmountField ? 'Hide' : 'Set'} Default Paycheck Amount
|
||||
</button>
|
||||
|
||||
{showDefaultAmountField && (
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-4">
|
||||
<h4 className="font-medium text-gray-900 mb-3">Default Paycheck Amount</h4>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={defaultAmountInput}
|
||||
onChange={(e) => setDefaultAmountInput(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"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSetDefaultAmount}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Set Default
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUseDefaultAmount}
|
||||
className="w-full px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700"
|
||||
>
|
||||
Use Default Amount
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end space-x-3 pt-4 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface ManualEntryProps {
|
||||
onTransactionAdded: (transaction: {
|
||||
amount: number;
|
||||
description: string;
|
||||
date: Date;
|
||||
envelopeId?: string;
|
||||
accountId: string;
|
||||
}) => void;
|
||||
envelopes: { id: string; name: string }[];
|
||||
accounts: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export default function ManualEntry({ onTransactionAdded, envelopes, accounts }: ManualEntryProps) {
|
||||
const [amount, setAmount] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [selectedEnvelope, setSelectedEnvelope] = useState('');
|
||||
const [selectedAccount, setSelectedAccount] = useState(accounts?.[0]?.id || '');
|
||||
const [isExpense, setIsExpense] = useState(true);
|
||||
|
||||
// Update selected account when accounts change
|
||||
useEffect(() => {
|
||||
if (accounts && accounts.length > 0 && !selectedAccount) {
|
||||
setSelectedAccount(accounts[0].id);
|
||||
}
|
||||
}, [accounts, selectedAccount]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const numAmount = parseFloat(amount);
|
||||
if (isNaN(numAmount) || !description.trim()) {
|
||||
alert('Please enter a valid amount and description');
|
||||
return;
|
||||
}
|
||||
|
||||
const transactionAmount = isExpense ? -Math.abs(numAmount) : Math.abs(numAmount);
|
||||
|
||||
onTransactionAdded({
|
||||
amount: transactionAmount,
|
||||
description: description.trim(),
|
||||
date: new Date(date),
|
||||
envelopeId: selectedEnvelope || undefined,
|
||||
accountId: selectedAccount,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setAmount('');
|
||||
setDescription('');
|
||||
setDate(new Date().toISOString().split('T')[0]);
|
||||
setSelectedEnvelope('');
|
||||
setSelectedAccount(accounts?.[0]?.id || '');
|
||||
};
|
||||
|
||||
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">Manual Transaction Entry</h3>
|
||||
<p className="text-gray-600">Please add an account first to enter transactions.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">Manual Transaction Entry</h3>
|
||||
<p className="text-gray-600">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Manual Transaction Entry</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Amount
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Date
|
||||
</label>
|
||||
<input
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Description
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Assign to Envelope (Optional)
|
||||
</label>
|
||||
<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"
|
||||
>
|
||||
<option value="">Select an envelope...</option>
|
||||
{envelopes.map((envelope) => (
|
||||
<option key={envelope.id} value={envelope.id}>
|
||||
{envelope.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Assign to Account
|
||||
</label>
|
||||
<select
|
||||
value={selectedAccount}
|
||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
>
|
||||
<option value="">Select an account...</option>
|
||||
{accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
checked={isExpense}
|
||||
onChange={() => setIsExpense(true)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Expense</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
checked={!isExpense}
|
||||
onChange={() => setIsExpense(false)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Income</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-green-600 text-white py-2 px-4 rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
Add Transaction
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,816 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { DataService } from '../lib/dataService';
|
||||
|
||||
interface Account {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'checking' | 'savings' | 'credit_card' | 'mortgage' | 'investment' | 'loan';
|
||||
balance: number;
|
||||
institution?: string;
|
||||
accountNumber?: string;
|
||||
color: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface Envelope {
|
||||
id: string;
|
||||
name: string;
|
||||
allocated: number;
|
||||
spent: number;
|
||||
color: string;
|
||||
accountId: string;
|
||||
incomeAllocation?: number;
|
||||
incomeAllocationType?: 'percentage' | 'fixed';
|
||||
}
|
||||
|
||||
interface SetupWizardProps {
|
||||
onComplete: (accounts: Account[], envelopes: Envelope[]) => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
type WizardStep = 'welcome' | 'data-source' | 'accounts' | 'envelopes' | 'income-setup' | 'get-paid-setup' | 'complete';
|
||||
|
||||
export default function SetupWizard({ onComplete, onSkip }: SetupWizardProps) {
|
||||
const [currentStep, setCurrentStep] = useState<WizardStep>('welcome');
|
||||
const [dataSource, setDataSource] = useState<'import' | 'manual' | null>(null);
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [envelopes, setEnvelopes] = useState<Envelope[]>([]);
|
||||
|
||||
// Account creation state
|
||||
const [showAddAccount, setShowAddAccount] = useState(false);
|
||||
const [newAccount, setNewAccount] = useState<Partial<Account>>({
|
||||
type: 'checking',
|
||||
balance: 0,
|
||||
color: 'bg-blue-500',
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// Envelope creation state
|
||||
const [showAddEnvelope, setShowAddEnvelope] = useState(false);
|
||||
const [newEnvelope, setNewEnvelope] = useState<Partial<Envelope>>({
|
||||
allocated: 0,
|
||||
spent: 0,
|
||||
color: 'bg-green-500',
|
||||
});
|
||||
|
||||
// Income allocation state
|
||||
const [incomeAllocations, setIncomeAllocations] = useState<{ [envelopeId: string]: { type: 'percentage' | 'fixed'; value: number } }>({});
|
||||
const [defaultPaycheckAmounts, setDefaultPaycheckAmounts] = useState<{ [accountId: string]: number }>({});
|
||||
|
||||
const accountTypes = [
|
||||
{ value: 'checking', label: 'Checking Account', icon: '🏦' },
|
||||
{ value: 'savings', label: 'Savings Account', icon: '💰' },
|
||||
{ value: 'credit_card', label: 'Credit Card', icon: '💳' },
|
||||
{ value: 'mortgage', label: 'Mortgage', icon: '🏠' },
|
||||
{ value: 'investment', label: 'Investment', icon: '📈' },
|
||||
{ value: 'loan', label: 'Loan', icon: '📋' },
|
||||
];
|
||||
|
||||
const envelopeColors = [
|
||||
'bg-green-500', 'bg-blue-500', 'bg-purple-500', 'bg-orange-500',
|
||||
'bg-red-500', 'bg-yellow-500', 'bg-pink-500', 'bg-indigo-500'
|
||||
];
|
||||
|
||||
const handleAddAccount = () => {
|
||||
if (newAccount.name && newAccount.type) {
|
||||
const account: Account = {
|
||||
id: `${newAccount.type}-${Date.now()}`,
|
||||
name: newAccount.name,
|
||||
type: newAccount.type as Account['type'],
|
||||
balance: newAccount.balance || 0,
|
||||
institution: newAccount.institution,
|
||||
accountNumber: newAccount.accountNumber,
|
||||
color: newAccount.color || 'bg-blue-500',
|
||||
isActive: true,
|
||||
};
|
||||
setAccounts(prev => [...prev, account]);
|
||||
setNewAccount({
|
||||
type: 'checking',
|
||||
balance: 0,
|
||||
color: 'bg-blue-500',
|
||||
isActive: true,
|
||||
});
|
||||
setShowAddAccount(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddEnvelope = () => {
|
||||
if (newEnvelope.name && newEnvelope.accountId && newEnvelope.allocated !== undefined) {
|
||||
const envelope: Envelope = {
|
||||
id: `env-${Date.now()}`,
|
||||
name: newEnvelope.name,
|
||||
allocated: newEnvelope.allocated,
|
||||
spent: 0,
|
||||
color: newEnvelope.color || 'bg-green-500',
|
||||
accountId: newEnvelope.accountId,
|
||||
};
|
||||
setEnvelopes(prev => [...prev, envelope]);
|
||||
setNewEnvelope({
|
||||
allocated: 0,
|
||||
spent: 0,
|
||||
color: 'bg-green-500',
|
||||
});
|
||||
setShowAddEnvelope(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
try {
|
||||
// Apply income allocation settings to envelopes
|
||||
const envelopesWithAllocations = envelopes.map(envelope => {
|
||||
const allocation = incomeAllocations[envelope.id];
|
||||
if (allocation) {
|
||||
return {
|
||||
...envelope,
|
||||
incomeAllocation: allocation.value,
|
||||
incomeAllocationType: allocation.type,
|
||||
};
|
||||
}
|
||||
return envelope;
|
||||
});
|
||||
|
||||
// Apply default paycheck amounts to accounts
|
||||
const accountsWithDefaults = accounts.map(account => {
|
||||
const defaultAmount = defaultPaycheckAmounts[account.id];
|
||||
if (defaultAmount) {
|
||||
return {
|
||||
...account,
|
||||
defaultPaycheckAmount: defaultAmount,
|
||||
};
|
||||
}
|
||||
return account;
|
||||
});
|
||||
|
||||
await DataService.saveUserData('current-user-id', {
|
||||
accounts: accountsWithDefaults,
|
||||
envelopes: envelopesWithAllocations,
|
||||
transactions: [],
|
||||
setupCompleted: true,
|
||||
});
|
||||
onComplete(accountsWithDefaults, envelopesWithAllocations);
|
||||
} catch (error) {
|
||||
console.error('Error saving setup data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const renderWelcome = () => (
|
||||
<div className="text-center">
|
||||
<div className="mb-8">
|
||||
<div className="text-6xl mb-4">💰</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">Welcome to Envelope Budgeting</h1>
|
||||
<p className="text-gray-600 dark:text-gray-300">Let's set up your budget to get started with secure, organized financial tracking.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={() => setCurrentStep('data-source')}
|
||||
className="w-full bg-blue-600 text-white py-3 px-6 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Get Started
|
||||
</button>
|
||||
<button
|
||||
onClick={onSkip}
|
||||
className="w-full text-gray-600 hover:text-gray-800 py-2"
|
||||
>
|
||||
Skip Setup (Use Demo Data)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderDataSource = () => (
|
||||
<div className="text-center">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">How would you like to set up your data?</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300">Choose how you'd like to get started with your budget.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<button
|
||||
onClick={() => {
|
||||
setDataSource('import');
|
||||
setCurrentStep('accounts');
|
||||
}}
|
||||
className="p-6 border-2 border-gray-200 rounded-lg hover:border-blue-500 transition-colors"
|
||||
>
|
||||
<div className="text-4xl mb-3">📊</div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-2">Import Data</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">Upload CSV files from your bank or credit card statements</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setDataSource('manual');
|
||||
setCurrentStep('accounts');
|
||||
}}
|
||||
className="p-6 border-2 border-gray-200 rounded-lg hover:border-blue-500 transition-colors"
|
||||
>
|
||||
<div className="text-4xl mb-3">✏️</div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-2">Manual Setup</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">Add your accounts and envelopes manually</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentStep('welcome')}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderAccounts = () => (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">Set Up Your Accounts</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300">Add your bank accounts, credit cards, and other financial accounts.</p>
|
||||
</div>
|
||||
|
||||
{/* Existing Accounts */}
|
||||
<div className="mb-6">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white 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>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{accounts.map((account) => (
|
||||
<div key={account.id} className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<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="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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Account Button */}
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => setShowAddAccount(true)}
|
||||
className="w-full bg-green-600 text-white py-3 px-4 rounded-lg hover:bg-green-700 transition-colors flex items-center justify-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 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
<span>Add Account</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={() => setCurrentStep('data-source')}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentStep('envelopes')}
|
||||
disabled={accounts.length === 0}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next: Create Envelopes →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
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>
|
||||
<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"
|
||||
>
|
||||
{accountTypes.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.icon} {type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
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>
|
||||
<div className="flex space-x-2">
|
||||
{envelopeColors.slice(0, 6).map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
onClick={() => setNewAccount(prev => ({ ...prev, color }))}
|
||||
className={`w-8 h-8 rounded-full ${color} ${newAccount.color === color ? 'ring-2 ring-gray-400' : ''}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<button
|
||||
onClick={() => setShowAddAccount(false)}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddAccount}
|
||||
disabled={!newAccount.name}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Add Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderEnvelopes = () => (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">Create Your Envelopes</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300">Set up budget categories for different spending areas.</p>
|
||||
</div>
|
||||
|
||||
{/* Existing Envelopes */}
|
||||
<div className="mb-6">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white 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>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{envelopes.map((envelope) => {
|
||||
const account = accounts.find(acc => acc.id === envelope.accountId);
|
||||
return (
|
||||
<div key={envelope.id} className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<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="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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Envelope Button */}
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => setShowAddEnvelope(true)}
|
||||
className="w-full bg-green-600 text-white py-3 px-4 rounded-lg hover:bg-green-700 transition-colors flex items-center justify-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 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
<span>Add Envelope</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={() => setCurrentStep('accounts')}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentStep('income-setup')}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Next: Set Up Income →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
placeholder="e.g., Groceries"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
>
|
||||
<option value="">Select an account</option>
|
||||
{accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.name} ({account.type.replace('_', ' ')})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Color</label>
|
||||
<div className="flex space-x-2">
|
||||
{envelopeColors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
onClick={() => setNewEnvelope(prev => ({ ...prev, color }))}
|
||||
className={`w-8 h-8 rounded-full ${color} ${newEnvelope.color === color ? 'ring-2 ring-gray-400' : ''}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<button
|
||||
onClick={() => setShowAddEnvelope(false)}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddEnvelope}
|
||||
disabled={!newEnvelope.name || !newEnvelope.accountId}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Add Envelope
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderIncomeSetup = () => (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">Income Allocation Setup</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300">Set up how your income is automatically allocated to envelopes.</p>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
Configure how much of your income goes to each envelope automatically when you get paid.
|
||||
You can set percentages or fixed amounts.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{envelopes.map((envelope) => {
|
||||
const account = accounts.find(acc => acc.id === envelope.accountId);
|
||||
const allocation = incomeAllocations[envelope.id];
|
||||
|
||||
return (
|
||||
<div key={envelope.id} className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<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">{envelope.name}</p>
|
||||
<p className="text-sm text-gray-500">{account?.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">${envelope.allocated.toFixed(2)} allocated</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<select
|
||||
value={allocation?.type || 'percentage'}
|
||||
onChange={(e) => setIncomeAllocations(prev => ({
|
||||
...prev,
|
||||
[envelope.id]: {
|
||||
type: e.target.value as 'percentage' | 'fixed',
|
||||
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"
|
||||
>
|
||||
<option value="percentage">Percentage</option>
|
||||
<option value="fixed">Fixed Amount</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{allocation?.type === 'fixed' ? 'Amount ($)' : 'Percentage (%)'}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step={allocation?.type === 'fixed' ? '0.01' : '1'}
|
||||
min="0"
|
||||
max={allocation?.type === 'percentage' ? '100' : undefined}
|
||||
value={allocation?.value || ''}
|
||||
onChange={(e) => setIncomeAllocations(prev => ({
|
||||
...prev,
|
||||
[envelope.id]: {
|
||||
type: allocation?.type || 'percentage',
|
||||
value: parseFloat(e.target.value) || 0
|
||||
}
|
||||
}))}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={() => setIncomeAllocations(prev => {
|
||||
const newAllocations = { ...prev };
|
||||
delete newAllocations[envelope.id];
|
||||
return newAllocations;
|
||||
})}
|
||||
className="px-3 py-2 text-sm text-red-600 hover:text-red-800 border border-red-300 rounded-md hover:bg-red-50"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={() => setCurrentStep('envelopes')}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentStep('get-paid-setup')}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Next: Get Paid Setup →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderGetPaidSetup = () => (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">Get Paid Setup</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300">Set up your default paycheck amounts for quick income entry.</p>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
Set default amounts for each account to speed up paycheck entry. You can always change these later.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{accounts.filter(acc => acc.type !== 'credit_card' && acc.type !== 'mortgage' && acc.type !== 'loan').map((account) => {
|
||||
const defaultAmount = defaultPaycheckAmounts[account.id];
|
||||
|
||||
return (
|
||||
<div key={account.id} className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<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">{account.name}</p>
|
||||
<p className="text-sm text-gray-500">{account.type.replace('_', ' ')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={defaultAmount || ''}
|
||||
onChange={(e) => setDefaultPaycheckAmounts(prev => ({
|
||||
...prev,
|
||||
[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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={() => setDefaultPaycheckAmounts(prev => {
|
||||
const newDefaults = { ...prev };
|
||||
delete newDefaults[account.id];
|
||||
return newDefaults;
|
||||
})}
|
||||
className="px-3 py-2 text-sm text-red-600 hover:text-red-800 border border-red-300 rounded-md hover:bg-red-50"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={() => setCurrentStep('income-setup')}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentStep('complete')}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Complete Setup →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderComplete = () => (
|
||||
<div className="text-center">
|
||||
<div className="mb-8">
|
||||
<div className="text-6xl mb-4">🎉</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">Setup Complete!</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300">Your envelope budgeting system is ready to use.</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-6 mb-8">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Your Setup Summary</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-left">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Accounts</p>
|
||||
<p className="font-semibold text-gray-900">{accounts.length}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Envelopes</p>
|
||||
<p className="font-semibold text-gray-900">{envelopes.length}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Total Balance</p>
|
||||
<p className="font-semibold text-gray-900">
|
||||
${accounts.reduce((sum, acc) => sum + acc.balance, 0).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Monthly Budget</p>
|
||||
<p className="font-semibold text-gray-900">
|
||||
${envelopes.reduce((sum, env) => sum + env.allocated, 0).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={handleComplete}
|
||||
className="w-full bg-blue-600 text-white py-3 px-6 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Start Budgeting!
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentStep('envelopes')}
|
||||
className="w-full text-gray-600 hover:text-gray-800 py-2"
|
||||
>
|
||||
← Add More Envelopes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-8 max-w-2xl w-full">
|
||||
{/* Progress Indicator */}
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
{['welcome', 'data-source', 'accounts', 'envelopes', 'income-setup', 'get-paid-setup', 'complete'].map((step, index) => (
|
||||
<div key={step} className="flex items-center">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
['welcome', 'data-source', 'accounts', 'envelopes', 'income-setup', 'get-paid-setup', 'complete'].indexOf(currentStep) >= index
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-600'
|
||||
}`}>
|
||||
{index + 1}
|
||||
</div>
|
||||
{index < 6 && (
|
||||
<div className={`w-12 h-1 mx-2 ${
|
||||
['welcome', 'data-source', 'accounts', 'envelopes', 'income-setup', 'get-paid-setup', 'complete'].indexOf(currentStep) > index
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-200'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
{currentStep === 'welcome' && 'Welcome'}
|
||||
{currentStep === 'data-source' && 'Choose Setup Method'}
|
||||
{currentStep === 'accounts' && 'Add Your Accounts'}
|
||||
{currentStep === 'envelopes' && 'Create Budget Envelopes'}
|
||||
{currentStep === 'income-setup' && 'Configure Income Allocation'}
|
||||
{currentStep === 'get-paid-setup' && 'Set Up Get Paid'}
|
||||
{currentStep === 'complete' && 'Setup Complete'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
{currentStep === 'welcome' && renderWelcome()}
|
||||
{currentStep === 'data-source' && renderDataSource()}
|
||||
{currentStep === 'accounts' && renderAccounts()}
|
||||
{currentStep === 'envelopes' && renderEnvelopes()}
|
||||
{currentStep === 'income-setup' && renderIncomeSetup()}
|
||||
{currentStep === 'get-paid-setup' && renderGetPaidSetup()}
|
||||
{currentStep === 'complete' && renderComplete()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Transaction {
|
||||
id: string;
|
||||
envelopeId?: string;
|
||||
amount: number;
|
||||
description: string;
|
||||
date: Date;
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
interface TransactionEditProps {
|
||||
transaction: Transaction;
|
||||
envelopes: { id: string; name: string }[];
|
||||
accounts: { id: string; name: string }[];
|
||||
onSave: (updatedTransaction: Transaction) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function TransactionEdit({ transaction, envelopes, accounts, onSave, onCancel }: TransactionEditProps) {
|
||||
const [amount, setAmount] = useState(Math.abs(transaction.amount).toString());
|
||||
const [description, setDescription] = useState(transaction.description);
|
||||
const [date, setDate] = useState(transaction.date.toISOString().split('T')[0]);
|
||||
const [selectedEnvelope, setSelectedEnvelope] = useState(transaction.envelopeId || '');
|
||||
const [selectedAccount, setSelectedAccount] = useState(transaction.accountId);
|
||||
const [isExpense, setIsExpense] = useState(transaction.amount < 0);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const numAmount = parseFloat(amount);
|
||||
if (isNaN(numAmount) || !description.trim()) {
|
||||
alert('Please enter a valid amount and description');
|
||||
return;
|
||||
}
|
||||
|
||||
const transactionAmount = isExpense ? -Math.abs(numAmount) : Math.abs(numAmount);
|
||||
|
||||
onSave({
|
||||
...transaction,
|
||||
amount: transactionAmount,
|
||||
description: description.trim(),
|
||||
date: new Date(date),
|
||||
envelopeId: selectedEnvelope || undefined,
|
||||
accountId: selectedAccount,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Edit Transaction</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Amount
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Date
|
||||
</label>
|
||||
<input
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Description
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Assign to Envelope
|
||||
</label>
|
||||
<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"
|
||||
>
|
||||
<option value="">No envelope</option>
|
||||
{envelopes.map((envelope) => (
|
||||
<option key={envelope.id} value={envelope.id}>
|
||||
{envelope.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Assign to Account
|
||||
</label>
|
||||
<select
|
||||
value={selectedAccount}
|
||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
checked={isExpense}
|
||||
onChange={() => setIsExpense(true)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Expense</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
checked={!isExpense}
|
||||
onChange={() => setIsExpense(false)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Income</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 bg-gray-300 text-gray-700 py-2 px-4 rounded-md hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Simple encryption utility using Web Crypto API
|
||||
// Note: In production, consider using a more robust encryption library
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
export async function encryptData(data: string, key: string): Promise<string> {
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(key),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits', 'deriveKey']
|
||||
);
|
||||
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const derivedKey = await crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: salt,
|
||||
iterations: 100000,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
keyMaterial,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt']
|
||||
);
|
||||
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: iv },
|
||||
derivedKey,
|
||||
encoder.encode(data)
|
||||
);
|
||||
|
||||
// Combine salt, iv, and encrypted data
|
||||
const combined = new Uint8Array(salt.length + iv.length + encrypted.byteLength);
|
||||
combined.set(salt, 0);
|
||||
combined.set(iv, salt.length);
|
||||
combined.set(new Uint8Array(encrypted), salt.length + iv.length);
|
||||
|
||||
return btoa(String.fromCharCode(...combined));
|
||||
}
|
||||
|
||||
export async function decryptData(encryptedData: string, key: string): Promise<string> {
|
||||
const combined = new Uint8Array(atob(encryptedData).split('').map(c => c.charCodeAt(0)));
|
||||
|
||||
const salt = combined.slice(0, 16);
|
||||
const iv = combined.slice(16, 28);
|
||||
const encrypted = combined.slice(28);
|
||||
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(key),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits', 'deriveKey']
|
||||
);
|
||||
|
||||
const derivedKey = await crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: salt,
|
||||
iterations: 100000,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
keyMaterial,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: iv },
|
||||
derivedKey,
|
||||
encrypted
|
||||
);
|
||||
|
||||
return decoder.decode(decrypted);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { doc, getDoc, setDoc, updateDoc } from 'firebase/firestore';
|
||||
import { db } from './firebase';
|
||||
import { encryptData, decryptData } from './crypto';
|
||||
|
||||
interface Envelope {
|
||||
id: string;
|
||||
name: string;
|
||||
allocated: number;
|
||||
spent: number;
|
||||
color: string;
|
||||
accountId: string; // Link envelope to specific account
|
||||
incomeAllocation?: number; // Percentage (0-100) or fixed amount if negative
|
||||
incomeAllocationType?: 'percentage' | 'fixed'; // How to interpret incomeAllocation
|
||||
}
|
||||
|
||||
interface Transaction {
|
||||
id: string;
|
||||
envelopeId?: string;
|
||||
accountId: string; // Link transaction to specific account
|
||||
amount: number;
|
||||
description: string;
|
||||
date: Date;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
interface Account {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'checking' | 'savings' | 'credit_card' | 'mortgage' | 'investment' | 'loan';
|
||||
balance: number;
|
||||
institution?: string;
|
||||
accountNumber?: string;
|
||||
color: string;
|
||||
isActive: boolean;
|
||||
defaultPaycheckAmount?: number;
|
||||
}
|
||||
|
||||
interface UserData {
|
||||
accounts: Account[];
|
||||
envelopes: Envelope[];
|
||||
transactions: Transaction[];
|
||||
setupCompleted: boolean;
|
||||
}
|
||||
|
||||
const ENCRYPTION_KEY = process.env.NEXT_PUBLIC_ENCRYPTION_KEY || 'default-key-change-in-production';
|
||||
|
||||
export class DataService {
|
||||
private static async encryptUserData(data: UserData): Promise<string> {
|
||||
return await encryptData(JSON.stringify(data), ENCRYPTION_KEY);
|
||||
}
|
||||
|
||||
private static async decryptUserData(encryptedData: string): Promise<UserData> {
|
||||
const decrypted = await decryptData(encryptedData, ENCRYPTION_KEY);
|
||||
return JSON.parse(decrypted);
|
||||
}
|
||||
|
||||
static async saveUserData(userId: string, data: UserData): Promise<void> {
|
||||
try {
|
||||
const encryptedData = await this.encryptUserData(data);
|
||||
const userDocRef = doc(db, 'users', userId);
|
||||
await setDoc(userDocRef, {
|
||||
data: encryptedData,
|
||||
lastUpdated: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error saving user data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async loadUserData(userId: string): Promise<UserData | null> {
|
||||
try {
|
||||
const userDocRef = doc(db, 'users', userId);
|
||||
const userDoc = await getDoc(userDocRef);
|
||||
|
||||
if (userDoc.exists()) {
|
||||
const encryptedData = userDoc.data()?.data;
|
||||
if (encryptedData) {
|
||||
return await this.decryptUserData(encryptedData);
|
||||
}
|
||||
}
|
||||
|
||||
// Return default data if no data exists
|
||||
return {
|
||||
accounts: [
|
||||
{
|
||||
id: 'checking-1',
|
||||
name: 'Main Checking',
|
||||
type: 'checking' as const,
|
||||
balance: 2500.00,
|
||||
institution: 'Bank of America',
|
||||
color: 'bg-blue-500',
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 'savings-1',
|
||||
name: 'Emergency Savings',
|
||||
type: 'savings' as const,
|
||||
balance: 5000.00,
|
||||
institution: 'Bank of America',
|
||||
color: 'bg-green-500',
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
envelopes: [
|
||||
{ id: '1', name: 'Groceries', allocated: 500, spent: 0, color: 'bg-green-500', accountId: 'checking-1' },
|
||||
{ id: '2', name: 'Transportation', allocated: 300, spent: 0, color: 'bg-blue-500', accountId: 'checking-1' },
|
||||
{ id: '3', name: 'Entertainment', allocated: 200, spent: 0, color: 'bg-purple-500', accountId: 'checking-1' },
|
||||
{ id: '4', name: 'Utilities', allocated: 250, spent: 0, color: 'bg-orange-500', accountId: 'checking-1' },
|
||||
],
|
||||
transactions: [],
|
||||
setupCompleted: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error loading user data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async updateUserData(userId: string, updates: Partial<UserData>): Promise<void> {
|
||||
try {
|
||||
const currentData = await this.loadUserData(userId);
|
||||
if (currentData) {
|
||||
const updatedData = { ...currentData, ...updates };
|
||||
await this.saveUserData(userId, updatedData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating user data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { getAuth } from 'firebase/auth';
|
||||
import { getFirestore } from 'firebase/firestore';
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
|
||||
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
|
||||
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
|
||||
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
|
||||
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
|
||||
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
|
||||
};
|
||||
|
||||
// Initialize Firebase
|
||||
const app = initializeApp(firebaseConfig);
|
||||
|
||||
// Initialize Firebase services
|
||||
export const auth = getAuth(app);
|
||||
export const db = getFirestore(app);
|
||||
|
||||
export default app;
|
||||
Reference in new issue
Block a user