feat: expand Sankey subcategories inline (#485)
## What Clicking a parent category in the cashflow **Money Flow** Sankey now expands its children inline as an extra column branching off the parent — expenses grow to the right, income to the left — instead of swapping the whole chart for a drilled-in view. <img width="782" height="392" alt="image" src="https://github.com/user-attachments/assets/6ce7d925-19d2-4744-a5da-6884a9583a4d" /> ## Behaviour - Toggle: click a parent to expand, click again to collapse. Multiple parents can be open at once. - One level deep (a child with its own children is not further expandable). - The parent stays as an intermediate node and its flow splits into the children + a node for transactions booked directly on the parent (labelled **Parent**). - Changing the period resets expansions. ## Implementation - `sankey-chart.tsx`: dynamic column layout (was fixed 3-column); lazily fetches children via the existing `/api/cashflow/sankey?parent=` endpoint and caches them; label + amount now render in a `foreignObject` so flexbox handles spacing, vertical centering, and CSS-ellipsis truncation (full name kept in a `title`); expand affordance is a `ChevronsRight` icon. - Child nodes use the same `MIN_NODE_HEIGHT` and gap as top-level nodes, centered on the parent so links fan out cleanly. - Removed the old drill-replace + breadcrumb flow (and the now-unused `sankeyParent` hook option). - Backend: the direct-transactions node is renamed from `"<name> (direct)"` to `"Parent"` (+ `es` translation).
This commit is contained in:
parent
e3cbbb2e0d
commit
679c8d7bff
|
|
@ -508,7 +508,7 @@ class CashflowAnalyticsController extends Controller
|
|||
*
|
||||
* With no drill target, every amount folds into its top-level ancestor.
|
||||
* When drilling into a parent, the parent's children become the nodes (each
|
||||
* rolled up over its own subtree) plus a "(direct)" node for transactions
|
||||
* rolled up over its own subtree) plus a "Parent" node for transactions
|
||||
* sitting on the parent itself. Items outside the drilled subtree drop out.
|
||||
*
|
||||
* @param array<int, array{category_id: ?string, category: Category|null, amount: int}> $categorized
|
||||
|
|
@ -561,7 +561,7 @@ class CashflowAnalyticsController extends Controller
|
|||
$key = $target['id'].':direct';
|
||||
$category = (new Category)->forceFill([
|
||||
'id' => $displayCategory->id,
|
||||
'name' => $displayCategory->name.' ('.__('direct').')',
|
||||
'name' => __('Parent'),
|
||||
'icon' => $displayCategory->icon,
|
||||
'color' => $displayCategory->color,
|
||||
'type' => $displayCategory->type,
|
||||
|
|
|
|||
|
|
@ -1042,6 +1042,7 @@
|
|||
"Owed amount evolution": "Evolución del monto adeudado",
|
||||
"Page": "Página",
|
||||
"Paraguayan Guarani": "Guaraní paraguayo",
|
||||
"Parent": "Padre",
|
||||
"Parent category": "Categoría padre",
|
||||
"Password": "Contraseña",
|
||||
"Password changes are disabled on the demo account.": "Los cambios de contraseña están deshabilitados en la cuenta demo.",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,155 @@
|
|||
import { SankeyData } from '@/hooks/use-cashflow-data';
|
||||
import { Category } from '@/types/category';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SankeyChart } from './sankey-chart';
|
||||
|
||||
vi.mock('@/contexts/privacy-mode-context', () => ({
|
||||
usePrivacyMode: () => ({ isPrivacyModeEnabled: false }),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-locale', () => ({
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
vi.mock('@inertiajs/react', () => ({
|
||||
router: { visit: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@/actions/App/Http/Controllers/TransactionController', () => ({
|
||||
index: () => ({ url: '/transactions' }),
|
||||
}));
|
||||
|
||||
function category(id: string, name: string, color = '#ccc'): Category {
|
||||
return { id, name, color } as Category;
|
||||
}
|
||||
|
||||
const data: SankeyData = {
|
||||
income_categories: [
|
||||
{
|
||||
category: category('salary', 'Salary'),
|
||||
category_id: 'salary',
|
||||
amount: 1000,
|
||||
},
|
||||
],
|
||||
expense_categories: [
|
||||
{
|
||||
category: category('food', 'Food'),
|
||||
category_id: 'food',
|
||||
amount: 310,
|
||||
has_children: true,
|
||||
},
|
||||
{
|
||||
category: category('rent', 'Rent'),
|
||||
category_id: 'rent',
|
||||
amount: 500,
|
||||
},
|
||||
],
|
||||
total_income: 1000,
|
||||
total_expense: 810,
|
||||
};
|
||||
|
||||
const foodChildren: SankeyData = {
|
||||
income_categories: [],
|
||||
expense_categories: [
|
||||
{
|
||||
category: category('groceries', 'Groceries'),
|
||||
category_id: 'groceries',
|
||||
amount: 200,
|
||||
},
|
||||
{
|
||||
category: category('other-groceries', 'Other groceries'),
|
||||
category_id: 'other-groceries',
|
||||
amount: 110,
|
||||
},
|
||||
],
|
||||
total_income: 0,
|
||||
total_expense: 310,
|
||||
};
|
||||
|
||||
const period = {
|
||||
from: new Date('2026-06-01'),
|
||||
to: new Date('2026-06-30'),
|
||||
};
|
||||
|
||||
describe('SankeyChart', () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
json: async () => foodChildren,
|
||||
}) as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders an expand affordance on parent categories with children', () => {
|
||||
render(<SankeyChart data={data} period={period} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Expand Food' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands a category into its subcategories without replacing the chart', async () => {
|
||||
render(<SankeyChart data={data} period={period} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand Food' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Groceries')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Other groceries')).toBeInTheDocument();
|
||||
// The original chart is untouched: center + parent nodes remain.
|
||||
expect(screen.getByText('Cashflow')).toBeInTheDocument();
|
||||
expect(screen.getByText('Food')).toBeInTheDocument();
|
||||
expect(screen.getByText('Rent')).toBeInTheDocument();
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('parent=food'),
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates long category names with an ellipsis and keeps the full name as a title', () => {
|
||||
const longName = 'Sports, and sport goods and equipment rentals';
|
||||
render(
|
||||
<SankeyChart
|
||||
data={{
|
||||
...data,
|
||||
expense_categories: [
|
||||
{
|
||||
category: category('long', longName),
|
||||
category_id: 'long',
|
||||
amount: 500,
|
||||
},
|
||||
],
|
||||
total_expense: 500,
|
||||
}}
|
||||
period={period}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The full name stays in the DOM (with a title for hover); CSS handles
|
||||
// the visual ellipsis via the `truncate` utility.
|
||||
const label = screen.getByTitle(longName);
|
||||
expect(label).toHaveTextContent(longName);
|
||||
expect(label).toHaveClass('truncate');
|
||||
});
|
||||
|
||||
it('collapses an expanded category when toggled again', async () => {
|
||||
render(<SankeyChart data={data} period={period} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand Food' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Groceries')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse Food' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Groceries')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -19,6 +19,7 @@ import { formatCurrency } from '@/utils/currency';
|
|||
import { __ } from '@/utils/i18n';
|
||||
import { router } from '@inertiajs/react';
|
||||
import { format } from 'date-fns';
|
||||
import { ChevronsRight } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
interface SankeyChartProps {
|
||||
|
|
@ -28,10 +29,15 @@ interface SankeyChartProps {
|
|||
currency?: string;
|
||||
groupingThreshold?: number;
|
||||
period?: { from: Date; to: Date };
|
||||
/** Drill into a parent category node instead of navigating to it. */
|
||||
onDrill?: (categoryId: string, label: string) => void;
|
||||
}
|
||||
|
||||
type ColumnKey =
|
||||
| 'incomeChild'
|
||||
| 'income'
|
||||
| 'center'
|
||||
| 'expense'
|
||||
| 'expenseChild';
|
||||
|
||||
interface NodeData {
|
||||
id: string;
|
||||
label: string;
|
||||
|
|
@ -39,9 +45,11 @@ interface NodeData {
|
|||
color: string;
|
||||
y: number;
|
||||
height: number;
|
||||
column: 0 | 1 | 2;
|
||||
column: ColumnKey;
|
||||
columnFraction: number;
|
||||
category?: Category;
|
||||
hasChildren?: boolean;
|
||||
expandable?: boolean;
|
||||
}
|
||||
|
||||
interface LinkData {
|
||||
|
|
@ -52,14 +60,18 @@ interface LinkData {
|
|||
targetY: number;
|
||||
sourceHeight: number;
|
||||
targetHeight: number;
|
||||
kind: 'income' | 'expense';
|
||||
}
|
||||
|
||||
const COLUMN_POSITIONS = [0.25, 0.5, 0.75];
|
||||
const NODE_WIDTH = 8;
|
||||
const NODE_PADDING = 6;
|
||||
const MIN_NODE_HEIGHT = 20;
|
||||
const MIN_NODE_HEIGHT = 28;
|
||||
const MIN_RENDERED_WIDTH = 400;
|
||||
const MAX_RENDERED_WIDTH = 800;
|
||||
const LABEL_BLOCK_HEIGHT = 24;
|
||||
const LABEL_GAP = 6;
|
||||
const LABEL_PAD = 4;
|
||||
const LABEL_CENTER_WIDTH = 90;
|
||||
|
||||
interface OtherCategoriesBreakdownProps {
|
||||
categories: SankeyCategory[];
|
||||
|
|
@ -152,20 +164,41 @@ export function SankeyChart({
|
|||
currency = 'USD',
|
||||
groupingThreshold = 0.03,
|
||||
period,
|
||||
onDrill,
|
||||
}: SankeyChartProps) {
|
||||
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
|
||||
const [hoveredLink, setHoveredLink] = useState<string | null>(null);
|
||||
const [renderedWidth, setRenderedWidth] = useState(MAX_RENDERED_WIDTH);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const [childrenById, setChildrenById] = useState<
|
||||
Record<string, SankeyData>
|
||||
>({});
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const locale = useLocale();
|
||||
const { isPrivacyModeEnabled } = usePrivacyMode();
|
||||
|
||||
const periodKey = period
|
||||
? `${period.from.getTime()}-${period.to.getTime()}`
|
||||
: '';
|
||||
|
||||
const maskIfPrivate = (value: number) => {
|
||||
const formatted = formatCurrency(value, currency, locale, 0, 0);
|
||||
return isPrivacyModeEnabled ? formatted.replace(/\d/g, '*') : formatted;
|
||||
};
|
||||
|
||||
const toggleExpand = (categoryId: string) => {
|
||||
setExpandedIds((previous) => {
|
||||
const next = new Set(previous);
|
||||
|
||||
if (next.has(categoryId)) {
|
||||
next.delete(categoryId);
|
||||
} else {
|
||||
next.add(categoryId);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
|
||||
|
|
@ -198,6 +231,51 @@ export function SankeyChart({
|
|||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Changing the period invalidates any expanded subcategories.
|
||||
useEffect(() => {
|
||||
setExpandedIds(new Set());
|
||||
setChildrenById({});
|
||||
}, [periodKey]);
|
||||
|
||||
// Lazily fetch the children of each newly expanded category.
|
||||
useEffect(() => {
|
||||
if (!period) {
|
||||
return;
|
||||
}
|
||||
|
||||
const missing = [...expandedIds].filter((id) => !(id in childrenById));
|
||||
|
||||
if (missing.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const from = format(period.from, 'yyyy-MM-dd');
|
||||
const to = format(period.to, 'yyyy-MM-dd');
|
||||
let cancelled = false;
|
||||
|
||||
missing.forEach(async (id) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/cashflow/sankey?from=${from}&to=${to}&parent=${id}`,
|
||||
);
|
||||
const json: SankeyData = await response.json();
|
||||
|
||||
if (!cancelled) {
|
||||
setChildrenById((previous) => ({
|
||||
...previous,
|
||||
[id]: json,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch subcategories:', error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [expandedIds, childrenById, period, periodKey]);
|
||||
|
||||
const { nodes, links, isEmpty, otherGroups } = useMemo(() => {
|
||||
const {
|
||||
income_categories,
|
||||
|
|
@ -208,31 +286,26 @@ export function SankeyChart({
|
|||
|
||||
if (total_income === 0 && total_expense === 0) {
|
||||
return {
|
||||
nodes: [],
|
||||
links: [],
|
||||
nodes: [] as NodeData[],
|
||||
links: [] as LinkData[],
|
||||
isEmpty: true,
|
||||
otherGroups: {},
|
||||
otherGroups: {} as Record<string, GroupedCategory>,
|
||||
};
|
||||
}
|
||||
|
||||
const nodeMap: Record<string, NodeData> = {};
|
||||
const linkList: LinkData[] = [];
|
||||
const otherGroupsMap: Record<string, GroupedCategory> = {};
|
||||
|
||||
// Calculate available height for nodes
|
||||
const availableHeight = height - 40; // padding
|
||||
const maxTotal = Math.max(total_income, total_expense);
|
||||
|
||||
// Group income categories
|
||||
// Income parent nodes (left column)
|
||||
const groupedIncome = groupSmallCategories(
|
||||
income_categories,
|
||||
total_income,
|
||||
groupingThreshold,
|
||||
);
|
||||
|
||||
// Create income nodes (left column)
|
||||
let incomeY = 20;
|
||||
const incomeNodes = groupedIncome.main.map((item) => {
|
||||
const incomeNodes: NodeData[] = groupedIncome.main.map((item) => {
|
||||
const nodeHeight = Math.max(
|
||||
MIN_NODE_HEIGHT,
|
||||
(item.amount / maxTotal) * availableHeight * 0.5,
|
||||
|
|
@ -244,35 +317,36 @@ export function SankeyChart({
|
|||
color: item.category.color || 'var(--color-chart-2)',
|
||||
y: incomeY,
|
||||
height: nodeHeight,
|
||||
column: 0,
|
||||
column: 'income',
|
||||
columnFraction: 0,
|
||||
category: item.category,
|
||||
hasChildren: item.has_children,
|
||||
expandable: !!item.has_children,
|
||||
};
|
||||
incomeY += nodeHeight + NODE_PADDING;
|
||||
return node;
|
||||
});
|
||||
|
||||
// Add "Other" income node if needed
|
||||
if (groupedIncome.other) {
|
||||
const nodeHeight = Math.max(
|
||||
MIN_NODE_HEIGHT,
|
||||
(groupedIncome.other.total / maxTotal) * availableHeight * 0.5,
|
||||
);
|
||||
const otherNode: NodeData = {
|
||||
incomeNodes.push({
|
||||
id: 'income-other',
|
||||
label: __('Other'),
|
||||
value: groupedIncome.other.total,
|
||||
color: 'var(--color-muted)',
|
||||
y: incomeY,
|
||||
height: nodeHeight,
|
||||
column: 0,
|
||||
};
|
||||
incomeNodes.push(otherNode);
|
||||
column: 'income',
|
||||
columnFraction: 0,
|
||||
});
|
||||
otherGroupsMap['income-other'] = groupedIncome.other;
|
||||
incomeY += nodeHeight + NODE_PADDING;
|
||||
}
|
||||
|
||||
// Create center node (total cashflow)
|
||||
// Center node (total cashflow)
|
||||
const centerHeight = Math.max(
|
||||
MIN_NODE_HEIGHT * 1.5,
|
||||
(Math.max(total_income, total_expense) / maxTotal) *
|
||||
|
|
@ -287,19 +361,19 @@ export function SankeyChart({
|
|||
color: 'var(--color-chart-1)',
|
||||
y: centerY,
|
||||
height: centerHeight,
|
||||
column: 1,
|
||||
column: 'center',
|
||||
columnFraction: 0,
|
||||
};
|
||||
|
||||
// Group expense categories
|
||||
// Expense parent nodes (right column)
|
||||
const groupedExpense = groupSmallCategories(
|
||||
expense_categories,
|
||||
total_expense,
|
||||
groupingThreshold,
|
||||
);
|
||||
|
||||
// Create expense nodes (right column)
|
||||
let expenseY = 20;
|
||||
const expenseNodes = groupedExpense.main.map((item) => {
|
||||
const expenseNodes: NodeData[] = groupedExpense.main.map((item) => {
|
||||
const nodeHeight = Math.max(
|
||||
MIN_NODE_HEIGHT,
|
||||
(item.amount / maxTotal) * availableHeight * 0.5,
|
||||
|
|
@ -311,43 +385,101 @@ export function SankeyChart({
|
|||
color: item.category.color || 'var(--color-chart-3)',
|
||||
y: expenseY,
|
||||
height: nodeHeight,
|
||||
column: 2,
|
||||
column: 'expense',
|
||||
columnFraction: 0,
|
||||
category: item.category,
|
||||
hasChildren: item.has_children,
|
||||
expandable: !!item.has_children,
|
||||
};
|
||||
expenseY += nodeHeight + NODE_PADDING;
|
||||
return node;
|
||||
});
|
||||
|
||||
// Add "Other" expense node if needed
|
||||
if (groupedExpense.other) {
|
||||
const nodeHeight = Math.max(
|
||||
MIN_NODE_HEIGHT,
|
||||
(groupedExpense.other.total / maxTotal) * availableHeight * 0.5,
|
||||
);
|
||||
const otherNode: NodeData = {
|
||||
expenseNodes.push({
|
||||
id: 'expense-other',
|
||||
label: __('Other'),
|
||||
value: groupedExpense.other.total,
|
||||
color: 'var(--color-muted)',
|
||||
y: expenseY,
|
||||
height: nodeHeight,
|
||||
column: 2,
|
||||
};
|
||||
expenseNodes.push(otherNode);
|
||||
column: 'expense',
|
||||
columnFraction: 0,
|
||||
});
|
||||
otherGroupsMap['expense-other'] = groupedExpense.other;
|
||||
expenseY += nodeHeight + NODE_PADDING;
|
||||
}
|
||||
|
||||
// Add all nodes to map
|
||||
incomeNodes.forEach((n) => (nodeMap[n.id] = n));
|
||||
nodeMap[centerNode.id] = centerNode;
|
||||
expenseNodes.forEach((n) => (nodeMap[n.id] = n));
|
||||
// Resolve which expanded parents actually have loaded children.
|
||||
const sortByAmount = (categories: SankeyCategory[]): SankeyCategory[] =>
|
||||
[...categories].sort((a, b) => b.amount - a.amount);
|
||||
|
||||
// Create links from income to center
|
||||
const incomeChildren: Record<string, SankeyCategory[]> = {};
|
||||
incomeNodes.forEach((node) => {
|
||||
if (node.category && expandedIds.has(node.category.id)) {
|
||||
const kids = childrenById[node.category.id]?.income_categories;
|
||||
|
||||
if (kids && kids.length > 0) {
|
||||
incomeChildren[node.id] = sortByAmount(kids);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const expenseChildren: Record<string, SankeyCategory[]> = {};
|
||||
expenseNodes.forEach((node) => {
|
||||
if (node.category && expandedIds.has(node.category.id)) {
|
||||
const kids = childrenById[node.category.id]?.expense_categories;
|
||||
|
||||
if (kids && kids.length > 0) {
|
||||
expenseChildren[node.id] = sortByAmount(kids);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const hasIncomeChildColumn = Object.keys(incomeChildren).length > 0;
|
||||
const hasExpenseChildColumn = Object.keys(expenseChildren).length > 0;
|
||||
|
||||
// Lay out the active columns left-to-right.
|
||||
const columns: ColumnKey[] = [];
|
||||
if (hasIncomeChildColumn) {
|
||||
columns.push('incomeChild');
|
||||
}
|
||||
columns.push('income', 'center', 'expense');
|
||||
if (hasExpenseChildColumn) {
|
||||
columns.push('expenseChild');
|
||||
}
|
||||
|
||||
const pad = columns.length <= 3 ? 0.25 : 0.12;
|
||||
const fractionFor = (index: number): number =>
|
||||
columns.length <= 1
|
||||
? 0.5
|
||||
: pad + (index / (columns.length - 1)) * (1 - 2 * pad);
|
||||
const fractionByColumn = {} as Record<ColumnKey, number>;
|
||||
columns.forEach((column, index) => {
|
||||
fractionByColumn[column] = fractionFor(index);
|
||||
});
|
||||
|
||||
incomeNodes.forEach((node) => {
|
||||
node.columnFraction = fractionByColumn.income;
|
||||
});
|
||||
expenseNodes.forEach((node) => {
|
||||
node.columnFraction = fractionByColumn.expense;
|
||||
});
|
||||
centerNode.columnFraction = fractionByColumn.center;
|
||||
|
||||
const linkList: LinkData[] = [];
|
||||
|
||||
// Income parents -> center
|
||||
let incomeLinkY = centerY;
|
||||
incomeNodes.forEach((incomeNode) => {
|
||||
const linkHeight = (incomeNode.value / total_income) * centerHeight;
|
||||
const linkHeight =
|
||||
total_income > 0
|
||||
? (incomeNode.value / total_income) * centerHeight
|
||||
: 0;
|
||||
linkList.push({
|
||||
source: incomeNode.id,
|
||||
target: 'center',
|
||||
|
|
@ -356,15 +488,18 @@ export function SankeyChart({
|
|||
targetY: incomeLinkY + linkHeight / 2,
|
||||
sourceHeight: incomeNode.height,
|
||||
targetHeight: linkHeight,
|
||||
kind: 'income',
|
||||
});
|
||||
incomeLinkY += linkHeight;
|
||||
});
|
||||
|
||||
// Create links from center to expenses
|
||||
// Center -> expense parents
|
||||
let expenseLinkY = centerY;
|
||||
expenseNodes.forEach((expenseNode) => {
|
||||
const linkHeight =
|
||||
(expenseNode.value / total_expense) * centerHeight;
|
||||
total_expense > 0
|
||||
? (expenseNode.value / total_expense) * centerHeight
|
||||
: 0;
|
||||
linkList.push({
|
||||
source: 'center',
|
||||
target: expenseNode.id,
|
||||
|
|
@ -373,17 +508,112 @@ export function SankeyChart({
|
|||
targetY: expenseNode.y + expenseNode.height / 2,
|
||||
sourceHeight: linkHeight,
|
||||
targetHeight: expenseNode.height,
|
||||
kind: 'expense',
|
||||
});
|
||||
expenseLinkY += linkHeight;
|
||||
});
|
||||
|
||||
// Child nodes stack within their parent's vertical band so the parent
|
||||
// visibly splits into its subcategories.
|
||||
const childNodes: NodeData[] = [];
|
||||
const buildChildren = (
|
||||
parents: NodeData[],
|
||||
childrenByParent: Record<string, SankeyCategory[]>,
|
||||
childColumn: ColumnKey,
|
||||
kind: 'income' | 'expense',
|
||||
) => {
|
||||
parents.forEach((parent) => {
|
||||
const kids = childrenByParent[parent.id];
|
||||
|
||||
if (!kids) {
|
||||
return;
|
||||
}
|
||||
|
||||
const childFraction = fractionByColumn[childColumn];
|
||||
const kidsSum = kids.reduce((sum, kid) => sum + kid.amount, 0);
|
||||
|
||||
if (kidsSum <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Size each child like a top-level node (same minimum height
|
||||
// and gap), then center the stack on the parent so the links
|
||||
// fan out from the parent's proportional slices.
|
||||
const childHeights = kids.map((kid) =>
|
||||
Math.max(
|
||||
MIN_NODE_HEIGHT,
|
||||
(kid.amount / maxTotal) * availableHeight * 0.5,
|
||||
),
|
||||
);
|
||||
const stackHeight =
|
||||
childHeights.reduce((sum, h) => sum + h, 0) +
|
||||
NODE_PADDING * (kids.length - 1);
|
||||
let childCursor =
|
||||
parent.y + parent.height / 2 - stackHeight / 2;
|
||||
let parentCursor = parent.y;
|
||||
|
||||
kids.forEach((kid, index) => {
|
||||
const childHeight = childHeights[index];
|
||||
const parentSlice = (kid.amount / kidsSum) * parent.height;
|
||||
const node: NodeData = {
|
||||
id: `${childColumn}-${parent.id}-${index}`,
|
||||
label: kid.category.name,
|
||||
value: kid.amount,
|
||||
color:
|
||||
kid.category.color ||
|
||||
(kind === 'income'
|
||||
? 'var(--color-chart-2)'
|
||||
: 'var(--color-chart-3)'),
|
||||
y: childCursor,
|
||||
height: childHeight,
|
||||
column: childColumn,
|
||||
columnFraction: childFraction,
|
||||
category: kid.category,
|
||||
hasChildren: kid.has_children,
|
||||
expandable: false,
|
||||
};
|
||||
childNodes.push(node);
|
||||
|
||||
if (kind === 'income') {
|
||||
linkList.push({
|
||||
source: node.id,
|
||||
target: parent.id,
|
||||
value: kid.amount,
|
||||
sourceY: node.y + childHeight / 2,
|
||||
targetY: parentCursor + parentSlice / 2,
|
||||
sourceHeight: childHeight,
|
||||
targetHeight: parentSlice,
|
||||
kind: 'income',
|
||||
});
|
||||
} else {
|
||||
linkList.push({
|
||||
source: parent.id,
|
||||
target: node.id,
|
||||
value: kid.amount,
|
||||
sourceY: parentCursor + parentSlice / 2,
|
||||
targetY: node.y + childHeight / 2,
|
||||
sourceHeight: parentSlice,
|
||||
targetHeight: childHeight,
|
||||
kind: 'expense',
|
||||
});
|
||||
}
|
||||
|
||||
childCursor += childHeight + NODE_PADDING;
|
||||
parentCursor += parentSlice;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
buildChildren(incomeNodes, incomeChildren, 'incomeChild', 'income');
|
||||
buildChildren(expenseNodes, expenseChildren, 'expenseChild', 'expense');
|
||||
|
||||
return {
|
||||
nodes: Object.values(nodeMap),
|
||||
nodes: [...incomeNodes, centerNode, ...expenseNodes, ...childNodes],
|
||||
links: linkList,
|
||||
isEmpty: false,
|
||||
otherGroups: otherGroupsMap,
|
||||
};
|
||||
}, [data, height, groupingThreshold]);
|
||||
}, [data, height, groupingThreshold, expandedIds, childrenById]);
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
|
|
@ -401,6 +631,11 @@ export function SankeyChart({
|
|||
|
||||
const width = renderedWidth;
|
||||
|
||||
const isLeftAligned = (column: ColumnKey): boolean =>
|
||||
column === 'incomeChild' || column === 'income';
|
||||
const isRightAligned = (column: ColumnKey): boolean =>
|
||||
column === 'expense' || column === 'expenseChild';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
|
@ -424,11 +659,9 @@ export function SankeyChart({
|
|||
if (!sourceNode || !targetNode) return null;
|
||||
|
||||
const sourceX =
|
||||
COLUMN_POSITIONS[sourceNode.column] * width +
|
||||
NODE_WIDTH / 2;
|
||||
sourceNode.columnFraction * width + NODE_WIDTH / 2;
|
||||
const targetX =
|
||||
COLUMN_POSITIONS[targetNode.column] * width -
|
||||
NODE_WIDTH / 2;
|
||||
targetNode.columnFraction * width - NODE_WIDTH / 2;
|
||||
|
||||
const linkId = `${link.source}-${link.target}`;
|
||||
const isHovered =
|
||||
|
|
@ -454,7 +687,7 @@ export function SankeyChart({
|
|||
key={linkId}
|
||||
d={path}
|
||||
fill={
|
||||
sourceNode.column === 0
|
||||
link.kind === 'income'
|
||||
? 'var(--color-chart-2)'
|
||||
: 'var(--color-chart-3)'
|
||||
}
|
||||
|
|
@ -470,9 +703,7 @@ export function SankeyChart({
|
|||
{/* Nodes */}
|
||||
<g className="nodes">
|
||||
{nodes.map((node) => {
|
||||
const x =
|
||||
COLUMN_POSITIONS[node.column] * width -
|
||||
NODE_WIDTH / 2;
|
||||
const x = node.columnFraction * width - NODE_WIDTH / 2;
|
||||
const isHovered = hoveredNode === node.id;
|
||||
const isOtherNode = node.id.endsWith('-other');
|
||||
const otherGroup = isOtherNode
|
||||
|
|
@ -494,18 +725,21 @@ export function SankeyChart({
|
|||
},
|
||||
}).url
|
||||
: null;
|
||||
const canDrill =
|
||||
const canExpand =
|
||||
!isOtherNode &&
|
||||
!!node.hasChildren &&
|
||||
!!node.expandable &&
|
||||
!!node.category &&
|
||||
!!onDrill;
|
||||
!!period;
|
||||
const isExpanded =
|
||||
!!node.category &&
|
||||
expandedIds.has(node.category.id);
|
||||
const isNavigable =
|
||||
!isOtherNode && !canDrill && categoryUrl !== null;
|
||||
const isInteractive = canDrill || isNavigable;
|
||||
!isOtherNode && !canExpand && categoryUrl !== null;
|
||||
const isInteractive = canExpand || isNavigable;
|
||||
|
||||
const activate = () => {
|
||||
if (canDrill && node.category) {
|
||||
onDrill?.(node.category.id, node.label);
|
||||
if (canExpand && node.category) {
|
||||
toggleExpand(node.category.id);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -514,6 +748,31 @@ export function SankeyChart({
|
|||
}
|
||||
};
|
||||
|
||||
// The label/amount block is rendered as real HTML in a
|
||||
// foreignObject so flexbox handles spacing, vertical
|
||||
// centering, and ellipsis truncation reliably.
|
||||
const rightAligned = isRightAligned(node.column);
|
||||
const leftAligned = isLeftAligned(node.column);
|
||||
const labelX = rightAligned
|
||||
? x + NODE_WIDTH + LABEL_GAP
|
||||
: leftAligned
|
||||
? LABEL_PAD
|
||||
: x + NODE_WIDTH / 2 - LABEL_CENTER_WIDTH / 2;
|
||||
const labelWidth = rightAligned
|
||||
? Math.max(0, width - labelX - LABEL_PAD)
|
||||
: leftAligned
|
||||
? Math.max(0, x - LABEL_GAP - LABEL_PAD)
|
||||
: LABEL_CENTER_WIDTH;
|
||||
const labelY =
|
||||
node.y + node.height / 2 - LABEL_BLOCK_HEIGHT / 2;
|
||||
const iconRotationClass = isExpanded
|
||||
? node.column === 'income'
|
||||
? ''
|
||||
: 'rotate-180'
|
||||
: node.column === 'income'
|
||||
? 'rotate-180'
|
||||
: '';
|
||||
|
||||
const nodeContent = (
|
||||
<g
|
||||
key={node.id}
|
||||
|
|
@ -538,7 +797,7 @@ export function SankeyChart({
|
|||
}
|
||||
}}
|
||||
role={
|
||||
canDrill
|
||||
canExpand
|
||||
? 'button'
|
||||
: isNavigable
|
||||
? 'link'
|
||||
|
|
@ -546,8 +805,10 @@ export function SankeyChart({
|
|||
}
|
||||
tabIndex={isInteractive ? 0 : undefined}
|
||||
aria-label={
|
||||
canDrill
|
||||
? `Drill into ${node.label}`
|
||||
canExpand
|
||||
? isExpanded
|
||||
? `Collapse ${node.label}`
|
||||
: `Expand ${node.label}`
|
||||
: isNavigable
|
||||
? `View ${node.label} transactions`
|
||||
: undefined
|
||||
|
|
@ -588,56 +849,60 @@ export function SankeyChart({
|
|||
className="transition-all duration-200"
|
||||
/>
|
||||
|
||||
{/* Label */}
|
||||
<text
|
||||
x={
|
||||
node.column === 0
|
||||
? x - 6
|
||||
: node.column === 2
|
||||
? x + NODE_WIDTH + 6
|
||||
: x + NODE_WIDTH / 2
|
||||
}
|
||||
y={node.y + node.height / 2 - 6}
|
||||
textAnchor={
|
||||
node.column === 0
|
||||
? 'end'
|
||||
: node.column === 2
|
||||
? 'start'
|
||||
: 'middle'
|
||||
}
|
||||
dominantBaseline="middle"
|
||||
className="fill-foreground text-[11px] font-medium"
|
||||
{/* Label + amount */}
|
||||
<foreignObject
|
||||
x={labelX}
|
||||
y={labelY}
|
||||
width={labelWidth}
|
||||
height={LABEL_BLOCK_HEIGHT}
|
||||
className="overflow-visible"
|
||||
>
|
||||
{node.label}
|
||||
{isOtherNode && (
|
||||
<tspan className="fill-muted-foreground">
|
||||
{' '}
|
||||
⋯
|
||||
</tspan>
|
||||
)}
|
||||
</text>
|
||||
{/* Amount */}
|
||||
<text
|
||||
x={
|
||||
node.column === 0
|
||||
? x - 6
|
||||
: node.column === 2
|
||||
? x + NODE_WIDTH + 6
|
||||
: x + NODE_WIDTH / 2
|
||||
}
|
||||
y={node.y + node.height / 2 + 6}
|
||||
textAnchor={
|
||||
node.column === 0
|
||||
? 'end'
|
||||
: node.column === 2
|
||||
? 'start'
|
||||
: 'middle'
|
||||
}
|
||||
dominantBaseline="middle"
|
||||
className="fill-muted-foreground text-[11px]"
|
||||
>
|
||||
{maskIfPrivate(node.value)}
|
||||
</text>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full flex-col justify-center gap-0.5 leading-tight',
|
||||
rightAligned &&
|
||||
'items-start text-left',
|
||||
leftAligned &&
|
||||
'items-end text-right',
|
||||
!rightAligned &&
|
||||
!leftAligned &&
|
||||
'items-center text-center',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex max-w-full items-center gap-2',
|
||||
node.column === 'income' &&
|
||||
'flex-row-reverse',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
title={node.label}
|
||||
className="min-w-0 truncate text-[11px] font-medium text-foreground"
|
||||
>
|
||||
{node.label}
|
||||
{isOtherNode && (
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
⋯
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{canExpand && (
|
||||
<ChevronsRight
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'size-3 shrink-0 text-muted-foreground',
|
||||
iconRotationClass,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{maskIfPrivate(node.value)}
|
||||
</span>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
);
|
||||
|
||||
|
|
@ -657,7 +922,9 @@ export function SankeyChart({
|
|||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align={
|
||||
node.column === 0 ? 'start' : 'end'
|
||||
node.id.startsWith('income-')
|
||||
? 'start'
|
||||
: 'end'
|
||||
}
|
||||
side="top"
|
||||
className="p-4"
|
||||
|
|
|
|||
|
|
@ -67,8 +67,6 @@ interface UseCashflowDataOptions {
|
|||
from: Date;
|
||||
to: Date;
|
||||
periodType: CashflowPeriodType;
|
||||
/** When set, the Sankey diagram drills into this parent category. */
|
||||
sankeyParent?: string | null;
|
||||
}
|
||||
|
||||
const emptyBreakdown: BreakdownData = {
|
||||
|
|
@ -90,7 +88,6 @@ export function useCashflowData({
|
|||
from,
|
||||
to,
|
||||
periodType,
|
||||
sankeyParent = null,
|
||||
}: UseCashflowDataOptions): CashflowData & { refetch: () => void } {
|
||||
const [data, setData] = useState<Omit<CashflowData, 'isLoading'>>({
|
||||
summary: { current: emptySummary, previous: emptySummary },
|
||||
|
|
@ -117,9 +114,6 @@ export function useCashflowData({
|
|||
to: toStr,
|
||||
});
|
||||
const periodQuery = `?${periodParams.toString()}`;
|
||||
const sankeyQuery = sankeyParent
|
||||
? `${periodQuery}&parent=${sankeyParent}`
|
||||
: periodQuery;
|
||||
const trendQuery =
|
||||
periodType === 'month' ? `?months=12&to=${toStr}` : periodQuery;
|
||||
|
||||
|
|
@ -128,7 +122,7 @@ export function useCashflowData({
|
|||
fetch(`/api/cashflow/summary${periodQuery}`).then((r) =>
|
||||
r.json(),
|
||||
),
|
||||
fetch(`/api/cashflow/sankey${sankeyQuery}`).then((r) =>
|
||||
fetch(`/api/cashflow/sankey${periodQuery}`).then((r) =>
|
||||
r.json(),
|
||||
),
|
||||
fetch(`/api/cashflow/trend${trendQuery}`).then((r) =>
|
||||
|
|
@ -154,7 +148,7 @@ export function useCashflowData({
|
|||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [from, periodType, to, sankeyParent]);
|
||||
}, [from, periodType, to]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
|
|
|
|||
|
|
@ -125,11 +125,6 @@ export default function CashflowPage() {
|
|||
parsePeriodParam(initialPeriod, initialPeriodType),
|
||||
);
|
||||
|
||||
const [sankeyDrill, setSankeyDrill] = useState<{
|
||||
id: string;
|
||||
label: string;
|
||||
} | null>(null);
|
||||
|
||||
const period = getPeriodRange(currentDate, periodType);
|
||||
|
||||
const {
|
||||
|
|
@ -142,14 +137,8 @@ export default function CashflowPage() {
|
|||
} = useCashflowData({
|
||||
...period,
|
||||
periodType,
|
||||
sankeyParent: sankeyDrill?.id ?? null,
|
||||
});
|
||||
|
||||
// Leaving the period resets any Sankey drill-down.
|
||||
useEffect(() => {
|
||||
setSankeyDrill(null);
|
||||
}, [currentDate, periodType]);
|
||||
|
||||
useEffect(() => {
|
||||
const periodParam = formatPeriodParam(currentDate, periodType);
|
||||
|
||||
|
|
@ -216,28 +205,9 @@ export default function CashflowPage() {
|
|||
{/* Sankey Diagram */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="text-base">
|
||||
{__('Money Flow')}
|
||||
</CardTitle>
|
||||
{sankeyDrill && (
|
||||
<nav className="flex items-center gap-1 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSankeyDrill(null)}
|
||||
className="text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
{__('All categories')}
|
||||
</button>
|
||||
<span className="text-muted-foreground">
|
||||
›
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{sankeyDrill.label}
|
||||
</span>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-base">
|
||||
{__('Money Flow')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
|
|
@ -248,9 +218,6 @@ export default function CashflowPage() {
|
|||
height={400}
|
||||
currency={auth.user.currency_code}
|
||||
period={period}
|
||||
onDrill={(id, label) =>
|
||||
setSankeyDrill({ id, label })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
Loading…
Reference in New Issue