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:
@@ -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} />
|
||||||
|
|||||||
Reference in New Issue
Block a user