mirror of
https://github.com/we-promise/sure.git
synced 2026-09-04 22:31:07 +00:00
Make report income/expense categories clickable (#2923)
* Make report category rows link to filtered transactions Match dashboard drill-down for income/expense categories on the reports breakdown, while leaving synthetic Other Investments non-clickable (#2850). Co-authored-by: Cursor <cursoragent@cursor.com> * Only link report categories backed by transactions Track has_transactions while building breakdown groups so trade-only rows (e.g. Other Investments) are not sent to /transactions, which cannot show Trade entries. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop redundant trade-only reports link test Coverage for non-clickable Other Investments remains in the tax-advantaged breakdown test. Co-authored-by: Cursor <cursoragent@cursor.com> * Strengthen reports category link coverage in tests Cover income and uncategorized drill-down links, and assert every Other Investments row has no transaction link. Co-authored-by: Cursor <cursoragent@cursor.com> * Make report category rows fully clickable like outflows Use a stretched ::before link on the row for a larger hit target, and cover Uncategorized href localization against Transaction::Search. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
acf4cb2010
commit
fa5c544431
@@ -398,12 +398,30 @@ class ReportsController < ApplicationController
|
||||
|
||||
# Helper to initialize a category group hash
|
||||
init_category_group = ->(id, name, color, icon, type) do
|
||||
{ category_id: id, category_name: name, category_color: color, category_icon: icon, type: type, total: 0, count: 0, subcategories: {} }
|
||||
{
|
||||
category_id: id,
|
||||
category_name: name,
|
||||
category_color: color,
|
||||
category_icon: icon,
|
||||
type: type,
|
||||
total: 0,
|
||||
count: 0,
|
||||
has_transactions: false,
|
||||
subcategories: {}
|
||||
}
|
||||
end
|
||||
|
||||
# Helper to initialize a subcategory hash
|
||||
init_subcategory = ->(category) do
|
||||
{ category_id: category.id, category_name: category.name, category_color: category.color, category_icon: category.lucide_icon, total: 0, count: 0 }
|
||||
{
|
||||
category_id: category.id,
|
||||
category_name: category.name,
|
||||
category_color: category.color,
|
||||
category_icon: category.lucide_icon,
|
||||
total: 0,
|
||||
count: 0,
|
||||
has_transactions: false
|
||||
}
|
||||
end
|
||||
|
||||
# Helper to process an entry (transaction or trade)
|
||||
@@ -434,6 +452,7 @@ class ReportsController < ApplicationController
|
||||
grouped_data[parent_key][:subcategories][category.id] ||= init_subcategory.call(category)
|
||||
grouped_data[parent_key][:subcategories][category.id][:count] += 1
|
||||
grouped_data[parent_key][:subcategories][category.id][:total] += converted_amount
|
||||
grouped_data[parent_key][:subcategories][category.id][:has_transactions] = true unless is_trade
|
||||
else
|
||||
# This is a root category (no parent)
|
||||
parent_key = [ category.id, type ]
|
||||
@@ -442,6 +461,7 @@ class ReportsController < ApplicationController
|
||||
|
||||
grouped_data[parent_key][:count] += 1
|
||||
grouped_data[parent_key][:total] += converted_amount
|
||||
grouped_data[parent_key][:has_transactions] = true unless is_trade
|
||||
end
|
||||
|
||||
# Process transactions
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<%# 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 %>
|
||||
<%# Local variables: groups, total, type (:income or :expense), amount_sort_params, current_sort_by, current_sort_direction, start_date, end_date %>
|
||||
|
||||
<%
|
||||
color_class = type == :income ? "text-success" : "text-primary"
|
||||
@@ -38,6 +38,8 @@
|
||||
total: total,
|
||||
color_class: color_class,
|
||||
level: :category,
|
||||
start_date: start_date,
|
||||
end_date: end_date,
|
||||
show_border: idx < groups.size - 1 || group[:subcategories].present? %>
|
||||
<%# Render subcategories if present %>
|
||||
<% if group[:subcategories].present? && group[:subcategories].any? %>
|
||||
@@ -47,6 +49,8 @@
|
||||
total: total,
|
||||
color_class: color_class,
|
||||
level: :subcategory,
|
||||
start_date: start_date,
|
||||
end_date: end_date,
|
||||
show_border: sub_idx < group[:subcategories].size - 1 %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -2,9 +2,16 @@
|
||||
percentage = total.zero? ? 0 : (item[:total].to_f / total * 100).round(1)
|
||||
is_sub = level == :subcategory
|
||||
show_border = local_assigns.fetch(:show_border, false)
|
||||
row_start_date = local_assigns[:start_date]
|
||||
row_end_date = local_assigns[:end_date]
|
||||
# /transactions only lists Transaction rows. Skip trade-only buckets (e.g. Other
|
||||
# Investments) so the destination is not empty relative to the report row.
|
||||
clickable = item[:has_transactions] && row_start_date.present? && row_end_date.present?
|
||||
transactions_href = clickable ? transactions_path(q: { categories: [ item[:category_name] ], start_date: row_start_date, end_date: row_end_date }) : nil
|
||||
%>
|
||||
|
||||
<tr data-category="category-<%= item[:category_id] %>" class="text-secondary text-sm <%= show_border ? "table-divider" : "" %>">
|
||||
<tr data-category="category-<%= item[:category_id] %>"
|
||||
class="text-secondary text-sm <%= show_border ? "table-divider" : "" %> <%= clickable ? "relative group/category-row" : "" %>">
|
||||
<td colspan="2" class="py-3 px-4 lg:px-6 <%= is_sub ? "pl-7 lg:pl-9" : "" %>">
|
||||
<div class="flex items-center gap-2">
|
||||
<% if is_sub %>
|
||||
@@ -13,7 +20,7 @@
|
||||
</div>
|
||||
<% end %>
|
||||
<% if item[:category_icon] %>
|
||||
<div class="h-7 w-7 flex-shrink-0 rounded-full flex justify-center items-center"
|
||||
<div class="h-7 w-7 flex-shrink-0 rounded-full flex justify-center items-center <%= clickable ? "group-hover/category-row:scale-105 transition-all duration-300" : "" %>"
|
||||
style="
|
||||
background-color: color-mix(in oklab, <%= item[:category_color] %> 10%, transparent);
|
||||
border-color: color-mix(in oklab, <%= item[:category_color] %> 10%, transparent);
|
||||
@@ -30,9 +37,18 @@
|
||||
rounded: true
|
||||
) %>
|
||||
<% end %>
|
||||
<span class="font-medium text-primary">
|
||||
<%= item[:category_name] %>
|
||||
</span>
|
||||
<% if clickable %>
|
||||
<%# Stretched link covers the full row (mirrors dashboard outflows).
|
||||
`before:absolute before:inset-0` is positioned against the relative <tr>. %>
|
||||
<%= link_to item[:category_name],
|
||||
transactions_href,
|
||||
class: "font-medium text-primary hover:underline before:absolute before:inset-0 before:content-['']",
|
||||
data: { turbo_frame: "_top", turbo_prefetch: false } %>
|
||||
<% else %>
|
||||
<span class="font-medium text-primary">
|
||||
<%= item[:category_name] %>
|
||||
</span>
|
||||
<% end %>
|
||||
<span class="text-xs text-subdued whitespace-nowrap">
|
||||
(<%= t("reports.transactions_breakdown.table.entries", count: item[:count]) %>)
|
||||
</span>
|
||||
|
||||
@@ -59,7 +59,9 @@
|
||||
type: :income,
|
||||
amount_sort_params: amount_sort_params,
|
||||
current_sort_by: current_sort_by,
|
||||
current_sort_direction: current_sort_direction %>
|
||||
current_sort_direction: current_sort_direction,
|
||||
start_date: start_date,
|
||||
end_date: end_date %>
|
||||
<% end %>
|
||||
|
||||
<%# Expenses Section %>
|
||||
@@ -70,7 +72,9 @@
|
||||
type: :expense,
|
||||
amount_sort_params: amount_sort_params,
|
||||
current_sort_by: current_sort_by,
|
||||
current_sort_direction: current_sort_direction %>
|
||||
current_sort_direction: current_sort_direction,
|
||||
start_date: start_date,
|
||||
end_date: end_date %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -420,6 +420,49 @@ class ReportsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_select "tr[data-category='category-#{subcategory_games.id}']", text: /^Games/
|
||||
end
|
||||
|
||||
test "index links income and expense categories to filtered transactions" do
|
||||
start_date = Date.current.beginning_of_month
|
||||
end_date = Date.current.end_of_month
|
||||
expense_category = @family.categories.create!(name: "Reports Clickable Groceries", color: "#ABCDEF")
|
||||
income_category = @family.categories.create!(name: "Reports Clickable Salary", color: "#FEDCBA")
|
||||
account = @family.accounts.first
|
||||
|
||||
create_transaction(account: account, name: "Groceries", amount: 42, category: expense_category, date: Date.current)
|
||||
create_transaction(account: account, name: "Salary", amount: -100, category: income_category, date: Date.current)
|
||||
create_transaction(account: account, name: "Uncategorized cash", amount: 25, date: Date.current)
|
||||
|
||||
get reports_path(period_type: :monthly, start_date: start_date, end_date: end_date)
|
||||
assert_response :ok
|
||||
|
||||
expense_href = transactions_path(q: { categories: [ expense_category.name ], start_date: start_date, end_date: end_date })
|
||||
income_href = transactions_path(q: { categories: [ income_category.name ], start_date: start_date, end_date: end_date })
|
||||
uncategorized_href = transactions_path(q: { categories: [ Category.uncategorized.name ], start_date: start_date, end_date: end_date })
|
||||
|
||||
assert_select "tr[data-category='category-#{expense_category.id}'] a[href=?]", expense_href, text: expense_category.name
|
||||
assert_select "tr[data-category='category-#{income_category.id}'] a[href=?]", income_href, text: income_category.name
|
||||
assert_select "tr[data-category='category-uncategorized'] a[href=?]", uncategorized_href, text: Category.uncategorized.name
|
||||
|
||||
# Full-row hit target via stretched ::before (mirrors dashboard outflows)
|
||||
assert_select "tr.relative.group\\/category-row[data-category='category-#{expense_category.id}'] a[class*='before:absolute'][class*='before:inset-0']"
|
||||
end
|
||||
|
||||
test "index uncategorized category link uses localized name that Search accepts" do
|
||||
start_date = Date.current.beginning_of_month
|
||||
end_date = Date.current.end_of_month
|
||||
account = @family.accounts.first
|
||||
create_transaction(account: account, name: "Uncategorized cash", amount: 25, date: Date.current)
|
||||
|
||||
@user.update!(locale: "zh-CN")
|
||||
localized_name = I18n.with_locale(:"zh-CN") { Category.uncategorized.name }
|
||||
assert_includes Category.all_uncategorized_names, localized_name
|
||||
|
||||
get reports_path(period_type: :monthly, start_date: start_date, end_date: end_date)
|
||||
assert_response :ok
|
||||
|
||||
href = transactions_path(q: { categories: [ localized_name ], start_date: start_date, end_date: end_date })
|
||||
assert_select "tr[data-category='category-uncategorized'] a[href=?]", href, text: localized_name
|
||||
end
|
||||
|
||||
test "index excludes tax-advantaged account transactions from activity breakdown" do
|
||||
@family.accounts.each { |account| account.entries.destroy_all }
|
||||
|
||||
@@ -451,11 +494,14 @@ class ReportsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_select "tr[data-category='category-#{taxable_category.id}']", text: /Reports Taxable Income/
|
||||
assert_select "tr[data-category='category-#{retirement_category.id}']", count: 0
|
||||
|
||||
other_investments_row = css_select("tr[data-category='category-other_investments']").first
|
||||
assert_not_nil other_investments_row
|
||||
assert_match(/#{Regexp.escape(Category.other_investments.name)}/, other_investments_row.text)
|
||||
assert_match(/#{Regexp.escape(I18n.t("reports.transactions_breakdown.table.entries", count: 1))}/, other_investments_row.text)
|
||||
assert_match(/\$100\.00/, other_investments_row.text)
|
||||
other_investments_rows = css_select("tr[data-category='category-other_investments']")
|
||||
assert_operator other_investments_rows.size, :>=, 1
|
||||
other_investments_rows.each do |row|
|
||||
assert_match(/#{Regexp.escape(Category.other_investments.name)}/, row.text)
|
||||
assert_equal 0, row.css("a").size
|
||||
end
|
||||
assert_match(/#{Regexp.escape(I18n.t("reports.transactions_breakdown.table.entries", count: 1))}/, other_investments_rows.first.text)
|
||||
assert_match(/\$100\.00/, other_investments_rows.first.text)
|
||||
end
|
||||
|
||||
test "monthly period navigation shows previous month link" do
|
||||
|
||||
Reference in New Issue
Block a user