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