import { useState, useMemo } from "react" import { Navigate } from "react-router-dom" import { useTranslation } from "react-i18next" import { useFirstRunState } from "@/hooks/useFirstRunState" import { useBudgets, useBudgetDetail } from "@/hooks/useBudgets" import { useMonthParam } from "@/hooks/useMonthParam" import type { CategoryType } from "@/lib/types" 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 { CollapsibleSections } from "@/components/dashboard/CollapsibleSections" import QuickAddPicker from "@/components/QuickAddPicker" // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const EXPENSE_TYPES: CategoryType[] = [ "bill", "variable_expense", "debt", "saving", "investment", ] const CATEGORY_TYPES_ALL: CategoryType[] = [ "income", "bill", "variable_expense", "debt", "saving", "investment", ] // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function isOverBudget(type: CategoryType, budgeted: number, actual: number): boolean { if (type === "income" || type === "saving" || type === "investment") { return actual < budgeted // under-earned / under-saved } return actual > budgeted // overspent } // --------------------------------------------------------------------------- // Dashboard inner — rendered once a budget id is known // --------------------------------------------------------------------------- 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] ) const groupedSections = useMemo(() => CATEGORY_TYPES_ALL .map((type) => { const groupItems = items.filter((i) => i.category?.type === type) if (groupItems.length === 0) return null const budgeted = groupItems.reduce((s, i) => s + i.budgeted_amount, 0) const actual = groupItems.reduce((s, i) => s + i.actual_amount, 0) return { type, label: t(`categories.types.${type}`), items: groupItems, budgeted, actual, } }) .filter((g): g is NonNullable => g !== null), [items, t] ) const [openSections, setOpenSections] = useState>(() => Object.fromEntries( groupedSections.map((g) => [g.type, isOverBudget(g.type, g.budgeted, g.actual)]) ) ) // Note: state resets automatically on month navigation because DashboardContent // is keyed by budgetId in DashboardPage (key={budgetId} causes full remount) // ------------------------------------------------------------------ // Early returns after all hooks // ------------------------------------------------------------------ if (loading) return if (!budget) return null const handleToggleSection = (type: string, open: boolean) => { setOpenSections((prev) => ({ ...prev, [type]: open })) } const currency = budget.currency const availableBalance = totalIncome - totalExpenses + budget.carryover_amount const carryover = budget.carryover_amount const carryoverSubtitle = carryover !== 0 ? t("dashboard.carryoverIncludes", { amount: formatCurrency(Math.abs(carryover), currency) }) : undefined const carryoverIsNegative = carryover < 0 return (
{/* Summary cards */} = 0, carryoverSubtitle, carryoverIsNegative, }} t={t} /> {/* 3-column chart grid */}
{t("dashboard.expenseDonut")} {t("dashboard.incomeChart")} {t("dashboard.spendChart")}
{/* Collapsible category sections */} {groupedSections.length > 0 && ( )} {/* Quick Add button */}
) } // --------------------------------------------------------------------------- // DashboardPage // --------------------------------------------------------------------------- export default function DashboardPage() { const { t } = useTranslation() const { isFirstRun, loading: firstRunLoading } = useFirstRunState() const { month } = useMonthParam() const { budgets, loading, createBudget, generateFromTemplate } = useBudgets() const availableMonths = useMemo( () => budgets.map((b) => b.start_date.slice(0, 7)), [budgets] ) const currentBudget = useMemo( () => budgets.find((b) => b.start_date.startsWith(month)), [budgets, month] ) const [parsedYear, parsedMonth] = month.split("-").map(Number) if (firstRunLoading) return if (isFirstRun) return return ( } > {loading ? ( ) : !currentBudget ? ( /* No budget for the selected month */

{t("dashboard.noBudgetForMonth")}

) : ( )}
) }