import React, { useState } from "react";
import { ChevronDown, ChevronRight, ListFilter } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "./card";
import { Spinner } from "./spinner";
import { InfoTooltip } from "./info-tooltip";
import { cn } from "@/lib/utils";
import { formatCurrency } from "@/lib/format-currency";
// ─── Types ───────────────────────────────────────────────────────────────────
export interface ExpenseSubCategory {
id: string;
name: string;
/** Absolute dollar amount */
amount: number;
/** Percentage of parent category 0–100 */
pct: number;
}
export interface ExpenseCategoryItem {
id: string;
name: string;
/** Absolute dollar amount */
amount: number;
/** Percentage of total budget 0–100 */
pct: number;
/** Optional lucide-react icon element */
icon?: React.ReactNode;
subCategories?: ExpenseSubCategory[];
}
export interface DashboardExpenseCategoriesProps {
categories?: ExpenseCategoryItem[];
title?: string;
subtitle?: string;
/**
* Optional explanatory note shown as an info-icon tooltip next to the subtitle
* (e.g. the Income bucket's internal-transfer caveat). Omit to hide the icon.
*/
tooltipText?: string;
/**
* Message shown when there are no categories to display. Lets each bucket give
* a specific empty state (e.g. "No income categories available").
*/
emptyMessage?: string;
isLoading?: boolean;
className?: string;
/** Currently active filter category ID (parent or sub-category) */
selectedCategoryId?: string | null;
/** Called with the category ID to filter by, or null to clear */
onCategorySelect?: (id: string | null) => void;
/**
* Color scheme for progress bars and active states.
* Use "secondary" for the Expense tab to distinguish from Income (primary).
* Defaults to "primary".
*/
colorScheme?: "primary" | "secondary";
}
// ─── Constants ────────────────────────────────────────────────────────────────
const SECONDARY_ACTIVE_BG =
"color-mix(in oklch, var(--brand-secondary) 8%, transparent)";
const SECONDARY_ACTIVE_HOVER_BG =
"color-mix(in oklch, var(--brand-secondary) 12%, transparent)";
// ─── Progress bar ─────────────────────────────────────────────────────────────
function ProgressBar({
pct,
className,
colorScheme = "primary",
}: {
pct: number;
className?: string;
colorScheme?: "primary" | "secondary";
}) {
return (
);
}
// ─── Sub-category row ─────────────────────────────────────────────────────────
function SubCategoryRow({
sub,
isSelected,
colorScheme = "primary",
onClick,
}: {
sub: ExpenseSubCategory;
isSelected: boolean;
colorScheme?: "primary" | "secondary";
onClick: () => void;
}) {
const isSecondary = colorScheme === "secondary";
return (
{sub.name}
{formatCurrency(sub.amount)}
);
}
// ─── Category row ─────────────────────────────────────────────────────────────
function CategoryRow({
item,
selectedCategoryId,
colorScheme = "primary",
onFilterClick,
}: {
item: ExpenseCategoryItem;
selectedCategoryId?: string | null;
colorScheme?: "primary" | "secondary";
onFilterClick: (id: string | null) => void;
}) {
const [expanded, setExpanded] = useState(false);
const [hovered, setHovered] = useState(false);
const hasChildren = (item.subCategories?.length ?? 0) > 0;
const isSelected = selectedCategoryId === item.id;
const isChildSelected =
hasChildren &&
(item.subCategories?.some((s) => s.id === selectedCategoryId) ?? false);
const isActive = isSelected || isChildSelected;
const isSecondary = colorScheme === "secondary";
// Secondary scheme active background is set via inline style (color-mix not
// expressible as a Tailwind arbitrary value here). Hover darkens the tint.
const wrapperStyle =
isSecondary && isActive
? {
backgroundColor: hovered
? SECONDARY_ACTIVE_HOVER_BG
: SECONDARY_ACTIVE_BG,
}
: undefined;
return (
{/* Main row — click expands/collapses; filter icon inside triggers filter */}
{/* Sub-categories */}
{hasChildren && expanded && (
{item.subCategories!.map((sub) => (
onFilterClick(selectedCategoryId === sub.id ? null : sub.id)
}
/>
))}
)}
);
}
// ─── Component ────────────────────────────────────────────────────────────────
export function DashboardExpenseCategories({
categories = [],
title = "Expenses Categories",
subtitle,
tooltipText,
emptyMessage = "No expense categories available",
isLoading = false,
className,
selectedCategoryId,
onCategorySelect,
colorScheme = "primary",
}: DashboardExpenseCategoriesProps) {
return (
{title}
{subtitle && (
{subtitle}
{tooltipText && }
)}
{isLoading ? (
) : categories.length === 0 ? (
{emptyMessage}
) : (
{categories.map((item) => (
onCategorySelect?.(id)}
/>
))}
)}
);
}