diff --git a/resources/js/components/charts/treemap-chart.tsx b/resources/js/components/charts/treemap-chart.tsx index 011bad8b..a519eb4e 100644 --- a/resources/js/components/charts/treemap-chart.tsx +++ b/resources/js/components/charts/treemap-chart.tsx @@ -1,6 +1,6 @@ import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController'; import { usePrivacyMode } from '@/contexts/privacy-mode-context'; -import { SankeyCategory } from '@/hooks/use-cashflow-data'; +import { SankeyCategory, SankeyData } from '@/hooks/use-cashflow-data'; import { useChartColors } from '@/hooks/use-chart-color-scheme'; import { useLocale } from '@/hooks/use-locale'; import { cn } from '@/lib/utils'; @@ -8,8 +8,8 @@ import { formatCurrency } from '@/utils/currency'; import { __ } from '@/utils/i18n'; import { router } from '@inertiajs/react'; import { format } from 'date-fns'; -import { useMemo } from 'react'; -import { ResponsiveContainer, Treemap } from 'recharts'; +import { useEffect, useMemo, useState } from 'react'; +import { ResponsiveContainer, Tooltip, Treemap } from 'recharts'; interface TreemapChartProps { categories: SankeyCategory[]; @@ -26,6 +26,7 @@ interface TreemapDatum { size: number; color: string; categoryId: string; + children?: TreemapDatum[]; // Satisfies recharts' TreemapDataType index signature. [key: string]: unknown; } @@ -72,24 +73,22 @@ function CategoryNode({ size = 0, color = 'var(--color-chart-4)', categoryId, + children, currency, locale, isPrivacyModeEnabled, }: TreemapNodeProps) { - // depth 0 is the invisible root; only leaves carry a category. + // depth 0 is the invisible root; only leaves/parents carry a category. if (depth < 1 || !categoryId) { return null; } const showName = width >= 40 && height >= 22; const showAmount = width >= 55 && height >= 40; + const hasChildren = Array.isArray(children) && children.length > 0; return ( - - {name}:{' '} - {maskIfPrivate(size, currency, locale, isPrivacyModeEnabled)} - - {truncate(name, width)} + {truncate(hasChildren ? `${name} ›` : name, width)} )} {showAmount && ( @@ -128,6 +127,42 @@ function CategoryNode({ ); } +interface TreemapTooltipProps { + active?: boolean; + payload?: Array<{ payload?: TreemapDatum }>; + currency: string; + locale: string; + isPrivacyModeEnabled: boolean; +} + +function TreemapTooltip({ + active, + payload, + currency, + locale, + isPrivacyModeEnabled, +}: TreemapTooltipProps) { + const node = payload?.[0]?.payload; + + if (!active || !node?.name) { + return null; + } + + return ( +
+
{node.name}
+
+ {maskIfPrivate( + node.size, + currency, + locale, + isPrivacyModeEnabled, + )} +
+
+ ); +} + export function TreemapChart({ categories, total, @@ -141,17 +176,101 @@ export function TreemapChart({ const { isPrivacyModeEnabled } = usePrivacyMode(); const { categoryBarColor } = useChartColors(); + // Nest mode needs the full tree upfront, so prefetch each parent's children. + // ponytail: one level deep — matches the old sankey; deeper nesting would + // need recursive fetching and rollUp already caps categories at depth 3. + const [childrenByParent, setChildrenByParent] = useState< + Record + >({}); + + const periodKey = period + ? `${period.from.getTime()}-${period.to.getTime()}` + : ''; + + useEffect(() => { + if (!period) { + return; + } + + const parents = categories.filter( + (item) => item.has_children && item.category_id, + ); + + if (parents.length === 0) { + setChildrenByParent({}); + return; + } + + const from = format(period.from, 'yyyy-MM-dd'); + const to = format(period.to, 'yyyy-MM-dd'); + let cancelled = false; + + Promise.all( + parents.map(async (parent) => { + const response = await fetch( + `/api/cashflow/sankey?from=${from}&to=${to}&parent=${parent.category_id}`, + ); + const json: SankeyData = await response.json(); + const kids = + mode === 'income' + ? json.income_categories + : json.expense_categories; + + return [parent.category_id, kids] as const; + }), + ) + .then((entries) => { + if (cancelled) { + return; + } + + const next: Record = {}; + entries.forEach(([id, kids]) => { + next[id] = kids; + }); + setChildrenByParent(next); + }) + .catch((error) => { + console.error('Failed to fetch subcategories:', error); + }); + + return () => { + cancelled = true; + }; + // categoryBarColor is intentionally excluded: it changes identity every + // render and colours are applied in the memo below, not here. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [categories, periodKey, mode]); + const data = useMemo(() => { - return [...categories] - .filter((item) => item.amount > 0) - .sort((a, b) => b.amount - a.amount) - .map((item, index) => ({ - name: item.category.name, - size: item.amount, - color: categoryBarColor(item.category.color, index), - categoryId: item.category.id, - })); - }, [categories, categoryBarColor]); + const toDatum = ( + item: SankeyCategory, + index: number, + ): TreemapDatum => ({ + name: item.category.name, + size: item.amount, + color: categoryBarColor(item.category.color, index), + categoryId: item.category_id ?? '', + }); + + const sortPositive = (items: SankeyCategory[]): SankeyCategory[] => + [...items] + .filter((item) => item.amount > 0) + .sort((a, b) => b.amount - a.amount); + + return sortPositive(categories).map((item, index) => { + const datum = toDatum(item, index); + const kids = item.category_id + ? childrenByParent[item.category_id] + : undefined; + + if (kids && kids.length > 0) { + datum.children = sortPositive(kids).map(toDatum); + } + + return datum; + }); + }, [categories, childrenByParent, categoryBarColor]); if (total === 0 || data.length === 0) { return ( @@ -168,7 +287,7 @@ export function TreemapChart({ } const navigate = (categoryId: unknown) => { - if (typeof categoryId !== 'string' || !period) { + if (!categoryId || typeof categoryId !== 'string' || !period) { return; } @@ -184,15 +303,31 @@ export function TreemapChart({ }; return ( -
+
navigate(node.categoryId)} + // In nest mode onClick fires on every node; parents zoom in, + // so only leaves navigate to their transactions. + onClick={(node) => { + if (!node.children) { + navigate(node.categoryId); + } + }} content={ } - /> + > + + } + /> +
);