feat(03-02): wire collapsible sections into DashboardContent

- Add CATEGORY_TYPES_ALL constant and isOverBudget direction-aware helper
- Derive groupedSections via useMemo (filters empty groups, computes totals)
- Initialize openSections state with smart defaults (over-budget expanded)
- State resets on month navigation via key={budgetId} on DashboardContent
- Insert CollapsibleSections between chart grid and QuickAdd
- Add skeleton placeholders for collapsible sections area in DashboardSkeleton

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 15:13:41 +01:00
parent 7d08da20ce
commit 9a8d13fcfb
2 changed files with 80 additions and 2 deletions

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react"
import { useState, useMemo } from "react"
import { useTranslation } from "react-i18next"
import { useBudgets, useBudgetDetail } from "@/hooks/useBudgets"
import { useMonthParam } from "@/hooks/useMonthParam"
@@ -13,6 +13,7 @@ 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"
// ---------------------------------------------------------------------------
@@ -27,6 +28,26 @@ const EXPENSE_TYPES: CategoryType[] = [
"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
// ---------------------------------------------------------------------------
@@ -114,6 +135,33 @@ function DashboardContent({ budgetId }: { budgetId: string }) {
[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
// ------------------------------------------------------------------
@@ -121,6 +169,10 @@ function DashboardContent({ budgetId }: { budgetId: string }) {
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
@@ -194,6 +246,17 @@ function DashboardContent({ budgetId }: { budgetId: string }) {
</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} />
@@ -264,7 +327,7 @@ export default function DashboardPage() {
</div>
</div>
) : (
<DashboardContent budgetId={currentBudget.id} />
<DashboardContent key={currentBudget.id} budgetId={currentBudget.id} />
)}
</PageShell>
)