From afe17846f7ff4baf3507c5ea480946a124f39b9a Mon Sep 17 00:00:00 2001 From: Alexander Zinn Date: Sun, 7 Dec 2025 11:16:25 -0500 Subject: [PATCH] Add Cashflow feature to frontend-web - Introduced CashflowPage component for managing income and expenses, including detailed summaries and transaction tracking. - Updated App component to include routing for the new CashflowPage. - Enhanced Layout component with navigation for Cashflow. - Created Redux slice for cashflow management, including actions for income sources, expenses, and transactions. - Integrated mock data for initial cashflow setup to facilitate development and testing. --- frontend-web/src/App.tsx | 2 + frontend-web/src/components/Layout.tsx | 3 +- frontend-web/src/pages/CashflowPage.tsx | 214 ++++++++++++++++++ frontend-web/src/store/index.ts | 15 ++ .../src/store/slices/cashflowSlice.ts | 144 ++++++++++++ frontend-web/src/store/store.ts | 2 + 6 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 frontend-web/src/pages/CashflowPage.tsx create mode 100644 frontend-web/src/store/slices/cashflowSlice.ts diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 9adbba6..e2d17ea 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -1,6 +1,7 @@ import {BrowserRouter, Routes, Route} from 'react-router-dom'; import Layout from '@/components/Layout'; import NetWorthPage from '@/pages/NetWorthPage'; +import CashflowPage from '@/pages/CashflowPage'; import DebtsPage from '@/pages/DebtsPage'; import InvoicesPage from '@/pages/InvoicesPage'; import ClientsPage from '@/pages/ClientsPage'; @@ -11,6 +12,7 @@ export default function App() { }> } /> + } /> } /> } /> } /> diff --git a/frontend-web/src/components/Layout.tsx b/frontend-web/src/components/Layout.tsx index 5911c8d..707a9cf 100644 --- a/frontend-web/src/components/Layout.tsx +++ b/frontend-web/src/components/Layout.tsx @@ -1,8 +1,9 @@ import {NavLink, Outlet} from 'react-router-dom'; -import {TrendingUp, CreditCard, FileText, Users} from 'lucide-react'; +import {TrendingUp, CreditCard, FileText, Users, ArrowLeftRight} from 'lucide-react'; const navItems = [ {to: '/', label: 'Net Worth', icon: TrendingUp}, + {to: '/cashflow', label: 'Cashflow', icon: ArrowLeftRight}, {to: '/debts', label: 'Debts', icon: CreditCard}, {to: '/invoices', label: 'Invoices', icon: FileText}, {to: '/clients', label: 'Clients', icon: Users}, diff --git a/frontend-web/src/pages/CashflowPage.tsx b/frontend-web/src/pages/CashflowPage.tsx new file mode 100644 index 0000000..6760248 --- /dev/null +++ b/frontend-web/src/pages/CashflowPage.tsx @@ -0,0 +1,214 @@ +import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; +import {Button} from '@/components/ui/button'; +import {useAppSelector} from '@/store'; + +export default function CashflowPage() { + const {incomeSources, expenses, transactions} = useAppSelector(state => state.cashflow); + + // Calculate monthly totals + const getMonthlyAmount = (amount: number, frequency: string) => { + switch (frequency) { + case 'weekly': return amount * 4.33; + case 'biweekly': return amount * 2.17; + case 'monthly': return amount; + case 'quarterly': return amount / 3; + case 'yearly': return amount / 12; + default: return 0; + } + }; + + const monthlyIncome = incomeSources + .filter(i => i.isActive) + .reduce((sum, i) => sum + getMonthlyAmount(i.amount, i.frequency), 0); + + const monthlyExpenses = expenses + .filter(e => e.isActive) + .reduce((sum, e) => sum + getMonthlyAmount(e.amount, e.frequency), 0); + + const monthlySavings = monthlyIncome - monthlyExpenses; + const savingsRate = monthlyIncome > 0 ? (monthlySavings / monthlyIncome) * 100 : 0; + + const essentialExpenses = expenses.filter(e => e.isActive && e.isEssential); + const discretionaryExpenses = expenses.filter(e => e.isActive && !e.isEssential); + + const essentialTotal = essentialExpenses.reduce((sum, e) => sum + getMonthlyAmount(e.amount, e.frequency), 0); + const discretionaryTotal = discretionaryExpenses.reduce((sum, e) => sum + getMonthlyAmount(e.amount, e.frequency), 0); + + const fmt = (value: number) => + new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0}).format(value); + + // Group expenses by category + const expensesByCategory = expenses.filter(e => e.isActive).reduce((acc, e) => { + const monthly = getMonthlyAmount(e.amount, e.frequency); + acc[e.category] = (acc[e.category] || 0) + monthly; + return acc; + }, {} as Record); + + const sortedCategories = Object.entries(expensesByCategory).sort((a, b) => b[1] - a[1]); + + return ( +
+ {/* Header */} +
+

Cashflow

+
+ + +
+
+ + {/* Summary Cards */} +
+ + +

Monthly Income

+
+ +

{fmt(monthlyIncome)}

+
+
+ + +

Monthly Expenses

+
+ +

{fmt(monthlyExpenses)}

+
+
+ + +

Monthly Savings

+
+ +

= 0 ? 'text-green-400' : 'text-red-400'}`}> + {fmt(monthlySavings)} +

+
+
+ + +

Savings Rate

+
+ +

= 20 ? 'text-green-400' : savingsRate >= 10 ? 'text-yellow-400' : 'text-red-400'}`}> + {savingsRate.toFixed(0)}% +

