the start

This commit is contained in:
nmemmert committed 2025-10-24 10:23:12 -04:00
1 parent e41e78f9a3
commit a79dddff60
22 files changed
+4856 -99

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
# Envelope Budgeting App - Project Setup
- [x] Verify that the copilot-instructions.md file in the .github directory is created.
- [x] Clarify Project Requirements: Envelope budgeting app with React/Next.js
- [x] Scaffold the Project: Next.js project created with TypeScript, Tailwind CSS, ESLint, and App Router
- [x] Customize the Project: Created envelope budgeting dashboard with React components, state management, and Tailwind styling
- [x] Install Required Extensions: No extensions specified in project setup info
- [x] Compile the Project: Project compiled successfully without errors
- [x] Create and Run Task: Standard Next.js scripts available, no custom task needed
- [x] Launch the Project: Development server started successfully
- [x] Ensure Documentation is Complete: README.md updated with project information and copilot-instructions.md cleaned up
## Development Guidelines
- Work through each checklist item systematically.
- Keep communication concise and focused.
- Follow development best practices.
+109 -20
View File
@@ -1,36 +1,125 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# Envelope Budgeting App
A modern web-based envelope budgeting application built with Next.js, TypeScript, and Tailwind CSS. This app helps you manage your finances using the traditional envelope budgeting method in a digital format.
## Features
- **Envelope Management**: Create and manage budget envelopes for different spending categories
- **Visual Progress Tracking**: See spending progress with color-coded progress bars
- **Transaction History**: Track and view recent transactions
- **Data Input Options**:
- **Manual Entry**: Add individual transactions with amount, description, date, and envelope assignment
- **File Import**: Import transactions from bank exports (CSV, OFX, QFX, QBO formats)
- **Transaction Editing**: Edit existing transactions with full details
- **Envelope Creation**: Add new budget envelopes with custom colors and allocations
- **Data Export**: Export all data to CSV or JSON formats for backup or analysis
- **Budget Overview**: Monitor total allocated, spent, and remaining amounts
- **Responsive Design**: Works seamlessly on desktop and mobile devices
## Getting Started
First, run the development server:
### Prerequisites
- Node.js 18+ and npm
### Installation
1. Clone the repository:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
git clone <repository-url>
cd budget
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
2. Install dependencies:
```bash
npm install
```
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
3. Run the development server:
```bash
npm run dev
```
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
4. Open [http://localhost:7654](http://localhost:7654) in your browser.
## Learn More
## Available Scripts
To learn more about Next.js, take a look at the following resources:
- `npm run dev` - Start the development server
- `npm run build` - Build the project for production
- `npm run start` - Start the production server
- `npm run lint` - Run ESLint for code quality checks
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
## Project Structure
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
```
src/
├── app/
│ ├── layout.tsx # Root layout component
│ ├── page.tsx # Main budgeting dashboard
│ └── globals.css # Global styles
public/ # Static assets
```
## Deploy on Vercel
## Technologies Used
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
- **Next.js 16** - React framework with App Router
- **TypeScript** - Type-safe JavaScript
- **Tailwind CSS** - Utility-first CSS framework
- **React 19** - UI library
- **ESLint** - Code linting
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
## How to Use
1. **View Dashboard**: The main page shows your budget overview and envelope cards
2. **Add Transactions**:
- **Manual Entry**: Click "Manual Entry" and fill in transaction details
- **File Import**: Click "Import from File", select your bank export format, and upload the file
3. **Monitor Envelopes**: Each envelope displays allocated amount, spent amount, and remaining balance
4. **Track Progress**: Visual progress bars show spending percentage for each envelope
5. **View Transactions**: Recent transactions are listed with envelope associations
## Data Import
### Supported File Formats
- **CSV**: Comma-separated values with columns: Date, Amount, Description, Category (optional)
- **OFX/QFX**: Standard bank export formats from most financial institutions
- **QBO**: QuickBooks Online format
- **PDF**: Coming soon (statement parsing)
### CSV Format Example
```csv
Date,Amount,Description,Category
2024-01-15,-25.50,Weekly groceries,Groceries
2024-01-16,-12.00,Gas station,Transportation
2024-01-17,1500.00,Salary deposit,Income
```
## Advanced Features
### Transaction Editing
- Click the edit icon (pencil) next to any transaction to modify its details
- Update amount, description, date, and envelope assignment
- Changes automatically update envelope spending calculations
### Envelope Management
- Click "Add Envelope" to create new budget categories
- Choose from multiple colors for visual organization
- Set custom budget allocations for each envelope
### Data Export
- Export all transactions and envelopes to CSV for spreadsheet analysis
- Export to JSON format for data backup or integration
- Includes complete transaction history and envelope details
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Run tests and linting
5. Submit a pull request
## License
This project is licensed under the MIT License.
+14
View File
@@ -0,0 +1,14 @@
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Allow authenticated users to read and write their own data
match /users/{userId} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
// Allow public read/write for development (less secure)
match /{document=**} {
allow read, write: if true;
}
}
}
+984 -6
View File
File diff suppressed because it is too large. Load diff
+9 -8
View File
@@ -3,25 +3,26 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "next dev -p 7654",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"firebase": "^12.4.0",
"next": "16.0.0",
"react": "19.2.0",
"react-dom": "19.2.0",
"next": "16.0.0"
"react-dom": "19.2.0"
},
"devDependencies": {
"babel-plugin-react-compiler": "1.0.0",
"typescript": "^5",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "^9",
"eslint-config-next": "16.0.0"
"eslint-config-next": "16.0.0",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+2 -2
View File
@@ -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
View File
@@ -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>
);
}
+320
View File
@@ -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>
);
}
+131
View File
@@ -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>
);
}
+158
View File
@@ -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>
);
}
+106
View File
@@ -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>
);
}
+235
View File
@@ -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>
);
}
+236
View File
@@ -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>
);
}
+279
View File
@@ -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>
);
}
+446
View File
@@ -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>
)}
</>
);
}
+196
View File
@@ -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>
);
}
+816
View File
@@ -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>
);
}
+177
View File
@@ -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>
);
}
+81
View File
@@ -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);
}
+132
View File
@@ -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;
}
}
}
+21
View File
@@ -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;
+14 -4
View File
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -19,7 +23,9 @@
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
}
},
"include": [
@@ -28,7 +34,11 @@
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
"**/*.mts",
".next\\dev/types/**/*.ts",
".next\\dev/types/**/*.ts"
],
"exclude": ["node_modules"]
"exclude": [
"node_modules"
]
}