Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import React from 'react';
import type { DashboardMetrics } from '../hooks/useFinancialDashboard';
import { Wallet, PiggyBank, ShieldAlert, AlertTriangle, CheckCircle, TrendingUp, Layers, Target } from 'lucide-react';

interface DashboardCardsProps {
metrics: DashboardMetrics;
}

export const DashboardCards: React.FC<DashboardCardsProps> = ({ metrics }) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR' }).format(amount);
};

// Budget Status Logic
const getBudgetStatusColor = (percentage: number) => {
if (percentage < 50) return { bg: 'bg-emerald-50', text: 'text-emerald-600', icon: CheckCircle };
if (percentage > 80) return { bg: 'bg-rose-50', text: 'text-rose-600', icon: AlertTriangle };
return { bg: 'bg-amber-50', text: 'text-amber-600', icon: TrendingUp };
};

const statusStyle = getBudgetStatusColor(metrics.budgetStatus);
const StatusIcon = statusStyle.icon;

return (
<div className="h-full w-full p-4 overflow-y-auto bg-slate-50 rounded-3xl">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 h-full">

{/* LEFT COLUMN: PRIMARY STATS (Balance, Safe to Spend, Budget Status) */}
<div className="flex flex-col gap-6">

{/* Main Balance Card (Like IPK Card) */}
<div className="bg-white p-8 rounded-2xl shadow-sm border border-slate-200 relative overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-10">
<Wallet size={120} />
</div>
<h2 className="text-xl font-bold text-slate-500 mb-2 uppercase tracking-wide">Remaining Wallet</h2>
<div className="text-5xl lg:text-6xl font-extrabold text-blue-600 mb-2 truncate">
{formatCurrency(metrics.balance)}
</div>
<p className="text-slate-400 font-medium">
Calculated from Income - Expenses
</p>
</div>

{/* Safe to Spend & Budget Status Row */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Daily Safe to Spend */}
<div className="bg-white p-6 rounded-2xl shadow-sm border border-slate-200 flex flex-col justify-between">
<div className="flex items-center gap-3 mb-4">
<div className="p-3 bg-purple-50 rounded-xl text-purple-600">
<ShieldAlert size={24} />
</div>
<h3 className="font-bold text-slate-600">Daily Safe Limit</h3>
</div>
<div>
<div className="text-2xl font-bold text-slate-800">{formatCurrency(metrics.dailySafeToSpend)}</div>
<p className="text-xs text-slate-400 mt-1">Per day remaining</p>
</div>
</div>

{/* Budget Status */}
<div className="bg-white p-6 rounded-2xl shadow-sm border border-slate-200 flex flex-col justify-between">
<div className="flex items-center gap-3 mb-4">
<div className={`p-3 rounded-xl ${statusStyle.bg} ${statusStyle.text}`}>
<StatusIcon size={24} />
</div>
<h3 className="font-bold text-slate-600">Budget Health</h3>
</div>
<div>
<div className={`text-2xl font-bold ${statusStyle.text}`}>
{metrics.budgetStatus.toFixed(1)}% <span className="text-sm font-normal text-black">Used</span>
</div>
<div className="w-full bg-slate-100 h-2 rounded-full mt-3 overflow-hidden">
<div
className={`h-full rounded-full ${statusStyle.text.replace('text-', 'bg-')}`}
style={{ width: `${Math.min(metrics.budgetStatus, 100)}%` }}
/>
</div>
</div>
</div>
</div>
</div>

{/* RIGHT COLUMN: BREAKDOWNS & SAVINGS (Like Nav Buttons) */}
<div className="flex flex-col gap-4">
{/* Total Savings - Featured Top Right */}
<StatRow
title="Total Savings"
value={formatCurrency(metrics.goalsTotalSaved)}
desc="Accumulated in Goals"
icon={<PiggyBank size={32} />}
color="bg-emerald-50 text-emerald-600"
/>

<div className="mt-4 mb-2">
<h3 className="text-lg font-bold text-slate-700 flex items-center gap-2">
<Layers size={20} className="text-blue-500" />
Expense Breakdown
</h3>
</div>

<div className="grid grid-cols-2 gap-4">
<StatRow
title="Needs"
value={formatCurrency(metrics.breakdown.needs)}
desc="Essential expenses"
icon={<Target size={32} />} // Using Target for Needs/Essentials
color="bg-rose-50 text-rose-600"
/>
<StatRow
title="Wants"
value={formatCurrency(metrics.breakdown.wants)}
desc="Discretionary spending"
icon={<Wallet size={32} />}
color="bg-orange-50 text-orange-600"
/>
<StatRow
title="Investments"
value={formatCurrency(metrics.breakdown.invest)}
desc="Future growth"
icon={<TrendingUp size={32} />}
color="bg-blue-50 text-blue-600"
/>
<StatRow
title="Others"
value={formatCurrency(metrics.breakdown.others)}
desc="Miscellaneous"
icon={<Layers size={32} />}
color="bg-slate-100 text-slate-600"
/>
</div>
</div>
</div>
</div>
);
};

// Helper Component similar to Academic's NavButton but for Stats
interface StatRowProps {
title: string;
value: string;
desc: string;
icon: React.ReactNode;
color: string;
}