+
+
+
+ +
+ {/* Income Sources */} + + + Income Sources + {fmt(monthlyIncome)}/mo + + +
+ {incomeSources.filter(i => i.isActive).map(income => ( +
+
+

{income.name}

+

{income.category} · {income.frequency}

+
+
+

{fmt(income.amount)}

+

{fmt(getMonthlyAmount(income.amount, income.frequency))}/mo

+
+
+ ))} +
+
+
+ + {/* Expenses by Category */} + + + Expenses by Category + {fmt(monthlyExpenses)}/mo + + +
+ {sortedCategories.map(([category, amount]) => { + const pct = (amount / monthlyExpenses) * 100; + return ( +
+
+
+ {category} + {fmt(amount)} +
+
+
+
+
+ {pct.toFixed(0)}% +
+ ); + })} +
+ + + + {/* Essential vs Discretionary */} + + + Essential vs Discretionary + + +
+
+

Essential

+

{fmt(essentialTotal)}

+

{((essentialTotal / monthlyExpenses) * 100).toFixed(0)}% of expenses

+
+
+

Discretionary

+

{fmt(discretionaryTotal)}

+

{((discretionaryTotal / monthlyExpenses) * 100).toFixed(0)}% of expenses

+
+
+
+ {expenses.filter(e => e.isActive).sort((a, b) => getMonthlyAmount(b.amount, b.frequency) - getMonthlyAmount(a.amount, a.frequency)).map(expense => ( +
+
+ {expense.name} + {expense.isEssential && ( + Essential + )} +
+ {fmt(getMonthlyAmount(expense.amount, expense.frequency))} +
+ ))} +
+
+
+ + {/* Recent Transactions */} + + + Recent Transactions + + + +
+ {transactions.slice(0, 8).map(tx => ( +
+
+

{tx.name}

+

{tx.category} · {tx.date}

+
+ + {tx.type === 'income' ? '+' : '-'}{fmt(tx.amount)} + +
+ ))} +
+
+
+
+
+ ); +} + diff --git a/frontend-web/src/store/index.ts b/frontend-web/src/store/index.ts index 164d74b..a23d3f4 100644 --- a/frontend-web/src/store/index.ts +++ b/frontend-web/src/store/index.ts @@ -57,3 +57,18 @@ export { updateInvoiceStatus, } from './slices/invoicesSlice'; export type {Client, Invoice, InvoiceLineItem, InvoicesState} from './slices/invoicesSlice'; + +// Cashflow slice +export { + setLoading as setCashflowLoading, + setError as setCashflowError, + addIncomeSource, + updateIncomeSource, + removeIncomeSource, + addExpense, + updateExpense, + removeExpense, + addTransaction, + removeTransaction, +} from './slices/cashflowSlice'; +export type {IncomeSource, Expense, Transaction, CashflowState} from './slices/cashflowSlice'; diff --git a/frontend-web/src/store/slices/cashflowSlice.ts b/frontend-web/src/store/slices/cashflowSlice.ts new file mode 100644 index 0000000..734f859 --- /dev/null +++ b/frontend-web/src/store/slices/cashflowSlice.ts @@ -0,0 +1,144 @@ +import {createSlice, type PayloadAction} from '@reduxjs/toolkit'; + +export interface IncomeSource { + id: string; + name: string; + amount: number; + frequency: 'weekly' | 'biweekly' | 'monthly' | 'quarterly' | 'yearly' | 'once'; + category: string; + nextDate: string; + isActive: boolean; + createdAt: string; +} + +export interface Expense { + id: string; + name: string; + amount: number; + frequency: 'weekly' | 'biweekly' | 'monthly' | 'quarterly' | 'yearly' | 'once'; + category: string; + nextDate: string; + isActive: boolean; + isEssential: boolean; + createdAt: string; +} + +export interface Transaction { + id: string; + type: 'income' | 'expense'; + name: string; + amount: number; + category: string; + date: string; + note?: string; +} + +export interface CashflowState { + incomeSources: IncomeSource[]; + expenses: Expense[]; + transactions: Transaction[]; + categories: {income: string[]; expense: string[]}; + isLoading: boolean; + error: string | null; +} + +const defaultCategories = { + income: ['Salary', 'Freelance', 'Investments', 'Rental', 'Side Business', 'Other'], + expense: ['Housing', 'Utilities', 'Transportation', 'Food', 'Insurance', 'Healthcare', 'Subscriptions', 'Entertainment', 'Shopping', 'Savings', 'Other'], +}; + +// Mock data +const mockIncomeSources: IncomeSource[] = [ + {id: 'i1', name: 'Software Engineer Salary', amount: 8500, frequency: 'monthly', category: 'Salary', nextDate: '2024-12-15', isActive: true, createdAt: '2024-01-01'}, + {id: 'i2', name: 'Consulting', amount: 2000, frequency: 'monthly', category: 'Freelance', nextDate: '2024-12-20', isActive: true, createdAt: '2024-03-01'}, + {id: 'i3', name: 'Dividend Income', amount: 450, frequency: 'quarterly', category: 'Investments', nextDate: '2024-12-31', isActive: true, createdAt: '2024-01-01'}, +]; + +const mockExpenses: Expense[] = [ + {id: 'e1', name: 'Mortgage', amount: 2200, frequency: 'monthly', category: 'Housing', nextDate: '2024-12-01', isActive: true, isEssential: true, createdAt: '2024-01-01'}, + {id: 'e2', name: 'Car Payment', amount: 450, frequency: 'monthly', category: 'Transportation', nextDate: '2024-12-05', isActive: true, isEssential: true, createdAt: '2024-01-01'}, + {id: 'e3', name: 'Car Insurance', amount: 180, frequency: 'monthly', category: 'Insurance', nextDate: '2024-12-10', isActive: true, isEssential: true, createdAt: '2024-01-01'}, + {id: 'e4', name: 'Utilities', amount: 250, frequency: 'monthly', category: 'Utilities', nextDate: '2024-12-15', isActive: true, isEssential: true, createdAt: '2024-01-01'}, + {id: 'e5', name: 'Groceries', amount: 600, frequency: 'monthly', category: 'Food', nextDate: '2024-12-01', isActive: true, isEssential: true, createdAt: '2024-01-01'}, + {id: 'e6', name: 'Gym Membership', amount: 50, frequency: 'monthly', category: 'Healthcare', nextDate: '2024-12-01', isActive: true, isEssential: false, createdAt: '2024-01-01'}, + {id: 'e7', name: 'Netflix', amount: 15, frequency: 'monthly', category: 'Subscriptions', nextDate: '2024-12-08', isActive: true, isEssential: false, createdAt: '2024-01-01'}, + {id: 'e8', name: 'Spotify', amount: 12, frequency: 'monthly', category: 'Subscriptions', nextDate: '2024-12-12', isActive: true, isEssential: false, createdAt: '2024-01-01'}, + {id: 'e9', name: 'Health Insurance', amount: 350, frequency: 'monthly', category: 'Insurance', nextDate: '2024-12-01', isActive: true, isEssential: true, createdAt: '2024-01-01'}, + {id: 'e10', name: '401k Contribution', amount: 1500, frequency: 'monthly', category: 'Savings', nextDate: '2024-12-15', isActive: true, isEssential: true, createdAt: '2024-01-01'}, +]; + +const mockTransactions: Transaction[] = [ + {id: 't1', type: 'income', name: 'Salary', amount: 8500, category: 'Salary', date: '2024-11-15'}, + {id: 't2', type: 'expense', name: 'Mortgage', amount: 2200, category: 'Housing', date: '2024-11-01'}, + {id: 't3', type: 'expense', name: 'Groceries', amount: 145, category: 'Food', date: '2024-11-28'}, + {id: 't4', type: 'expense', name: 'Gas', amount: 55, category: 'Transportation', date: '2024-11-25'}, + {id: 't5', type: 'income', name: 'Consulting Payment', amount: 2000, category: 'Freelance', date: '2024-11-20'}, + {id: 't6', type: 'expense', name: 'Restaurant', amount: 85, category: 'Food', date: '2024-11-22'}, +]; + +const initialState: CashflowState = { + incomeSources: mockIncomeSources, + expenses: mockExpenses, + transactions: mockTransactions, + categories: defaultCategories, + isLoading: false, + error: null, +}; + +const cashflowSlice = createSlice({ + name: 'cashflow', + initialState, + reducers: { + setLoading: (state, action: PayloadAction) => { + state.isLoading = action.payload; + }, + setError: (state, action: PayloadAction) => { + state.error = action.payload; + }, + // Income actions + addIncomeSource: (state, action: PayloadAction) => { + state.incomeSources.push(action.payload); + }, + updateIncomeSource: (state, action: PayloadAction) => { + const idx = state.incomeSources.findIndex(i => i.id === action.payload.id); + if (idx !== -1) state.incomeSources[idx] = action.payload; + }, + removeIncomeSource: (state, action: PayloadAction) => { + state.incomeSources = state.incomeSources.filter(i => i.id !== action.payload); + }, + // Expense actions + addExpense: (state, action: PayloadAction) => { + state.expenses.push(action.payload); + }, + updateExpense: (state, action: PayloadAction) => { + const idx = state.expenses.findIndex(e => e.id === action.payload.id); + if (idx !== -1) state.expenses[idx] = action.payload; + }, + removeExpense: (state, action: PayloadAction) => { + state.expenses = state.expenses.filter(e => e.id !== action.payload); + }, + // Transaction actions + addTransaction: (state, action: PayloadAction) => { + state.transactions.push(action.payload); + }, + removeTransaction: (state, action: PayloadAction) => { + state.transactions = state.transactions.filter(t => t.id !== action.payload); + }, + }, +}); + +export const { + setLoading, + setError, + addIncomeSource, + updateIncomeSource, + removeIncomeSource, + addExpense, + updateExpense, + removeExpense, + addTransaction, + removeTransaction, +} = cashflowSlice.actions; + +export default cashflowSlice.reducer; + diff --git a/frontend-web/src/store/store.ts b/frontend-web/src/store/store.ts index 53b8a63..df1c448 100644 --- a/frontend-web/src/store/store.ts +++ b/frontend-web/src/store/store.ts @@ -3,6 +3,7 @@ import userReducer from './slices/userSlice'; import netWorthReducer from './slices/netWorthSlice'; import debtsReducer from './slices/debtsSlice'; import invoicesReducer from './slices/invoicesSlice'; +import cashflowReducer from './slices/cashflowSlice'; export const store = configureStore({ reducer: { @@ -10,6 +11,7 @@ export const store = configureStore({ netWorth: netWorthReducer, debts: debtsReducer, invoices: invoicesReducer, + cashflow: cashflowReducer, }, });