Files
SimpleFinanceDash/src/pages/DashboardPage.tsx
Jean-Luc Makiola 6b75f14361 feat(07-02): register /setup route and add first-run redirect
- /setup route as protected standalone page (outside AppLayout)
- DashboardPage redirects first-run users to /setup via useFirstRunState
- All hooks called before conditional returns (React rules of hooks)
2026-04-20 21:10:58 +02:00

341 lines
11 KiB
TypeScript

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<typeof g> => g !== null),
[items, t]
)
const [openSections, setOpenSections] = useState<Record<string, boolean>>(() =>
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 <DashboardSkeleton />
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 (
<div className="space-y-8">
{/* Summary cards */}
<SummaryStrip
income={{
value: formatCurrency(totalIncome, currency),
budgeted: formatCurrency(budgetedIncome, currency),
}}
expenses={{
value: formatCurrency(totalExpenses, currency),
budgeted: formatCurrency(budgetedExpenses, currency),
}}
balance={{
value: formatCurrency(availableBalance, currency),
isPositive: availableBalance >= 0,
carryoverSubtitle,
carryoverIsNegative,
}}
t={t}
/>
{/* 3-column chart grid */}
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader>
<CardTitle className="text-base">{t("dashboard.expenseDonut")}</CardTitle>
</CardHeader>
<CardContent>
<ExpenseDonutChart
data={pieData}
totalExpenses={totalExpenses}
currency={currency}
emptyMessage={t("dashboard.noData")}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("dashboard.incomeChart")}</CardTitle>
</CardHeader>
<CardContent>
<IncomeBarChart
data={incomeBarData}
currency={currency}
emptyMessage={t("dashboard.noData")}
/>
</CardContent>
</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>
{/* Collapsible category sections */}
{groupedSections.length > 0 && (
<CollapsibleSections
groups={groupedSections}
currency={currency}
openSections={openSections}
onToggleSection={handleToggleSection}
t={t}
/>
)}
{/* Quick Add button */}
<div className="flex justify-end">
<QuickAddPicker budgetId={budgetId} />
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// 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 <DashboardSkeleton />
if (isFirstRun) return <Navigate to="/setup" replace />
return (
<PageShell
title={t("dashboard.title")}
action={<MonthNavigator availableMonths={availableMonths} t={t} />}
>
{loading ? (
<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>
) : (
<DashboardContent key={currentBudget.id} budgetId={currentBudget.id} />
)}
</PageShell>
)
}