From f30b846f04ed002889ca46b4ddcd314400901439 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 17 Mar 2026 15:07:56 +0100 Subject: [PATCH] feat(03-01): build CategorySection and CollapsibleSections components - CategorySection: presentational collapsible with left border accent, chevron, badges, 4-column table - Direction-aware difference logic: spending types (actual > budget = over), income/saving/investment (actual < budget = over) - Per-item and footer totals with color-coded difference column - CollapsibleSections: thin container rendering ordered CategorySection list with controlled open state - Both components accept t() as prop (presentational pattern) --- src/components/dashboard/CategorySection.tsx | 166 ++++++++++++++++++ .../dashboard/CollapsibleSections.tsx | 45 +++++ 2 files changed, 211 insertions(+) create mode 100644 src/components/dashboard/CategorySection.tsx create mode 100644 src/components/dashboard/CollapsibleSections.tsx diff --git a/src/components/dashboard/CategorySection.tsx b/src/components/dashboard/CategorySection.tsx new file mode 100644 index 0000000..74b3b58 --- /dev/null +++ b/src/components/dashboard/CategorySection.tsx @@ -0,0 +1,166 @@ +import { ChevronRight } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible" +import { + Table, + TableBody, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Badge } from "@/components/ui/badge" +import { categoryColors } from "@/lib/palette" +import { formatCurrency } from "@/lib/format" +import type { CategoryType, BudgetItem } from "@/lib/types" + +// Spending categories: over when actual > budget +// Income/saving/investment: over when actual < budget +const SPENDING_TYPES: CategoryType[] = ["bill", "variable_expense", "debt"] + +function isSpendingType(type: CategoryType): boolean { + return SPENDING_TYPES.includes(type) +} + +function computeDiff( + budgeted: number, + actual: number, + type: CategoryType +): { diff: number; isOver: boolean } { + if (isSpendingType(type)) { + return { + diff: budgeted - actual, + isOver: actual > budgeted, + } + } + return { + diff: actual - budgeted, + isOver: actual < budgeted, + } +} + +interface CategorySectionProps { + type: CategoryType + label: string + items: BudgetItem[] + budgeted: number + actual: number + currency: string + open: boolean + onOpenChange: (open: boolean) => void + t: (key: string, opts?: Record) => string +} + +export function CategorySection({ + type, + label, + items, + budgeted, + actual, + currency, + open, + onOpenChange, + t, +}: CategorySectionProps) { + const { diff: headerDiff, isOver: headerIsOver } = computeDiff(budgeted, actual, type) + + return ( + + + + + +
+ + + + {t("dashboard.sections.itemName")} + {t("budgets.budgeted")} + {t("budgets.actual")} + {t("budgets.difference")} + + + + {items.map((item) => { + const { diff: itemDiff, isOver: itemIsOver } = computeDiff( + item.budgeted_amount, + item.actual_amount, + type + ) + return ( + + + {item.category?.name ?? item.category_id} + + + {formatCurrency(item.budgeted_amount, currency)} + + + {formatCurrency(item.actual_amount, currency)} + + + {formatCurrency(Math.abs(itemDiff), currency)} + + + ) + })} + + + + + {t("dashboard.sections.groupTotal", { label })} + + + {formatCurrency(budgeted, currency)} + + + {formatCurrency(actual, currency)} + + + {formatCurrency(Math.abs(headerDiff), currency)} + + + +
+
+
+
+ ) +} diff --git a/src/components/dashboard/CollapsibleSections.tsx b/src/components/dashboard/CollapsibleSections.tsx new file mode 100644 index 0000000..a93f816 --- /dev/null +++ b/src/components/dashboard/CollapsibleSections.tsx @@ -0,0 +1,45 @@ +import { CategorySection } from "./CategorySection" +import type { CategoryType, BudgetItem } from "@/lib/types" + +interface GroupData { + type: CategoryType + label: string + items: BudgetItem[] + budgeted: number + actual: number +} + +interface CollapsibleSectionsProps { + groups: GroupData[] + currency: string + openSections: Record + onToggleSection: (type: string, open: boolean) => void + t: (key: string, opts?: Record) => string +} + +export function CollapsibleSections({ + groups, + currency, + openSections, + onToggleSection, + t, +}: CollapsibleSectionsProps) { + return ( +
+ {groups.map((group) => ( + onToggleSection(group.type, open)} + t={t} + /> + ))} +
+ ) +}