diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index 6f8c97b..04c10ff 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -1,20 +1,18 @@ -import { Link } from "react-router-dom" +import { useMemo } from "react" import { useTranslation } from "react-i18next" -import { - PieChart, - Pie, - Cell, - ResponsiveContainer, - Tooltip, -} from "recharts" import { useBudgets, useBudgetDetail } from "@/hooks/useBudgets" +import { useMonthParam } from "@/hooks/useMonthParam" import type { CategoryType } from "@/lib/types" -import { categoryColors } from "@/lib/palette" import { formatCurrency } from "@/lib/format" +import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { PageShell } from "@/components/shared/PageShell" import { SummaryStrip } from "@/components/dashboard/SummaryStrip" import { DashboardSkeleton } from "@/components/dashboard/DashboardSkeleton" +import { MonthNavigator } from "@/components/dashboard/MonthNavigator" +import { ExpenseDonutChart } from "@/components/dashboard/charts/ExpenseDonutChart" +import { IncomeBarChart } from "@/components/dashboard/charts/IncomeBarChart" +import { SpendBarChart } from "@/components/dashboard/charts/SpendBarChart" import QuickAddPicker from "@/components/QuickAddPicker" // --------------------------------------------------------------------------- @@ -29,18 +27,6 @@ const EXPENSE_TYPES: CategoryType[] = [ "investment", ] -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** - * Returns the ISO date string for the first day of the given month. - * e.g. currentMonthStart(2026, 3) => "2026-03-01" - */ -function currentMonthStart(year: number, month: number): string { - return `${year}-${String(month).padStart(2, "0")}-01` -} - // --------------------------------------------------------------------------- // Dashboard inner — rendered once a budget id is known // --------------------------------------------------------------------------- @@ -49,67 +35,97 @@ function DashboardContent({ budgetId }: { budgetId: string }) { const { t } = useTranslation() const { budget, items, loading } = useBudgetDetail(budgetId) + // ------------------------------------------------------------------ + // Derived totals — all hooks must be called before any early returns + // ------------------------------------------------------------------ + + const totalIncome = useMemo( + () => + items + .filter((i) => i.category?.type === "income") + .reduce((sum, i) => sum + i.actual_amount, 0), + [items] + ) + + const totalExpenses = useMemo( + () => + items + .filter((i) => i.category?.type !== "income") + .reduce((sum, i) => sum + i.actual_amount, 0), + [items] + ) + + const budgetedIncome = useMemo( + () => + items + .filter((i) => i.category?.type === "income") + .reduce((sum, i) => sum + i.budgeted_amount, 0), + [items] + ) + + const budgetedExpenses = useMemo( + () => + items + .filter((i) => i.category?.type !== "income") + .reduce((sum, i) => sum + i.budgeted_amount, 0), + [items] + ) + + // ------------------------------------------------------------------ + // Chart data derivations (memoized) + // ------------------------------------------------------------------ + + const pieData = useMemo( + () => + EXPENSE_TYPES.map((type) => { + const total = items + .filter((i) => i.category?.type === type) + .reduce((sum, i) => sum + i.actual_amount, 0) + return { type, value: total, label: t(`categories.types.${type}`) } + }).filter((d) => d.value > 0), + [items, t] + ) + + const incomeBarData = useMemo(() => { + const budgeted = items + .filter((i) => i.category?.type === "income") + .reduce((sum, i) => sum + i.budgeted_amount, 0) + const actual = items + .filter((i) => i.category?.type === "income") + .reduce((sum, i) => sum + i.actual_amount, 0) + if (budgeted === 0 && actual === 0) return [] + return [{ label: t("categories.types.income"), budgeted, actual }] + }, [items, t]) + + const spendBarData = useMemo( + () => + EXPENSE_TYPES.map((type) => { + const groupItems = items.filter((i) => i.category?.type === type) + if (groupItems.length === 0) return null + const budgeted = groupItems.reduce((sum, i) => sum + i.budgeted_amount, 0) + const actual = groupItems.reduce((sum, i) => sum + i.actual_amount, 0) + return { type, label: t(`categories.types.${type}`), budgeted, actual } + }).filter(Boolean) as Array<{ + type: string + label: string + budgeted: number + actual: number + }>, + [items, t] + ) + + // ------------------------------------------------------------------ + // Early returns after all hooks + // ------------------------------------------------------------------ + if (loading) return if (!budget) return null const currency = budget.currency - - // ------------------------------------------------------------------ - // Derived totals - // ------------------------------------------------------------------ - - const totalIncome = items - .filter((i) => i.category?.type === "income") - .reduce((sum, i) => sum + i.actual_amount, 0) - - const totalExpenses = items - .filter((i) => i.category?.type !== "income") - .reduce((sum, i) => sum + i.actual_amount, 0) - const availableBalance = totalIncome - totalExpenses + budget.carryover_amount - const budgetedIncome = items - .filter((i) => i.category?.type === "income") - .reduce((sum, i) => sum + i.budgeted_amount, 0) - - const budgetedExpenses = items - .filter((i) => i.category?.type !== "income") - .reduce((sum, i) => sum + i.budgeted_amount, 0) - - // ------------------------------------------------------------------ - // Pie chart data — actual spending grouped by category type (non-income) - // ------------------------------------------------------------------ - - const pieData = EXPENSE_TYPES.map((type) => { - const total = items - .filter((i) => i.category?.type === type) - .reduce((sum, i) => sum + i.actual_amount, 0) - return { name: t(`categories.types.${type}`), value: total, type } - }).filter((d) => d.value > 0) - - // ------------------------------------------------------------------ - // Category progress rows — non-income types with at least one item - // ------------------------------------------------------------------ - - const progressGroups = EXPENSE_TYPES.map((type) => { - const groupItems = items.filter((i) => i.category?.type === type) - if (groupItems.length === 0) return null - - const budgeted = groupItems.reduce((sum, i) => sum + i.budgeted_amount, 0) - const actual = groupItems.reduce((sum, i) => sum + i.actual_amount, 0) - const pct = budgeted > 0 ? Math.round((actual / budgeted) * 100) : 0 - const overBudget = actual > budgeted - - return { type, budgeted, actual, pct, overBudget } - }).filter(Boolean) - return (
- {/* Quick Add button */} -
- -
- {/* Summary cards */} - {/* Expense breakdown chart + category progress */} -
- {/* Pie chart */} - {pieData.length > 0 && ( - - - - {t("dashboard.expenseBreakdown")} - - - - - - - {pieData.map((entry) => ( - - ))} - - - formatCurrency(Number(value), currency) - } - /> - - + {/* 3-column chart grid */} +
+ + + {t("dashboard.expenseDonut")} + + + + + - {/* Legend */} -
    - {pieData.map((entry) => ( -
  • - - {entry.name} - - {formatCurrency(entry.value, currency)} - -
  • - ))} -
- - - )} + + + {t("dashboard.incomeChart")} + + + + + - {/* Category progress */} - {progressGroups.length > 0 && ( - - - - {t("dashboard.expenseBreakdown")} - - - -
    - {progressGroups.map((group) => { - if (!group) return null - const barColor = group.overBudget - ? "bg-over-budget" - : "bg-on-budget" - const clampedPct = Math.min(group.pct, 100) + + + {t("dashboard.spendChart")} + + + + + +
- return ( -
  • -
    - - - {t(`categories.types.${group.type}`)} - - - {formatCurrency(group.actual, currency)} - {" / "} - {formatCurrency(group.budgeted, currency)} - {" "} - ({group.pct}%) - -
    - - {/* Progress bar */} -
    -
    -
    -
  • - ) - })} - -
    -
    - )} + {/* Quick Add button */} +
    +
    ) @@ -255,36 +200,60 @@ function DashboardContent({ budgetId }: { budgetId: string }) { export default function DashboardPage() { const { t } = useTranslation() - const { budgets, loading } = useBudgets() + const { month } = useMonthParam() + const { budgets, loading, createBudget, generateFromTemplate } = useBudgets() - // Find budget whose start_date falls in the current calendar month - const now = new Date() - const year = now.getFullYear() - const month = now.getMonth() + 1 - const monthPrefix = currentMonthStart(year, month).slice(0, 7) // "YYYY-MM" - - const currentBudget = budgets.find((b) => - b.start_date.startsWith(monthPrefix) + const availableMonths = useMemo( + () => budgets.map((b) => b.start_date.slice(0, 7)), + [budgets] ) - if (loading) return ( - - - + const currentBudget = useMemo( + () => budgets.find((b) => b.start_date.startsWith(month)), + [budgets, month] ) + const [parsedYear, parsedMonth] = month.split("-").map(Number) + return ( - - {!currentBudget ? ( - /* No budget for this month */ + } + > + {loading ? ( + + ) : !currentBudget ? ( + /* No budget for the selected month */
    -

    {t("dashboard.noBudget")}

    - - {t("budgets.newBudget")} - +

    {t("dashboard.noBudgetForMonth")}

    +
    + + +
    ) : (