the start

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

No files matched your search

+2 -2
View File
@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Envelope Budgeting App",
description: "A modern envelope budgeting application to help you manage your finances effectively",
};
export default function RootLayout({
+373 -59
View File
@@ -1,65 +1,379 @@
import Image from "next/image";
'use client';
import { useState, useEffect } from 'react';
import { onAuthStateChanged, signOut, User } from 'firebase/auth';
import { auth } from '../lib/firebase';
import { DataService } from '../lib/dataService';
import DataInput from '../components/DataInput';
import TransactionEdit from '../components/TransactionEdit';
import EnvelopeCreate from '../components/EnvelopeCreate';
import EnvelopeEdit from '../components/EnvelopeEdit';
import DataExport from '../components/DataExport';
import AuthModal from '../components/AuthModal';
import SetupWizard from '../components/SetupWizard';
import AccountManagement from '../components/AccountManagement';
import GetPaid from '../components/GetPaid';
interface Account {
id: string;
name: string;
type: 'checking' | 'savings' | 'credit_card' | 'mortgage' | 'investment' | 'loan';
balance: number;
institution?: string;
accountNumber?: string;
color: string;
isActive: boolean;
defaultPaycheckAmount?: number;
}
interface Envelope {
id: string;
name: string;
allocated: number;
spent: number;
color: string;
accountId: string;
incomeAllocation?: number; // Percentage (0-100) or fixed amount if negative
incomeAllocationType?: 'percentage' | 'fixed'; // How to interpret incomeAllocation
}
interface Transaction {
id: string;
envelopeId?: string;
amount: number;
description: string;
date: Date;
accountId: string;
}
type ViewMode = 'dashboard' | 'accounts' | 'transactions' | 'envelopes' | 'settings';
export default function BudgetDashboard() {
const [currentView, setCurrentView] = useState<ViewMode>('dashboard');
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [showAuthModal, setShowAuthModal] = useState(false);
const [showSetupWizard, setShowSetupWizard] = useState(false);
const [accounts, setAccounts] = useState<Account[]>([]);
const [envelopes, setEnvelopes] = useState<Envelope[]>([]);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [setupCompleted, setSetupCompleted] = useState(false);
// Modal states
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
const [showCreateEnvelope, setShowCreateEnvelope] = useState(false);
const [editingEnvelope, setEditingEnvelope] = useState<Envelope | null>(null);
// Initialize Firebase auth listener
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, async (user) => {
setUser(user);
if (user) {
// Load user data from Firestore
try {
const userData = await DataService.loadUserData(user.uid);
if (userData) {
setAccounts(userData.accounts || []);
setEnvelopes(userData.envelopes || []);
setTransactions(userData.transactions || []);
setSetupCompleted(userData.setupCompleted || false);
setShowSetupWizard(!(userData.setupCompleted || false));
} else {
// New user - show setup wizard
setShowSetupWizard(true);
}
} catch (error) {
console.error('Error loading user data:', error);
// On error, assume new user and show setup wizard
setShowSetupWizard(true);
}
} else {
// Clear data when user logs out
setAccounts([]);
setEnvelopes([]);
setTransactions([]);
setSetupCompleted(false);
setShowSetupWizard(false);
}
setLoading(false);
});
return () => unsubscribe();
}, []);
// Save data to Firestore whenever data changes
useEffect(() => {
if (user && (accounts.length > 0 || envelopes.length > 0 || transactions.length > 0 || setupCompleted)) {
const saveData = async () => {
try {
await DataService.saveUserData(user.uid, { accounts, envelopes, transactions, setupCompleted });
} catch (error) {
console.error('Error saving user data:', error);
}
};
saveData();
}
}, [user, accounts, envelopes, transactions, setupCompleted]);
const handleTransactionsAdded = (newTransactions: Transaction[]) => {
const allTransactions = [...newTransactions];
// Process income transactions - automatically allocate to envelopes
newTransactions.forEach(transaction => {
if (transaction.amount > 0 && !transaction.envelopeId) {
// This is income - allocate to envelopes based on custom settings or proportionally
const accountEnvelopes = envelopes.filter(env => env.accountId === transaction.accountId);
const envelopesWithAllocation = accountEnvelopes.filter(env => env.incomeAllocation && env.incomeAllocationType);
if (envelopesWithAllocation.length > 0) {
// Validate allocations before processing
const validation = validateIncomeAllocation(envelopesWithAllocation, transaction.amount);
if (!validation.isValid) {
console.warn('Invalid income allocation detected, falling back to proportional');
// Fall back to proportional allocation if validation fails
const totalAllocated = accountEnvelopes.reduce((sum, env) => sum + env.allocated, 0);
if (accountEnvelopes.length > 0 && totalAllocated > 0) {
accountEnvelopes.forEach(envelope => {
const proportion = envelope.allocated / totalAllocated;
const allocatedAmount = transaction.amount * proportion;
if (allocatedAmount > 0) {
allTransactions.push({
id: `allocation-${envelope.id}-${Date.now()}-${Math.random()}`,
envelopeId: envelope.id,
amount: allocatedAmount,
description: `Income allocation - ${envelope.name}`,
date: transaction.date,
accountId: transaction.accountId,
});
}
});
}
} else {
// Use custom allocation settings
envelopesWithAllocation.forEach(envelope => {
let allocatedAmount = 0;
if (envelope.incomeAllocationType === 'percentage') {
allocatedAmount = transaction.amount * (envelope.incomeAllocation! / 100);
} else if (envelope.incomeAllocationType === 'fixed') {
allocatedAmount = Math.min(envelope.incomeAllocation!, transaction.amount);
}
if (allocatedAmount > 0) {
allTransactions.push({
id: `allocation-${envelope.id}-${Date.now()}-${Math.random()}`,
envelopeId: envelope.id,
amount: allocatedAmount,
description: `Income allocation - ${envelope.name}`,
date: transaction.date,
accountId: transaction.accountId,
});
}
});
}
} else {
// No specific envelope allocation found, fall back to proportional allocation
const totalAllocated = accountEnvelopes.reduce((sum, env) => sum + env.allocated, 0);
if (accountEnvelopes.length > 0 && totalAllocated > 0) {
accountEnvelopes.forEach(envelope => {
const proportion = envelope.allocated / totalAllocated;
const allocatedAmount = transaction.amount * proportion;
if (allocatedAmount > 0) {
allTransactions.push({
id: `allocation-${envelope.id}-${Date.now()}-${Math.random()}`,
envelopeId: envelope.id,
amount: allocatedAmount,
description: `Income allocation - ${envelope.name}`,
date: transaction.date,
accountId: transaction.accountId,
});
}
});
}
}
}
});
// Add all processed transactions at once
setTransactions(prevTransactions => [...prevTransactions, ...allTransactions]);
};
const validateIncomeAllocation = (envelopes: Envelope[], totalAmount: number) => {
let fixedTotal = 0;
let percentageTotal = 0;
let hasFixed = false;
let hasPercentage = false;
envelopes.forEach(envelope => {
if (envelope.incomeAllocationType === 'fixed') {
fixedTotal += envelope.incomeAllocation!;
hasFixed = true;
} else if (envelope.incomeAllocationType === 'percentage') {
percentageTotal += envelope.incomeAllocation!;
hasPercentage = true;
}
});
// Check for conflicting allocation types
if (hasFixed && hasPercentage) {
return { isValid: false, message: 'Conflicting allocation types (fixed and percentage) detected.' };
}
// Validate fixed allocations
if (hasFixed && fixedTotal !== totalAmount) {
return { isValid: false, message: 'Fixed allocations do not sum up to the total amount.' };
}
// Validate percentage allocations
if (hasPercentage && (percentageTotal < 0 || percentageTotal > 100)) {
return { isValid: false, message: 'Percentage allocations must be between 0 and 100.' };
}
return { isValid: true };
};
const handleSignOut = async () => {
setLoading(true);
try {
await signOut(auth);
} catch (error) {
console.error('Error signing out:', error);
}
setLoading(false);
};
const handleAuthSuccess = () => {
setShowAuthModal(false);
};
const handleSetupComplete = (setupData: {
accounts: Account[];
envelopes: Envelope[];
transactions: Transaction[];
}) => {
setAccounts(setupData.accounts);
setEnvelopes(setupData.envelopes);
setTransactions(setupData.transactions);
setSetupCompleted(true);
setShowSetupWizard(false);
};
const handleAccountUpdate = (updatedAccount: Account) => {
setAccounts(prevAccounts => prevAccounts.map(acc => acc.id === updatedAccount.id ? updatedAccount : acc));
};
const handleAccountAdd = (newAccount: Account) => {
setAccounts(prevAccounts => [...prevAccounts, newAccount]);
};
const handleAccountDelete = (accountId: string) => {
setAccounts(prevAccounts => prevAccounts.filter(acc => acc.id !== accountId));
};
const handleEnvelopeUpdate = (updatedEnvelope: Envelope) => {
setEnvelopes(prevEnvelopes => prevEnvelopes.map(env => env.id === updatedEnvelope.id ? updatedEnvelope : env));
};
const handleTransactionEdit = (updatedTransaction: Transaction) => {
setTransactions(prevTransactions => prevTransactions.map(tx => tx.id === updatedTransaction.id ? updatedTransaction : tx));
};
const handleTransactionDelete = (transactionId: string) => {
setTransactions(prevTransactions => prevTransactions.filter(tx => tx.id !== transactionId));
};
const handleCreateEnvelope = (newEnvelope: Envelope) => {
setEnvelopes(prevEnvelopes => [...prevEnvelopes, newEnvelope]);
};
const handleDeleteEnvelope = (envelopeId: string) => {
setEnvelopes(prevEnvelopes => prevEnvelopes.filter(env => env.id !== envelopeId));
};
const handleDataExport = (exportData: { accounts: Account[]; envelopes: Envelope[]; transactions: Transaction[] }) => {
// Implement data export logic here (e.g., download CSV, send to server, etc.)
console.log('Exporting data:', exportData);
};
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
<div>
{/* Render components based on current view */}
{currentView === 'dashboard' && <div>Dashboard View</div>}
{currentView === 'accounts' && <div>Accounts View</div>}
{currentView === 'transactions' && <div>Transactions View</div>}
{currentView === 'envelopes' && <div>Envelopes View</div>}
{currentView === 'settings' && <div>Settings View</div>}
{/* Auth modal */}
{showAuthModal && <AuthModal onClose={() => setShowAuthModal(false)} onSuccess={handleAuthSuccess} />}
{/* Setup wizard */}
{showSetupWizard && <SetupWizard onComplete={handleSetupComplete} />}
{/* Data input component */}
<DataInput
onDataImported={(importedData) => {
// Handle imported data (e.g., from CSV)
console.log('Imported data:', importedData);
}}
/>
{/* Transaction edit component */}
{editingTransaction && (
<TransactionEdit
transaction={editingTransaction}
onSave={(updatedTransaction) => {
handleTransactionEdit(updatedTransaction);
setEditingTransaction(null);
}}
onCancel={() => setEditingTransaction(null)}
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
)}
{/* Envelope create/edit component */}
{showCreateEnvelope && (
<EnvelopeCreate
onCreate={(newEnvelope) => {
handleCreateEnvelope(newEnvelope);
setShowCreateEnvelope(false);
}}
onCancel={() => setShowCreateEnvelope(false)}
/>
)}
{editingEnvelope && (
<EnvelopeEdit
envelope={editingEnvelope}
onSave={(updatedEnvelope) => {
handleEnvelopeUpdate(updatedEnvelope);
setEditingEnvelope(null);
}}
onCancel={() => setEditingEnvelope(null)}
/>
)}
{/* Data export component */}
<DataExport
accounts={accounts}
envelopes={envelopes}
transactions={transactions}
onExport={handleDataExport}
/>
{/* Account management component */}
<AccountManagement
accounts={accounts}
onAccountAdd={handleAccountAdd}
onAccountUpdate={handleAccountUpdate}
onAccountDelete={handleAccountDelete}
/>
{/* Get Paid component */}
<GetPaid
accounts={accounts}
envelopes={envelopes}
onIncomeAdded={handleTransactionsAdded}
onAccountUpdate={handleAccountUpdate}
/>
</div>
);
}