Files
sure/app/views/reports/_breakdown_table.html.erb
David Gil 3d91e60a8a feat: Add subcategory breakdown to Cash Flow Sankey and Reports (#639)
* feat: Add subcategory breakdown to Cash Flow and Reports

Implements Discussion #546 - adds hierarchical category/subcategory
visualization to both the Sankey chart and Reports breakdown tables.

Sankey chart changes:
- Income: subcategory → parent category → Cash Flow
- Expense: Cash Flow → parent category → subcategory
- Extracted process_category_totals helper to DRY up income/expense logic

Reports breakdown changes:
- Subcategories display nested under parent categories
- Smaller dots and indented rows for visual hierarchy
- Extracted _breakdown_table partial to eliminate duplication

* fix: Dynamic node padding for Sankey chart with many nodes

- Add dynamic nodePadding calculation to prevent padding from dominating
  chart height when there are many subcategory nodes
- Extract magic numbers to static constants for configuration
- Decompose monolithic #draw() into focused methods
- Consolidate duplicate tooltip/currency formatting code
- Modernize syntax with spread operators and optional chaining

* fix: Hide overlapping Sankey labels, show on hover

- Add label overlap detection by grouping nodes by column depth
- Hide labels that would overlap with adjacent nodes
- Show hidden labels on hover (node rectangle or connected links)
- Add hover events to node rectangles (not just text)

* fix: Use deterministic fallback colors for categories

- Replace Category::COLORS.sample with Category::UNCATEGORIZED_COLOR
  for income categories in Sankey chart (was producing different colors
  on each page load)
- Add nil color fallback in reports_controller for parent and root
  categories

Addresses CodeRabbit review feedback.

* fix: Expand CSS variable map for d3 color manipulation

Add hex mappings for commonly used CSS variables so d3 can manipulate
opacity for gradients and hover effects:
- var(--color-destructive) -> #EC2222
- var(--color-gray-400) -> #9E9E9E
- var(--color-gray-500) -> #737373

* test: Add tests for subcategory breakdown in dashboard and reports

- Test dashboard renders Sankey chart with parent/subcategory transactions
- Test reports groups transactions by parent and subcategories
- Test reports handles categories with nil colors
- Use EntriesTestHelper#create_transaction for cleaner test setup

* Fix lint: use Number.NEGATIVE_INFINITY

* Remove obsolete nil color test

Category model now validates color presence, so nil color categories
cannot exist. The fallback handling in reports_controller is still in
place but the scenario is unreachable.

* Update reports_controller.rb

* FIX trade category

---------

Co-authored-by: sokie <sokysrm@gmail.com>
2026-01-20 00:01:55 +01:00

86 lines
4.3 KiB
Plaintext

<%# Renders a breakdown table for income or expense groups %>
<%# Local variables: groups, total, type (:income or :expense), amount_sort_params, current_sort_by, current_sort_direction %>
<%
color_class = type == :income ? "text-success" : "text-destructive"
icon_name = type == :income ? "trending-up" : "trending-down"
title_key = type == :income ? "reports.transactions_breakdown.table.income" : "reports.transactions_breakdown.table.expense"
%>
<div>
<h3 class="text-base font-semibold <%= color_class %> mb-4 flex items-center gap-2">
<%= icon(icon_name, class: "w-5 h-5") %>
<%= t(title_key) %>
<span class="text-sm font-normal text-tertiary">(<%= Money.new(total, Current.family.currency).format %>)</span>
</h3>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-tertiary">
<th class="text-left py-3 pr-4 font-medium text-secondary"><%= t("reports.transactions_breakdown.table.category") %></th>
<th class="text-right py-3 px-4 font-medium text-secondary">
<%= link_to reports_path(amount_sort_params), class: "inline-flex items-center gap-1 hover:text-primary" do %>
<%= t("reports.transactions_breakdown.table.amount") %>
<% if current_sort_by == "amount" %>
<%= icon(current_sort_direction == "desc" ? "chevron-down" : "chevron-up", class: "w-3 h-3") %>
<% end %>
<% end %>
</th>
<th class="text-right py-3 pl-4 font-medium text-secondary"><%= t("reports.transactions_breakdown.table.percentage") %></th>
</tr>
</thead>
<tbody>
<% groups.each do |group| %>
<% percentage = total.zero? ? 0 : (group[:total].to_f / total * 100).round(1) %>
<% has_subcategories = group[:subcategories].present? && group[:subcategories].any? %>
<tr class="border-b border-tertiary hover:bg-surface-inset">
<td class="py-3 pr-4">
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color: <%= group[:category_color] %>"></span>
<span class="font-medium text-primary"><%= group[:category_name] %></span>
<span class="text-xs text-tertiary whitespace-nowrap">(<%= t("reports.transactions_breakdown.table.entries", count: group[:count]) %>)</span>
</div>
</td>
<td class="py-3 px-4 text-right">
<span class="font-semibold <%= color_class %>">
<%= Money.new(group[:total], Current.family.currency).format %>
</span>
</td>
<td class="py-3 pl-4 text-right">
<span class="text-sm text-secondary">
<%= percentage %>%
</span>
</td>
</tr>
<%# Render subcategories if present %>
<% if has_subcategories %>
<% group[:subcategories].each do |subcategory| %>
<% sub_percentage = total.zero? ? 0 : (subcategory[:total].to_f / total * 100).round(1) %>
<tr class="border-b border-tertiary hover:bg-surface-inset bg-surface-inset/30">
<td class="py-2 pr-4 pl-6">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full flex-shrink-0" style="background-color: <%= subcategory[:category_color] %>"></span>
<span class="text-sm text-secondary"><%= subcategory[:category_name] %></span>
<span class="text-xs text-tertiary whitespace-nowrap">(<%= t("reports.transactions_breakdown.table.entries", count: subcategory[:count]) %>)</span>
</div>
</td>
<td class="py-2 px-4 text-right">
<span class="text-sm <%= color_class %>">
<%= Money.new(subcategory[:total], Current.family.currency).format %>
</span>
</td>
<td class="py-2 pl-4 text-right">
<span class="text-xs text-tertiary">
<%= sub_percentage %>%
</span>
</td>
</tr>
<% end %>
<% end %>
<% end %>
</tbody>
</table>
</div>
</div>