Files
personal-finance/frontend-web/src/pages/CashflowPage.tsx
2025-12-07 12:19:38 -05:00

217 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {useState} from 'react';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {Button} from '@/components/ui/button';
import {useAppSelector} from '@/store';
import AddIncomeDialog from '@/components/dialogs/AddIncomeDialog';
import AddExpenseDialog from '@/components/dialogs/AddExpenseDialog';
import AddTransactionDialog from '@/components/dialogs/AddTransactionDialog';
export default function CashflowPage() {
const [incomeDialogOpen, setIncomeDialogOpen] = useState(false);
const [expenseDialogOpen, setExpenseDialogOpen] = useState(false);
const [transactionDialogOpen, setTransactionDialogOpen] = useState(false);
const {incomeSources, expenses, transactions} = useAppSelector(state => state.cashflow);
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 fmt = (value: number) => new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0}).format(value);
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<string, number>
);
const sortedCategories = Object.entries(expensesByCategory).sort((a, b) => b[1] - a[1]);
const topExpenses = expenses.filter(e => e.isActive).sort((a, b) => getMonthlyAmount(b.amount, b.frequency) - getMonthlyAmount(a.amount, a.frequency));
return (
<div className="p-4">
{/* Header */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-6">
<h1 className="text-lg font-semibold">Cashflow</h1>
<div className="flex gap-4 text-sm">
<div>
<span className="text-muted-foreground">Income</span> <span className="font-medium text-green-400">{fmt(monthlyIncome)}</span>
</div>
<div>
<span className="text-muted-foreground">Expenses</span> <span className="font-medium">{fmt(monthlyExpenses)}</span>
</div>
<div>
<span className="text-muted-foreground">Net</span>{' '}
<span className={`font-semibold ${monthlySavings >= 0 ? 'text-green-400' : 'text-red-400'}`}>{fmt(monthlySavings)}</span>
</div>
<div>
<span className="text-muted-foreground">Savings</span>{' '}
<span className={`font-medium ${savingsRate >= 20 ? 'text-green-400' : ''}`}>{savingsRate.toFixed(0)}%</span>
</div>
</div>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setIncomeDialogOpen(true)}>
Add Income
</Button>
<Button size="sm" onClick={() => setExpenseDialogOpen(true)}>
Add Expense
</Button>
</div>
</div>
<div className="grid grid-cols-12 gap-4">
{/* Income Sources */}
<Card className="card-elevated col-span-3">
<CardHeader className="p-3 pb-2">
<CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Income</CardTitle>
</CardHeader>
<CardContent className="p-3 pt-0">
<div className="space-y-3">
{incomeSources
.filter(i => i.isActive)
.map(income => (
<div key={income.id} className="flex justify-between items-baseline">
<div>
<p className="text-sm font-medium">{income.name}</p>
<p className="text-xs text-muted-foreground">{income.frequency}</p>
</div>
<p className="text-sm font-medium tabular-nums">{fmt(income.amount)}</p>
</div>
))}
</div>
</CardContent>
</Card>
{/* Expenses Breakdown */}
<Card className="card-elevated col-span-5">
<CardHeader className="p-3 pb-2">
<CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Expenses by Category</CardTitle>
</CardHeader>
<CardContent className="p-3 pt-0">
<div className="space-y-2">
{sortedCategories.map(([category, amount]) => {
const pct = (amount / monthlyExpenses) * 100;
return (
<div key={category} className="flex items-center gap-3">
<div className="w-24 text-sm truncate">{category}</div>
<div className="flex-1 h-1.5 rounded-full bg-foreground/10 overflow-hidden">
<div className="h-full rounded-full bg-foreground/30" style={{width: `${pct}%`}} />
</div>
<div className="w-16 text-right text-sm font-medium tabular-nums">{fmt(amount)}</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Recent Transactions */}
<Card className="card-elevated col-span-4">
<CardHeader className="flex flex-row items-center justify-between p-3 pb-2">
<CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Recent Activity</CardTitle>
<Button variant="ghost" size="sm" className="h-5 px-2 text-xs" onClick={() => setTransactionDialogOpen(true)}>
+
</Button>
</CardHeader>
<CardContent className="p-3 pt-0">
<div className="space-y-2">
{transactions.slice(0, 8).map(tx => (
<div key={tx.id} className="flex justify-between items-baseline">
<div>
<p className="text-sm">{tx.name}</p>
<p className="text-xs text-muted-foreground">{tx.date}</p>
</div>
<span className={`text-sm font-medium tabular-nums ${tx.type === 'income' ? 'text-green-400' : ''}`}>
{tx.type === 'income' ? '+' : ''}
{fmt(tx.amount)}
</span>
</div>
))}
</div>
</CardContent>
</Card>
{/* Top Expenses */}
<Card className="card-elevated col-span-6">
<CardHeader className="p-3 pb-2">
<CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Top Monthly Expenses</CardTitle>
</CardHeader>
<CardContent className="p-3 pt-0">
<div className="grid grid-cols-2 gap-x-6 gap-y-2">
{topExpenses.slice(0, 10).map(expense => (
<div key={expense.id} className="flex justify-between items-baseline py-0.5">
<span className={`text-sm ${expense.isEssential ? '' : 'text-muted-foreground'}`}>{expense.name}</span>
<span className="text-sm font-medium tabular-nums">{fmt(getMonthlyAmount(expense.amount, expense.frequency))}</span>
</div>
))}
</div>
</CardContent>
</Card>
{/* Summary Stats */}
<Card className="card-elevated col-span-6">
<CardHeader className="p-3 pb-2">
<CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Monthly Summary</CardTitle>
</CardHeader>
<CardContent className="p-3 pt-0">
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-2xl font-semibold text-green-400">{fmt(monthlyIncome)}</p>
<p className="text-xs text-muted-foreground">Total Income</p>
</div>
<div>
<p className="text-2xl font-semibold">{fmt(monthlyExpenses)}</p>
<p className="text-xs text-muted-foreground">Total Expenses</p>
</div>
<div>
<p className={`text-2xl font-semibold ${monthlySavings >= 0 ? 'text-green-400' : 'text-red-400'}`}>{fmt(monthlySavings)}</p>
<p className="text-xs text-muted-foreground">Net Savings</p>
</div>
</div>
<div className="mt-4 pt-3 border-t border-border">
<div className="flex justify-between text-sm mb-1">
<span className="text-muted-foreground">Savings Rate</span>
<span className={`font-medium ${savingsRate >= 20 ? 'text-green-400' : ''}`}>{savingsRate.toFixed(1)}%</span>
</div>
<div className="h-2 rounded-full bg-foreground/10 overflow-hidden">
<div
className={`h-full rounded-full ${savingsRate >= 20 ? 'bg-green-400/50' : 'bg-foreground/30'}`}
style={{width: `${Math.min(savingsRate, 100)}%`}}
/>
</div>
</div>
</CardContent>
</Card>
</div>
<AddIncomeDialog open={incomeDialogOpen} onOpenChange={setIncomeDialogOpen} />
<AddExpenseDialog open={expenseDialogOpen} onOpenChange={setExpenseDialogOpen} />
<AddTransactionDialog open={transactionDialogOpen} onOpenChange={setTransactionDialogOpen} />
</div>
);
}