- /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)
341 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|