const StatRow: React.FC<StatRowProps> = ({ title, value, desc, icon, color }) => {
return (
<div className={`w-full p-4 lg:p-6 rounded-2xl border border-transparent flex items-center gap-4 lg:gap-6 shadow-sm ${color} transition-all hover:scale-[1.01]`}>
<div className="p-3 lg:p-4 bg-white rounded-xl shadow-sm shrink-0">
{icon}
</div>
<div className="flex-1 min-w-0"> {/* min-w-0 for truncate to work */}
<p className="opacity-80 text-xs lg:text-sm font-bold uppercase tracking-wider mb-1">{title}</p>
<h3 className="text-lg lg:text-2xl font-bold truncate">{value}</h3>
<p className="opacity-70 text-xs font-medium truncate">{desc}</p>
</div>
</div>
);
}
39 changes: 39 additions & 0 deletions Frontend/project-CL/src/features/financial/financial.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import React from 'react';
import { useFinancialDashboard } from './hooks/useFinancialDashboard';
import { DashboardCards } from './components/DashboardCards';
import Header from "../../components/Header";

const Financial: React.FC = () => {
const { metrics, loading, error } = useFinancialDashboard();

if (loading) {
return (
<div className="flex items-center justify-center min-h-screen w-full">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}

return (
<div className="min-h-screen h-full w-full flex flex-col bg-white overflow-hidden">
<div className="px-6 pt-6 pb-4 border-b border-slate-100 flex-none w-full">
<Header title="Financial" subtitle="Track your income, expenses, and savings goals" />

{/* Future: Toolbar / Filters similar to Academic could go here */}
</div>

{/* View Area */}
<div className="flex-1 w-full overflow-hidden bg-slate-50 relative">
{!error ? (
<div className="flex items-center justify-center h-full text-red-500">
{error}
</div>
) : (
<DashboardCards metrics={metrics} />
)}
</div>
</div>
);
};

export default Financial;
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { useState, useEffect } from 'react';
import { financialService, type Transaction, type Category, type Goal } from '../services/financialService';

export interface DashboardMetrics {
totalIncome: number;
totalExpense: number;
balance: number;
budgetStatus: number; // Percentage (0-100+)
dailySafeToSpend: number;
breakdown: {
needs: number;
wants: number;
savings: number;
invest: number;
others: number;
};
goalsTotalSaved: number;
}

export const useFinancialDashboard = () => {
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [goals, setGoals] = useState<Goal[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

const [metrics, setMetrics] = useState<DashboardMetrics>({
totalIncome: 0,
totalExpense: 0,
balance: 0,
budgetStatus: 0,
dailySafeToSpend: 0,
breakdown: { needs: 0, wants: 0, savings: 0, invest: 0, others: 0 },
goalsTotalSaved: 0
});

useEffect(() => {
const fetchData = async () => {
try {
const [txData, catData, goalData] = await Promise.all([
financialService.getTransactions(),
financialService.getCategories(),
financialService.getGoals()
]);

setTransactions(txData);
setCategories(catData);
setGoals(goalData);

calculateMetrics(txData, catData, goalData);
} catch (err) {
console.error("Failed to fetch financial data", err);
setError("Failed to load dashboard data");
} finally {
setLoading(false);
}
};

fetchData();
}, []);

const calculateMetrics = (txs: Transaction[], cats: Category[], goals: Goal[]) => {
let income = 0;
let expense = 0;
const breakdown = { needs: 0, wants: 0, savings: 0, invest: 0, others: 0 };

// Helper to map category ID to bucket
const categoryMap = new Map<number, string>();
cats.forEach(c => categoryMap.set(c.id, c.bucket?.toLowerCase() || 'others'));

txs.forEach(tx => {
// Determine if it's income or expense based on Category Type or Amount sign
// Assuming Category has 'type' which could be 'income' or 'expense'
// We need to fetch the category for this transaction to know the type/bucket
const bucket = categoryMap.get(tx.category_id) || 'others';
const category = cats.find(c => c.id === tx.category_id);

if (category?.type === 'income') {
income += tx.amount;
} else {
expense += tx.amount;
// Add to breakdown
switch (bucket) {
case 'needs': breakdown.needs += tx.amount; break;
case 'wants': breakdown.wants += tx.amount; break;
case 'savings': breakdown.savings += tx.amount; break;
case 'invest': breakdown.invest += tx.amount; break;
default: breakdown.others += tx.amount; break;
}
}
});

const balance = income - expense;
const budgetStatus = income > 0 ? (expense / income) * 100 : 0;

// Goals total saved
const goalsSaved = goals.reduce((acc, g) => acc + g.saved_amount, 0);

// Safe to spend
// Logic: (Balance - Planned Savings?) / Days remaining?
// User asked for "Daily safe to spend". Simple version: Balance / Days remaining in month.
const today = new Date();
const lastDayOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0);
const daysRemaining = Math.max(1, lastDayOfMonth.getDate() - today.getDate()); // Prevent div by 0
const dailySafe = balance / daysRemaining;

setMetrics({
totalIncome: income,
totalExpense: expense,
balance,
budgetStatus,
dailySafeToSpend: dailySafe,
breakdown,
goalsTotalSaved: goalsSaved
});
};

return { metrics, loading, error, transactions, categories, goals };
};
5 changes: 5 additions & 0 deletions Frontend/project-CL/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { createBrowserRouter } from 'react-router'
import Academic from './features/academic/academic'
import SidebarLayout from './layouts/SidebarLayout'
import { SubjectProvider } from './features/academic/hooks/useSubject'
import Financial from './features/financial/financial'

const router = createBrowserRouter([
{
Expand All @@ -20,6 +21,10 @@ const router = createBrowserRouter([
path: '/academic',
element: <Academic />,
},
{
path: '/finance',
element: <Financial />,
},
]
}
])
Expand Down