From a533e06f8c68a2f8f1d0fd4f97d63a7dbd806243 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 16 Mar 2026 12:20:21 +0100 Subject: [PATCH] feat(01-02): integrate PageShell, SummaryStrip, and DashboardSkeleton into DashboardPage - Replace inline SummaryCard with SummaryStrip component (responsive 3-card grid) - Replace inline h1 header with PageShell wrapper - Replace loading null returns with DashboardSkeleton pulse animation - Replace hardcoded green/red color classes with semantic tokens (text-on-budget, text-over-budget, bg-on-budget, bg-over-budget) - Derive budgetedIncome/budgetedExpenses for variance display Co-Authored-By: Claude Opus 4.6 --- src/pages/DashboardPage.tsx | 294 ++++++++++++++++++++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 src/pages/DashboardPage.tsx diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..6f8c97b --- /dev/null +++ b/src/pages/DashboardPage.tsx @@ -0,0 +1,294 @@ +import { Link } from "react-router-dom" +import { useTranslation } from "react-i18next" +import { + PieChart, + Pie, + Cell, + ResponsiveContainer, + Tooltip, +} from "recharts" +import { useBudgets, useBudgetDetail } from "@/hooks/useBudgets" +import type { CategoryType } from "@/lib/types" +import { categoryColors } from "@/lib/palette" +import { formatCurrency } from "@/lib/format" +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 QuickAddPicker from "@/components/QuickAddPicker" + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const EXPENSE_TYPES: CategoryType[] = [ + "bill", + "variable_expense", + "debt", + "saving", + "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 +// --------------------------------------------------------------------------- + +function DashboardContent({ budgetId }: { budgetId: string }) { + const { t } = useTranslation() + const { budget, items, loading } = useBudgetDetail(budgetId) + + 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 */} + = 0, + }} + t={t} + /> + + {/* Expense breakdown chart + category progress */} +
+ {/* Pie chart */} + {pieData.length > 0 && ( + + + + {t("dashboard.expenseBreakdown")} + + + + + + + {pieData.map((entry) => ( + + ))} + + + formatCurrency(Number(value), currency) + } + /> + + + + {/* Legend */} +
    + {pieData.map((entry) => ( +
  • + + {entry.name} + + {formatCurrency(entry.value, currency)} + +
  • + ))} +
+
+
+ )} + + {/* 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) + + return ( +
  • +
    + + + {t(`categories.types.${group.type}`)} + + + {formatCurrency(group.actual, currency)} + {" / "} + {formatCurrency(group.budgeted, currency)} + {" "} + ({group.pct}%) + +
    + + {/* Progress bar */} +
    +
    +
    +
  • + ) + })} +
+
+
+ )} +
+
+ ) +} + +// --------------------------------------------------------------------------- +// DashboardPage +// --------------------------------------------------------------------------- + +export default function DashboardPage() { + const { t } = useTranslation() + const { budgets, loading } = 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) + ) + + if (loading) return ( + + + + ) + + return ( + + {!currentBudget ? ( + /* No budget for this month */ +
+

{t("dashboard.noBudget")}

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