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