feat(cashflow): self-controlled treemap drill-down with breadcrumb to root
This commit is contained in:
parent
edaddc9c90
commit
44f578f060
|
|
@ -416,6 +416,7 @@
|
||||||
"Bitcoin": "Bitcoin",
|
"Bitcoin": "Bitcoin",
|
||||||
"Blue": "Azul",
|
"Blue": "Azul",
|
||||||
"Bolivian Boliviano": "Boliviano",
|
"Bolivian Boliviano": "Boliviano",
|
||||||
|
"Breadcrumb": "Ruta de navegación",
|
||||||
"British Pound": "Libra esterlina",
|
"British Pound": "Libra esterlina",
|
||||||
"Browse Files": "Explorar Archivos",
|
"Browse Files": "Explorar Archivos",
|
||||||
"Budget Name": "Nombre del Presupuesto",
|
"Budget Name": "Nombre del Presupuesto",
|
||||||
|
|
|
||||||
|
|
@ -394,6 +394,7 @@
|
||||||
"Bitcoin": "Bitcoin",
|
"Bitcoin": "Bitcoin",
|
||||||
"Blue": "Bleu",
|
"Blue": "Bleu",
|
||||||
"Bolivian Boliviano": "bolivien",
|
"Bolivian Boliviano": "bolivien",
|
||||||
|
"Breadcrumb": "Fil d'Ariane",
|
||||||
"British Pound": "Livre sterling",
|
"British Pound": "Livre sterling",
|
||||||
"Browse Files": "Parcourir les fichiers",
|
"Browse Files": "Parcourir les fichiers",
|
||||||
"Budget Name": "Nom du budget",
|
"Budget Name": "Nom du budget",
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ import { formatCurrency } from '@/utils/currency';
|
||||||
import { __ } from '@/utils/i18n';
|
import { __ } from '@/utils/i18n';
|
||||||
import { router } from '@inertiajs/react';
|
import { router } from '@inertiajs/react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||||
import { ResponsiveContainer, Tooltip, Treemap } from 'recharts';
|
import { ResponsiveContainer, Tooltip, Treemap } from 'recharts';
|
||||||
|
|
||||||
interface TreemapChartProps {
|
interface TreemapChartProps {
|
||||||
|
|
@ -26,7 +27,7 @@ interface TreemapDatum {
|
||||||
size: number;
|
size: number;
|
||||||
color: string;
|
color: string;
|
||||||
categoryId: string;
|
categoryId: string;
|
||||||
children?: TreemapDatum[];
|
hasChildren: boolean;
|
||||||
// Satisfies recharts' TreemapDataType index signature.
|
// Satisfies recharts' TreemapDataType index signature.
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
@ -73,19 +74,18 @@ function CategoryNode({
|
||||||
size = 0,
|
size = 0,
|
||||||
color = 'var(--color-chart-4)',
|
color = 'var(--color-chart-4)',
|
||||||
categoryId,
|
categoryId,
|
||||||
children,
|
hasChildren = false,
|
||||||
currency,
|
currency,
|
||||||
locale,
|
locale,
|
||||||
isPrivacyModeEnabled,
|
isPrivacyModeEnabled,
|
||||||
}: TreemapNodeProps) {
|
}: TreemapNodeProps) {
|
||||||
// depth 0 is the invisible root; only leaves/parents carry a category.
|
// depth 0 is the invisible root; only leaves carry a category.
|
||||||
if (depth < 1 || !categoryId) {
|
if (depth < 1 || !categoryId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const showName = width >= 40 && height >= 22;
|
const showName = width >= 40 && height >= 22;
|
||||||
const showAmount = width >= 55 && height >= 40;
|
const showAmount = width >= 55 && height >= 40;
|
||||||
const hasChildren = Array.isArray(children) && children.length > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g style={{ cursor: 'pointer' }}>
|
<g style={{ cursor: 'pointer' }}>
|
||||||
|
|
@ -176,10 +176,11 @@ export function TreemapChart({
|
||||||
const { isPrivacyModeEnabled } = usePrivacyMode();
|
const { isPrivacyModeEnabled } = usePrivacyMode();
|
||||||
const { categoryBarColor } = useChartColors();
|
const { categoryBarColor } = useChartColors();
|
||||||
|
|
||||||
// Nest mode needs the full tree upfront, so prefetch each parent's children.
|
// Self-controlled drill-down: recharts' native "nest" breadcrumb can't
|
||||||
// ponytail: one level deep — matches the old sankey; deeper nesting would
|
// offer a way back to the root, so we keep our own path + children cache
|
||||||
// need recursive fetching and rollUp already caps categories at depth 3.
|
// and render a flat treemap per level.
|
||||||
const [childrenByParent, setChildrenByParent] = useState<
|
const [path, setPath] = useState<Array<{ id: string; name: string }>>([]);
|
||||||
|
const [childrenById, setChildrenById] = useState<
|
||||||
Record<string, SankeyCategory[]>
|
Record<string, SankeyCategory[]>
|
||||||
>({});
|
>({});
|
||||||
|
|
||||||
|
|
@ -188,91 +189,29 @@ export function TreemapChart({
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!period) {
|
setPath([]);
|
||||||
return;
|
setChildrenById({});
|
||||||
}
|
}, [periodKey, mode]);
|
||||||
|
|
||||||
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<string, SankeyCategory[]> = {};
|
|
||||||
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<TreemapDatum[]>(() => {
|
const data = useMemo<TreemapDatum[]>(() => {
|
||||||
const toDatum = (
|
const current =
|
||||||
item: SankeyCategory,
|
path.length === 0
|
||||||
index: number,
|
? categories
|
||||||
): TreemapDatum => ({
|
: (childrenById[path[path.length - 1].id] ?? []);
|
||||||
name: item.category.name,
|
|
||||||
size: item.amount,
|
|
||||||
color: categoryBarColor(item.category.color, index),
|
|
||||||
categoryId: item.category_id ?? '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortPositive = (items: SankeyCategory[]): SankeyCategory[] =>
|
return [...current]
|
||||||
[...items]
|
.filter((item) => item.amount > 0)
|
||||||
.filter((item) => item.amount > 0)
|
.sort((a, b) => b.amount - a.amount)
|
||||||
.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 ?? '',
|
||||||
|
hasChildren: !!item.has_children,
|
||||||
|
}));
|
||||||
|
}, [categories, childrenById, path, categoryBarColor]);
|
||||||
|
|
||||||
return sortPositive(categories).map((item, index) => {
|
if (total === 0 || categories.length === 0) {
|
||||||
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -286,8 +225,38 @@ export function TreemapChart({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigate = (categoryId: unknown) => {
|
const drillInto = async (categoryId: string, name: string) => {
|
||||||
if (!categoryId || typeof categoryId !== 'string' || !period) {
|
if (!period) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(categoryId in childrenById)) {
|
||||||
|
try {
|
||||||
|
const from = format(period.from, 'yyyy-MM-dd');
|
||||||
|
const to = format(period.to, 'yyyy-MM-dd');
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/cashflow/sankey?from=${from}&to=${to}&parent=${categoryId}`,
|
||||||
|
);
|
||||||
|
const json: SankeyData = await response.json();
|
||||||
|
const kids =
|
||||||
|
mode === 'income'
|
||||||
|
? json.income_categories
|
||||||
|
: json.expense_categories;
|
||||||
|
setChildrenById((previous) => ({
|
||||||
|
...previous,
|
||||||
|
[categoryId]: kids,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch subcategories:', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setPath((previous) => [...previous, { id: categoryId, name }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigate = (categoryId: string) => {
|
||||||
|
if (!categoryId || !period) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -302,30 +271,68 @@ export function TreemapChart({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rootLabel = mode === 'income' ? __('Income') : __('Expenses');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn('w-full', className)}>
|
||||||
className={cn(
|
{path.length > 0 && (
|
||||||
'w-full',
|
<nav
|
||||||
// Recharts renders the nest breadcrumb with hardcoded inline
|
aria-label={__('Breadcrumb')}
|
||||||
// styles; override them to match the design system.
|
className="mb-3 flex flex-wrap items-center gap-1 text-sm"
|
||||||
'[&_.recharts-treemap-nest-index-box]:!mr-1 [&_.recharts-treemap-nest-index-box]:!rounded [&_.recharts-treemap-nest-index-box]:!bg-muted [&_.recharts-treemap-nest-index-box]:!px-2 [&_.recharts-treemap-nest-index-box]:!text-foreground',
|
>
|
||||||
className,
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPath([])}
|
||||||
|
className="cursor-pointer font-medium text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{rootLabel}
|
||||||
|
</button>
|
||||||
|
{path.map((item, index) => {
|
||||||
|
const isLast = index === path.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={item.id}>
|
||||||
|
<ChevronRight className="size-3.5 text-muted-foreground" />
|
||||||
|
{isLast ? (
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setPath(path.slice(0, index + 1))
|
||||||
|
}
|
||||||
|
className="cursor-pointer text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
<ResponsiveContainer width="100%" height={height}>
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
<Treemap
|
<Treemap
|
||||||
data={data}
|
data={data}
|
||||||
dataKey="size"
|
dataKey="size"
|
||||||
type="nest"
|
|
||||||
nodeGap={2}
|
nodeGap={2}
|
||||||
// ponytail: key by mode so a toggle switch re-lays out cleanly
|
// ponytail: key by level so switching mode/level re-lays out cleanly
|
||||||
key={mode}
|
key={`${mode}-${path.length}`}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
// In nest mode onClick fires on every node; parents zoom in,
|
|
||||||
// so only leaves navigate to their transactions.
|
|
||||||
onClick={(node) => {
|
onClick={(node) => {
|
||||||
if (!node.children) {
|
const id = node.categoryId;
|
||||||
navigate(node.categoryId);
|
|
||||||
|
if (typeof id !== 'string' || !id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.hasChildren) {
|
||||||
|
drillInto(id, String(node.name ?? ''));
|
||||||
|
} else {
|
||||||
|
navigate(id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
content={
|
content={
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue