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

@@ -52,6 +52,21 @@ export function DashboardSkeleton() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Collapsible sections skeleton */}
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-3 rounded-md border-l-4 border-muted bg-card px-4 py-3">
<Skeleton className="size-4" />
<Skeleton className="h-4 w-32" />
<div className="ml-auto flex items-center gap-2">
<Skeleton className="h-5 w-24 rounded-full" />
<Skeleton className="h-5 w-24 rounded-full" />
<Skeleton className="h-4 w-16" />
</div>
</div>
))}
</div>
</div> </div>
) )
} }

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react" import { useState, useMemo } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { useBudgets, useBudgetDetail } from "@/hooks/useBudgets" import { useBudgets, useBudgetDetail } from "@/hooks/useBudgets"
import { useMonthParam } from "@/hooks/useMonthParam" import { useMonthParam } from "@/hooks/useMonthParam"
@@ -13,6 +13,7 @@ import { MonthNavigator } from "@/components/dashboard/MonthNavigator"
import { ExpenseDonutChart } from "@/components/dashboard/charts/ExpenseDonutChart" import { ExpenseDonutChart } from "@/components/dashboard/charts/ExpenseDonutChart"
import { IncomeBarChart } from "@/components/dashboard/charts/IncomeBarChart" import { IncomeBarChart } from "@/components/dashboard/charts/IncomeBarChart"
import { SpendBarChart } from "@/components/dashboard/charts/SpendBarChart" import { SpendBarChart } from "@/components/dashboard/charts/SpendBarChart"
import { CollapsibleSections } from "@/components/dashboard/CollapsibleSections"
import QuickAddPicker from "@/components/QuickAddPicker" import QuickAddPicker from "@/components/QuickAddPicker"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -27,6 +28,26 @@ const EXPENSE_TYPES: CategoryType[] = [
"investment", "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 // Dashboard inner — rendered once a budget id is known
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -114,6 +135,33 @@ function DashboardContent({ budgetId }: { budgetId: string }) {
[items, t] [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 // Early returns after all hooks
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@@ -121,6 +169,10 @@ function DashboardContent({ budgetId }: { budgetId: string }) {
if (loading) return <DashboardSkeleton /> if (loading) return <DashboardSkeleton />
if (!budget) return null if (!budget) return null
const handleToggleSection = (type: string, open: boolean) => {
setOpenSections((prev) => ({ ...prev, [type]: open }))
}
const currency = budget.currency const currency = budget.currency
const availableBalance = totalIncome - totalExpenses + budget.carryover_amount const availableBalance = totalIncome - totalExpenses + budget.carryover_amount
@@ -194,6 +246,17 @@ function DashboardContent({ budgetId }: { budgetId: string }) {
</Card> </Card>
</div> </div>
{/* Collapsible category sections */}
{groupedSections.length > 0 && (
<CollapsibleSections
groups={groupedSections}
currency={currency}
openSections={openSections}
onToggleSection={handleToggleSection}
t={t}
/>
)}
{/* Quick Add button */} {/* Quick Add button */}
<div className="flex justify-end"> <div className="flex justify-end">
<QuickAddPicker budgetId={budgetId} /> <QuickAddPicker budgetId={budgetId} />
@@ -264,7 +327,7 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
) : ( ) : (
<DashboardContent budgetId={currentBudget.id} /> <DashboardContent key={currentBudget.id} budgetId={currentBudget.id} />
)} )}
</PageShell> </PageShell>
) )