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