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:
166
src/components/dashboard/CategorySection.tsx
Normal file
166
src/components/dashboard/CategorySection.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
src/components/dashboard/CollapsibleSections.tsx
Normal file
45
src/components/dashboard/CollapsibleSections.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user