feat(02-03): refactor DashboardPage with month navigation and 3-column chart grid

- Replace hardcoded current month with useMonthParam (URL search params)
- Add MonthNavigator in PageShell action slot
- Replace old recharts pie + progress bars with ExpenseDonutChart, IncomeBarChart, SpendBarChart
- Add empty-month prompt with create/generate buttons
- Memoize all derived data with useMemo
- Move QuickAddPicker below chart grid per plan
This commit is contained in:
2026-03-16 14:21:55 +01:00
parent ddcddbef56
commit 01674e18fb

View File

@@ -1,20 +1,18 @@
import { Link } from "react-router-dom" import { useMemo } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
Tooltip,
} from "recharts"
import { useBudgets, useBudgetDetail } from "@/hooks/useBudgets" import { useBudgets, useBudgetDetail } from "@/hooks/useBudgets"
import { useMonthParam } from "@/hooks/useMonthParam"
import type { CategoryType } from "@/lib/types" import type { CategoryType } from "@/lib/types"
import { categoryColors } from "@/lib/palette"
import { formatCurrency } from "@/lib/format" import { formatCurrency } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { PageShell } from "@/components/shared/PageShell" import { PageShell } from "@/components/shared/PageShell"
import { SummaryStrip } from "@/components/dashboard/SummaryStrip" import { SummaryStrip } from "@/components/dashboard/SummaryStrip"
import { DashboardSkeleton } from "@/components/dashboard/DashboardSkeleton" 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" import QuickAddPicker from "@/components/QuickAddPicker"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -29,18 +27,6 @@ const EXPENSE_TYPES: CategoryType[] = [
"investment", "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 // Dashboard inner — rendered once a budget id is known
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -49,67 +35,97 @@ function DashboardContent({ budgetId }: { budgetId: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { budget, items, loading } = useBudgetDetail(budgetId) 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 <DashboardSkeleton /> if (loading) return <DashboardSkeleton />
if (!budget) return null if (!budget) return null
const currency = budget.currency 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 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Quick Add button */}
<div className="flex justify-end">
<QuickAddPicker budgetId={budgetId} />
</div>
{/* Summary cards */} {/* Summary cards */}
<SummaryStrip <SummaryStrip
income={{ income={{
@@ -127,123 +143,52 @@ function DashboardContent({ budgetId }: { budgetId: string }) {
t={t} t={t}
/> />
{/* Expense breakdown chart + category progress */} {/* 3-column chart grid */}
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{/* Pie chart */}
{pieData.length > 0 && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base"> <CardTitle className="text-base">{t("dashboard.expenseDonut")}</CardTitle>
{t("dashboard.expenseBreakdown")}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ResponsiveContainer width="100%" height={240}> <ExpenseDonutChart
<PieChart>
<Pie
data={pieData} data={pieData}
dataKey="value" totalExpenses={totalExpenses}
nameKey="name" currency={currency}
cx="50%" emptyMessage={t("dashboard.noData")}
cy="50%"
outerRadius={90}
innerRadius={48}
>
{pieData.map((entry) => (
<Cell
key={entry.type}
fill={categoryColors[entry.type]}
/> />
))}
</Pie>
<Tooltip
formatter={(value) =>
formatCurrency(Number(value), currency)
}
/>
</PieChart>
</ResponsiveContainer>
{/* Legend */}
<ul className="mt-2 space-y-1">
{pieData.map((entry) => (
<li key={entry.type} className="flex items-center gap-2 text-sm">
<span
className="inline-block size-3 shrink-0 rounded-full"
style={{ backgroundColor: categoryColors[entry.type] }}
/>
<span className="text-muted-foreground">{entry.name}</span>
<span className="ml-auto tabular-nums font-medium">
{formatCurrency(entry.value, currency)}
</span>
</li>
))}
</ul>
</CardContent> </CardContent>
</Card> </Card>
)}
{/* Category progress */}
{progressGroups.length > 0 && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base"> <CardTitle className="text-base">{t("dashboard.incomeChart")}</CardTitle>
{t("dashboard.expenseBreakdown")}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ul className="space-y-4"> <IncomeBarChart
{progressGroups.map((group) => { data={incomeBarData}
if (!group) return null currency={currency}
const barColor = group.overBudget emptyMessage={t("dashboard.noData")}
? "bg-over-budget"
: "bg-on-budget"
const clampedPct = Math.min(group.pct, 100)
return (
<li key={group.type} className="space-y-1.5">
<div className="flex items-center gap-2">
<span
className="inline-block size-3 shrink-0 rounded-full"
style={{ backgroundColor: categoryColors[group.type] }}
/> />
<span className="text-sm font-medium">
{t(`categories.types.${group.type}`)}
</span>
<span
className={`ml-auto text-xs tabular-nums ${
group.overBudget
? "text-over-budget"
: "text-muted-foreground"
}`}
>
{formatCurrency(group.actual, currency)}
{" / "}
{formatCurrency(group.budgeted, currency)}
{" "}
({group.pct}%)
</span>
</div>
{/* Progress bar */}
<div className="h-2 w-full rounded-full bg-muted">
<div
className={`h-2 rounded-full transition-all ${barColor}`}
style={{ width: `${clampedPct}%` }}
role="progressbar"
aria-valuenow={group.pct}
aria-valuemin={0}
aria-valuemax={100}
aria-label={t(`categories.types.${group.type}`)}
/>
</div>
</li>
)
})}
</ul>
</CardContent> </CardContent>
</Card> </Card>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">{t("dashboard.spendChart")}</CardTitle>
</CardHeader>
<CardContent>
<SpendBarChart
data={spendBarData}
currency={currency}
emptyMessage={t("dashboard.noData")}
/>
</CardContent>
</Card>
</div>
{/* Quick Add button */}
<div className="flex justify-end">
<QuickAddPicker budgetId={budgetId} />
</div> </div>
</div> </div>
) )
@@ -255,36 +200,60 @@ function DashboardContent({ budgetId }: { budgetId: string }) {
export default function DashboardPage() { export default function DashboardPage() {
const { t } = useTranslation() 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 availableMonths = useMemo(
const now = new Date() () => budgets.map((b) => b.start_date.slice(0, 7)),
const year = now.getFullYear() [budgets]
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 ( const currentBudget = useMemo(
<PageShell title={t("dashboard.title")}> () => budgets.find((b) => b.start_date.startsWith(month)),
<DashboardSkeleton /> [budgets, month]
</PageShell>
) )
const [parsedYear, parsedMonth] = month.split("-").map(Number)
return ( return (
<PageShell title={t("dashboard.title")}> <PageShell
{!currentBudget ? ( title={t("dashboard.title")}
/* No budget for this month */ action={<MonthNavigator availableMonths={availableMonths} t={t} />}
<div className="flex flex-col items-center gap-4 py-20 text-center">
<p className="text-muted-foreground">{t("dashboard.noBudget")}</p>
<Link
to="/budgets"
className="text-sm underline underline-offset-4 hover:text-foreground"
> >
{t("budgets.newBudget")} {loading ? (
</Link> <DashboardSkeleton />
) : !currentBudget ? (
/* No budget for the selected month */
<div className="flex flex-col items-center gap-4 py-20 text-center">
<p className="text-muted-foreground">{t("dashboard.noBudgetForMonth")}</p>
<div className="flex gap-3">
<Button
variant="default"
onClick={() =>
createBudget.mutate({
month: parsedMonth,
year: parsedYear,
currency: "EUR",
})
}
disabled={createBudget.isPending}
>
{t("dashboard.createBudget")}
</Button>
<Button
variant="outline"
onClick={() =>
generateFromTemplate.mutate({
month: parsedMonth,
year: parsedYear,
currency: "EUR",
})
}
disabled={generateFromTemplate.isPending}
>
{t("dashboard.generateFromTemplate")}
</Button>
</div>
</div> </div>
) : ( ) : (
<DashboardContent budgetId={currentBudget.id} /> <DashboardContent budgetId={currentBudget.id} />