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)
This commit is contained in:
2026-03-17 15:07:56 +01:00
parent 21ce6d8230
commit f30b846f04
2 changed files with 211 additions and 0 deletions

View File

@@ -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, unknown>) => string
}
export function CategorySection({
type,
label,
items,
budgeted,
actual,
currency,
open,
onOpenChange,
t,
}: CategorySectionProps) {
const { diff: headerDiff, isOver: headerIsOver } = computeDiff(budgeted, actual, type)
return (
<Collapsible open={open} onOpenChange={onOpenChange}>
<CollapsibleTrigger asChild>
<button
className="group flex w-full items-center gap-3 rounded-md border-l-4 bg-card px-4 py-3 text-left hover:bg-muted/40 transition-colors"
style={{ borderLeftColor: categoryColors[type] }}
>
<ChevronRight
className="size-4 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-90"
aria-hidden
/>
<span className="font-medium">{label}</span>
<div className="ml-auto flex items-center gap-2">
<Badge variant="outline" className="tabular-nums">
{t("budgets.budgeted")} {formatCurrency(budgeted, currency)}
</Badge>
<Badge variant="secondary" className="tabular-nums">
{t("budgets.actual")} {formatCurrency(actual, currency)}
</Badge>
<span
className={cn(
"text-sm tabular-nums font-medium",
headerIsOver ? "text-over-budget" : "text-on-budget"
)}
>
{formatCurrency(Math.abs(headerDiff), currency)}
</span>
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-collapsible-open data-[state=closed]:animate-collapsible-close">
<div className="pt-2">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("dashboard.sections.itemName")}</TableHead>
<TableHead className="text-right">{t("budgets.budgeted")}</TableHead>
<TableHead className="text-right">{t("budgets.actual")}</TableHead>
<TableHead className="text-right">{t("budgets.difference")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => {
const { diff: itemDiff, isOver: itemIsOver } = computeDiff(
item.budgeted_amount,
item.actual_amount,
type
)
return (
<TableRow key={item.id}>
<TableCell className="font-medium">
{item.category?.name ?? item.category_id}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatCurrency(item.budgeted_amount, currency)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatCurrency(item.actual_amount, currency)}
</TableCell>
<TableCell
className={cn(
"text-right tabular-nums",
itemIsOver ? "text-over-budget" : "text-muted-foreground"
)}
>
{formatCurrency(Math.abs(itemDiff), currency)}
</TableCell>
</TableRow>
)
})}
</TableBody>
<TableFooter>
<TableRow>
<TableCell className="font-medium">
{t("dashboard.sections.groupTotal", { label })}
</TableCell>
<TableCell className="text-right tabular-nums font-medium">
{formatCurrency(budgeted, currency)}
</TableCell>
<TableCell className="text-right tabular-nums font-medium">
{formatCurrency(actual, currency)}
</TableCell>
<TableCell
className={cn(
"text-right tabular-nums font-medium",
headerIsOver ? "text-over-budget" : "text-on-budget"
)}
>
{formatCurrency(Math.abs(headerDiff), currency)}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
</CollapsibleContent>
</Collapsible>
)
}

View File

@@ -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<string, boolean>
onToggleSection: (type: string, open: boolean) => void
t: (key: string, opts?: Record<string, unknown>) => string
}
export function CollapsibleSections({
groups,
currency,
openSections,
onToggleSection,
t,
}: CollapsibleSectionsProps) {
return (
<div className="space-y-3">
{groups.map((group) => (
<CategorySection
key={group.type}
type={group.type}
label={group.label}
items={group.items}
budgeted={group.budgeted}
actual={group.actual}
currency={currency}
open={openSections[group.type] ?? false}
onOpenChange={(open) => onToggleSection(group.type, open)}
t={t}
/>
))}
</div>
)
}