Merge pull request #744 from alessiocappa/FT-UpdateReportPageUI

feat: Improve report page UI
This commit is contained in:
soky srm
2026-01-23 09:19:17 +01:00
committed by GitHub
11 changed files with 264 additions and 227 deletions

View File

@@ -374,13 +374,13 @@ class ReportsController < ApplicationController
family_currency = Current.family.currency
# Helper to initialize a category group hash
init_category_group = ->(id, name, color, type) do
{ category_id: id, category_name: name, category_color: color, type: type, total: 0, count: 0, subcategories: {} }
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: {} }
end
# Helper to initialize a subcategory hash
init_subcategory = ->(category) do
{ category_id: category.id, category_name: category.name, category_color: category.color, 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 }
end
# Helper to process an entry (transaction or trade)
@@ -392,16 +392,16 @@ class ReportsController < ApplicationController
# Uncategorized or Other Investments (for trades)
if is_trade
parent_key = [ :other_investments, type ]
grouped_data[parent_key] ||= init_category_group.call(:other_investments, Category.other_investments_name, Category::OTHER_INVESTMENTS_COLOR, type)
grouped_data[parent_key] ||= init_category_group.call(:other_investments, Category.other_investments.name, Category.other_investments.color, Category.other_investments.lucide_icon, type)
else
parent_key = [ :uncategorized, type ]
grouped_data[parent_key] ||= init_category_group.call(:uncategorized, Category.uncategorized_name, Category::UNCATEGORIZED_COLOR, type)
grouped_data[parent_key] ||= init_category_group.call(:uncategorized, Category.uncategorized.name, Category.uncategorized.color, Category.uncategorized.lucide_icon, type)
end
elsif category.parent_id.present?
# This is a subcategory - group under parent
parent = category.parent
parent_key = [ parent.id, type ]
grouped_data[parent_key] ||= init_category_group.call(parent.id, parent.name, parent.color || Category::UNCATEGORIZED_COLOR, type)
grouped_data[parent_key] ||= init_category_group.call(parent.id, parent.name, parent.color || Category::UNCATEGORIZED_COLOR, parent.lucide_icon, type)
# Add to subcategory
grouped_data[parent_key][:subcategories][category.id] ||= init_subcategory.call(category)
@@ -410,7 +410,7 @@ class ReportsController < ApplicationController
else
# This is a root category (no parent)
parent_key = [ category.id, type ]
grouped_data[parent_key] ||= init_category_group.call(category.id, category.name, category.color || Category::UNCATEGORIZED_COLOR, type)
grouped_data[parent_key] ||= init_category_group.call(category.id, category.name, category.color || Category::UNCATEGORIZED_COLOR, category.lucide_icon, type)
end
grouped_data[parent_key][:count] += 1

View File

@@ -8,78 +8,54 @@
%>
<div>
<h3 class="text-base font-semibold <%= color_class %> mb-4 flex items-center gap-2">
<div class="text-large mb-4 flex items-center gap-2 text-lg">
<%= 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>
<span><%= t(title_key) %>:</span>
<span class="font-medium text-secondary <%= color_class %>"> <%= Money.new(total, Current.family.currency).format %></span>
</div>
<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 %>
<div class="bg-container-inset rounded-xl p-1 overflow-x-auto">
<div class="w-max sm:w-full">
<div class="grid grid-cols-4 items-center uppercase text-xs font-medium text-secondary px-4 py-2">
<div class="col-span-2 font-medium text-secondary"><%= t("reports.transactions_breakdown.table.category") %></div>
<div class="col-span-1 text-right 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 %>
</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>
<% end %>
</div>
<div class="col-span-1 text-right font-medium text-secondary"><%= t("reports.transactions_breakdown.table.percentage") %></div>
</div>
<div class="bg-container rounded-lg shadow-border-xs">
<% groups.each_with_index do |group, idx| %>
<%= render "reports/category_row",
item: group,
total: total,
color_class: color_class,
level: :category
%>
<% if idx < group.size - 1 %>
<%= render "shared/ruler", classes: "mx-3 lg:mx-4" %>
<% end %>
<%# 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>
<% if group[:subcategories].present? && group[:subcategories].any? %>
<% group[:subcategories].each_with_index do |subcategory, idx| %>
<%= render "reports/category_row",
item: subcategory,
total: total,
color_class: color_class,
level: :subcategory
%>
<% end %>
<% if idx < group.size - 1 %>
<%= render "shared/ruler", classes: "mx-3 lg:mx-4" %>
<% end %>
<% end %>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,50 @@
<%
percentage = total.zero? ? 0 : (item[:total].to_f / total * 100).round(1)
is_sub = level == :subcategory
%>
<div data-category="category-<%= item[:category_id] %>" class="grid grid-cols-4 items-center text-secondary text-sm py-3 px-4 lg:px-6 <%= is_sub ? "pl-7 lg:pl-9" : "" %>">
<div class="col-span-2 flex items-center gap-2">
<% if is_sub %>
<div class="text-subdued">
<%= icon "corner-down-right" %>
</div>
<% end %>
<% if item[:category_icon] %>
<div class="h-7 w-7 flex-shrink-0 rounded-full flex justify-center items-center"
style="
background-color: color-mix(in oklab, <%= item[:category_color] %> 10%, transparent);
border-color: color-mix(in oklab, <%= item[:category_color] %> 10%, transparent);
color: <%= item[:category_color] %>;
">
<%= icon(item[:category_icon], color: "current", size: "sm") %>
</div>
<% else %>
<%= render DS::FilledIcon.new(
variant: :text,
hex_color: item[:category_color],
text: item[:category_name],
size: "md",
rounded: true
) %>
<% end %>
<span class="font-medium text-primary">
<%= item[:category_name] %>
</span>
<span class="text-xs text-tertiary whitespace-nowrap">
(<%= t("reports.transactions_breakdown.table.entries", count: item[:count]) %>)
</span>
</div>
<div class="col-span-1 text-right">
<span class="text-sm <%= color_class %>">
<%= Money.new(item[:total], Current.family.currency).format %>
</span>
</div>
<div class="col-span-1 text-right">
<span class="text-sm text-secondary">
<%= percentage %>%
</span>
</div>
</div>

View File

@@ -2,14 +2,12 @@
<% if investment_metrics[:has_investments] %>
<div class="space-y-6">
<h3 class="text-lg font-medium text-primary"><%= t("reports.investment_performance.title") %></h3>
<%# Investment Summary Cards %>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<%# Portfolio Value Card %>
<div class="bg-container-inset rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<%= icon("briefcase", class: "w-4 h-4 text-secondary") %>
<div class="flex items-center gap-2 mb-3">
<%= icon("briefcase", size: "sm") %>
<span class="text-sm text-secondary"><%= t("reports.investment_performance.portfolio_value") %></span>
</div>
<p class="text-xl font-semibold text-primary">
@@ -19,8 +17,8 @@
<%# Total Return Card %>
<div class="bg-container-inset rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<%= icon("trending-up", class: "w-4 h-4 text-secondary") %>
<div class="flex items-center gap-2 mb-3">
<%= icon("trending-up", size: "sm") %>
<span class="text-sm text-secondary"><%= t("reports.investment_performance.total_return") %></span>
</div>
<% if investment_metrics[:unrealized_trend] %>
@@ -35,8 +33,8 @@
<%# Period Contributions Card %>
<div class="bg-container-inset rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<%= icon("arrow-down-to-line", class: "w-4 h-4 text-secondary") %>
<div class="flex items-center gap-2 mb-3">
<%= icon("arrow-down-to-line", size: "sm") %>
<span class="text-sm text-secondary"><%= t("reports.investment_performance.contributions") %></span>
</div>
<p class="text-xl font-semibold text-primary">
@@ -46,8 +44,8 @@
<%# Period Withdrawals Card %>
<div class="bg-container-inset rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<%= icon("arrow-up-from-line", class: "w-4 h-4 text-secondary") %>
<div class="flex items-center gap-2 mb-3">
<%= icon("arrow-up-from-line", size: "sm") %>
<span class="text-sm text-secondary"><%= t("reports.investment_performance.withdrawals") %></span>
</div>
<p class="text-xl font-semibold text-primary">
@@ -59,57 +57,50 @@
<%# Top Holdings Table %>
<% if investment_metrics[:top_holdings].any? %>
<div class="space-y-3">
<h4 class="text-sm font-medium text-secondary uppercase"><%= t("reports.investment_performance.top_holdings") %></h4>
<h4 class="text-lg font-medium"><%= t("reports.investment_performance.top_holdings") %></h4>
<div class="bg-container-inset rounded-lg overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-container">
<tr class="text-left text-secondary uppercase text-xs">
<th class="px-4 py-3 font-medium"><%= t("reports.investment_performance.holding") %></th>
<th class="px-4 py-3 font-medium text-right"><%= t("reports.investment_performance.weight") %></th>
<th class="px-4 py-3 font-medium text-right"><%= t("reports.investment_performance.value") %></th>
<th class="px-4 py-3 font-medium text-right"><%= t("reports.investment_performance.return") %></th>
</tr>
</thead>
<tbody class="divide-y divide-primary">
<% investment_metrics[:top_holdings].each do |holding| %>
<tr>
<td class="px-4 py-3">
<div class="flex items-center gap-3">
<% if holding.security.brandfetch_icon_url.present? %>
<img src="<%= holding.security.brandfetch_icon_url %>" alt="<%= holding.ticker %>" class="w-6 h-6 rounded-full">
<% elsif holding.security.logo_url.present? %>
<img src="<%= Setting.transform_brand_fetch_url(holding.security.logo_url) %>" alt="<%= holding.ticker %>" class="w-6 h-6 rounded-full">
<% else %>
<div class="w-6 h-6 rounded-full bg-container flex items-center justify-center text-xs font-medium text-secondary">
<%= holding.ticker[0..1] %>
</div>
<% end %>
<div>
<p class="font-medium text-primary"><%= holding.ticker %></p>
<p class="text-xs text-secondary"><%= truncate(holding.name, length: 25) %></p>
</div>
<div class="bg-container-inset rounded-xl p-1 overflow-x-auto">
<div class="grid grid-cols-4 items-center uppercase text-xs font-medium text-secondary px-4 py-2">
<div class="font-medium text-secondary"><%= t("reports.investment_performance.holding") %></div>
<div class="text-right font-medium text-secondary"><%= t("reports.investment_performance.weight") %></div>
<div class="text-right font-medium text-secondary"><%= t("reports.investment_performance.value") %></div>
<div class="text-right font-medium text-secondary"><%= t("reports.investment_performance.return") %></div>
</div>
<div class="bg-container rounded-lg shadow-border-xs">
<% investment_metrics[:top_holdings].each_with_index do |holding, idx| %>
<div class="grid grid-cols-4 items-center text-secondary text-sm py-3 px-4 lg:px-6">
<div class="flex items-center gap-3">
<% if holding.security.brandfetch_icon_url.present? %>
<img src="<%= holding.security.brandfetch_icon_url %>" alt="<%= holding.ticker %>" class="w-6 h-6 rounded-full">
<% elsif holding.security.logo_url.present? %>
<img src="<%= Setting.transform_brand_fetch_url(holding.security.logo_url) %>" alt="<%= holding.ticker %>" class="w-6 h-6 rounded-full">
<% else %>
<div class="w-8 h-8 rounded-full bg-container-inset flex items-center justify-center text-xs font-medium text-secondary">
<%= holding.ticker[0..1] %>
</div>
</td>
<td class="px-4 py-3 text-right text-secondary">
<%= number_to_percentage(holding.weight || 0, precision: 1) %>
</td>
<td class="px-4 py-3 text-right font-medium text-primary">
<%= format_money(holding.amount_money) %>
</td>
<td class="px-4 py-3 text-right">
<% if holding.trend %>
<span style="color: <%= holding.trend.color %>">
<%= holding.trend.percent_formatted %>
</span>
<% else %>
<span class="text-secondary"><%= t("reports.investment_performance.no_data") %></span>
<% end %>
</td>
</tr>
<% end %>
<div>
<p class="font-medium text-primary"><%= holding.ticker %></p>
<p class="text-xs text-secondary"><%= truncate(holding.name, length: 25) %></p>
</div>
</div>
<div class="text-right text-secondary"><%= number_to_percentage(holding.weight || 0, precision: 1) %></div>
<div class="text-right font-medium text-primary"><%= format_money(holding.amount_money) %></div>
<div class="text-right">
<% if holding.trend %>
<span style="color: <%= holding.trend.color %>">
<%= holding.trend.percent_formatted %>
</span>
<% else %>
<span class="text-secondary"><%= t("reports.investment_performance.no_data") %></span>
<% end %>
</div>
</div>
<% if idx < investment_metrics[:top_holdings].size - 1 %>
<%= render "shared/ruler", classes: "mx-3 lg:mx-4" %>
<% end %>
</tbody>
</table>
<% end %>
</div>
</div>
</div>
<% end %>
@@ -117,7 +108,7 @@
<%# Gains by Tax Treatment %>
<% if investment_metrics[:gains_by_tax_treatment].present? %>
<div class="space-y-3">
<h4 class="text-sm font-medium text-secondary uppercase"><%= t("reports.investment_performance.gains_by_tax_treatment") %></h4>
<h4 class="text-lg font-medium"><%= t("reports.investment_performance.gains_by_tax_treatment") %></h4>
<div class="flex flex-wrap gap-4">
<% investment_metrics[:gains_by_tax_treatment].each do |treatment, data| %>
@@ -202,7 +193,7 @@
<%# Investment Accounts Summary %>
<% if investment_metrics[:accounts].any? %>
<div class="space-y-3">
<h4 class="text-sm font-medium text-secondary uppercase"><%= t("reports.investment_performance.accounts") %></h4>
<h4 class="text-lg font-medium"><%= t("reports.investment_performance.accounts") %></h4>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<% investment_metrics[:accounts].each do |account| %>

View File

@@ -3,31 +3,31 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<%# Current Net Worth %>
<div class="p-4 bg-surface-inset rounded-lg">
<p class="text-xs text-tertiary mb-1"><%= t("reports.net_worth.current_net_worth") %></p>
<p class="text-xl font-semibold <%= net_worth_metrics[:current_net_worth] >= 0 ? "text-success" : "text-destructive" %>">
<p class="text-sm text-secondary mb-2"><%= t("reports.net_worth.current_net_worth") %></p>
<p class="text-2xl font-semibold <%= net_worth_metrics[:current_net_worth] >= 0 ? "text-success" : "text-destructive" %>">
<%= net_worth_metrics[:current_net_worth].format %>
</p>
</div>
<%# Period Change %>
<div class="p-4 bg-surface-inset rounded-lg">
<p class="text-xs text-tertiary mb-1"><%= t("reports.net_worth.period_change") %></p>
<p class="text-sm text-secondary mb-2"><%= t("reports.net_worth.period_change") %></p>
<% if net_worth_metrics[:trend] %>
<% trend = net_worth_metrics[:trend] %>
<p class="text-xl font-semibold" style="color: <%= trend.color %>">
<p class="text-2xl font-semibold mb-1" style="color: <%= trend.color %>">
<%= trend.value.format(signify_positive: true) %>
</p>
<p class="text-xs" style="color: <%= trend.color %>">
<%= trend.value >= 0 ? "+" : "" %><%= trend.percent_formatted %>
</p>
<% else %>
<p class="text-xl font-semibold text-tertiary">--</p>
<p class="text-2xl font-semibold mb-1 text-tertiary">--</p>
<% end %>
</div>
<%# Assets vs Liabilities %>
<div class="p-4 bg-surface-inset rounded-lg">
<p class="text-xs text-tertiary mb-1"><%= t("reports.net_worth.assets_vs_liabilities") %></p>
<p class="text-sm text-secondary mb-2"><%= t("reports.net_worth.assets_vs_liabilities") %></p>
<div class="flex items-baseline gap-2">
<span class="text-lg font-semibold text-success"><%= net_worth_metrics[:total_assets].format %></span>
<span class="text-xs text-tertiary">-</span>
@@ -39,30 +39,40 @@
<%# Asset/Liability Breakdown %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<%# Assets Summary %>
<div>
<h3 class="text-sm font-medium text-secondary mb-3"><%= t("reports.net_worth.total_assets") %></h3>
<div class="space-y-2">
<% net_worth_metrics[:asset_groups].each do |group| %>
<div class="flex items-center justify-between py-2 border-b border-tertiary/50">
<span class="text-sm text-primary"><%= group[:name] %></span>
<span class="text-sm font-medium text-success"><%= group[:total].format %></span>
<div class="bg-container-inset rounded-xl p-1 overflow-x-auto w-full flex flex-col">
<div class="grid grid-cols-2 items-center uppercase text-xs font-medium text-secondary px-4 py-2">
<div class="col-span-1 font-medium text-secondary"><%= t("reports.net_worth.total_assets") %></div>
</div>
<div class="bg-container rounded-lg shadow-border-xs flex-1 flex flex-col">
<% net_worth_metrics[:asset_groups].each_with_index do |group, idx| %>
<div class="grid grid-cols-2 items-center text-secondary text-sm py-3 px-4 lg:px-6">
<div class="col-span-1 font-medium text-secondary"><%= group[:name] %></div>
<div class="col-span-1 font-medium text-secondary justify-self-end text-success"><%= group[:total].format %></div>
</div>
<% if idx < net_worth_metrics[:asset_groups].size - 1 %>
<%= render "shared/ruler", classes: "mx-3 lg:mx-4" %>
<% end %>
<% end %>
<% if net_worth_metrics[:asset_groups].empty? %>
<p class="text-sm text-tertiary py-2"><%= t("reports.net_worth.no_assets") %></p>
<p class="text-sm text-secondary p-2 text-center"><%= t("reports.net_worth.no_assets") %></p>
<% end %>
</div>
</div>
<%# Liabilities Summary %>
<div>
<h3 class="text-sm font-medium text-secondary mb-3"><%= t("reports.net_worth.total_liabilities") %></h3>
<div class="space-y-2">
<% net_worth_metrics[:liability_groups].each do |group| %>
<div class="flex items-center justify-between py-2 border-b border-tertiary/50">
<span class="text-sm text-primary"><%= group[:name] %></span>
<span class="text-sm font-medium text-destructive"><%= group[:total].format %></span>
<div class="bg-container-inset rounded-xl p-1 overflow-x-auto w-full flex flex-col">
<div class="grid grid-cols-2 items-center uppercase text-xs font-medium text-secondary px-4 py-2">
<div class="col-span-1 font-medium text-secondary"><%= t("reports.net_worth.total_liabilities") %></div>
</div>
<div class="bg-container rounded-lg shadow-border-xs flex-1 flex flex-col">
<% net_worth_metrics[:liability_groups].each_with_index do |group, idx| %>
<div class="grid grid-cols-2 items-center text-secondary text-sm py-3 px-4 lg:px-6">
<div class="col-span-1 font-medium text-secondary"><%= group[:name] %></div>
<div class="col-span-1 font-medium text-secondary justify-self-end text-destructive"><%= group[:total].format %></div>
</div>
<% if idx < net_worth_metrics[:liability_groups].size - 1 %>
<%= render "shared/ruler", classes: "mx-3 lg:mx-4" %>
<% end %>
<% end %>
<% if net_worth_metrics[:liability_groups].empty? %>
<p class="text-sm text-tertiary py-2"><%= t("reports.net_worth.no_liabilities") %></p>

View File

@@ -3,7 +3,7 @@
<div class="bg-container rounded-xl shadow-border-xs p-6">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<%= icon("trending-up", class: "w-5 h-5 text-success") %>
<%= icon("trending-up", size: "sm") %>
<h3 class="text-sm font-medium text-secondary">
<%= t("reports.summary.total_income") %>
</h3>
@@ -18,12 +18,12 @@
<% if metrics[:income_change] %>
<div class="flex items-center gap-1.5">
<% if metrics[:income_change] >= 0 %>
<%= icon("arrow-up", class: "w-4 h-4 text-success") %>
<%= icon("arrow-up", size: "sm") %>
<span class="text-sm font-medium text-success">
+<%= metrics[:income_change] %>%
</span>
<% else %>
<%= icon("arrow-down", class: "w-4 h-4 text-destructive") %>
<%= icon("arrow-down", size: "sm") %>
<span class="text-sm font-medium text-destructive">
<%= metrics[:income_change] %>%
</span>
@@ -40,7 +40,7 @@
<div class="bg-container rounded-xl shadow-border-xs p-6">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<%= icon("trending-down", class: "w-5 h-5 text-destructive") %>
<%= icon("trending-down", class: "w-5 h-5") %>
<h3 class="text-sm font-medium text-secondary">
<%= t("reports.summary.total_expenses") %>
</h3>
@@ -77,7 +77,7 @@
<div class="bg-container rounded-xl shadow-border-xs p-6">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<%= icon("piggy-bank", class: "w-5 h-5 text-primary") %>
<%= icon("piggy-bank", class: "w-5 h-5") %>
<h3 class="text-sm font-medium text-secondary">
<%= t("reports.summary.net_savings") %>
</h3>
@@ -99,7 +99,7 @@
<div class="bg-container rounded-xl shadow-border-xs p-6">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<%= icon("gauge", class: "w-5 h-5 text-primary") %>
<%= icon("gauge", class: "w-5 h-5") %>
<h3 class="text-sm font-medium text-secondary">
<%= t("reports.summary.budget_performance") %>
</h3>

View File

@@ -14,14 +14,13 @@
<%# Export Options %>
<div class="flex items-center gap-2">
<span class="text-sm text-secondary"><%= t("reports.transactions_breakdown.export.label") %>:</span>
<%= link_to export_transactions_reports_path(base_params.merge(format: :csv)),
class: "inline-flex items-center gap-1 text-sm px-3 py-1 bg-surface-inset text-secondary hover:bg-surface-hover rounded-lg" do %>
class: "font-medium whitespace-nowrap inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm text-primary border border-secondary bg-transparent hover:bg-surface-hover" do %>
<%= icon("download", class: "w-3 h-3") %>
<span><%= t("reports.transactions_breakdown.export.csv") %></span>
<% end %>
<%= link_to google_sheets_instructions_reports_path(base_params),
class: "inline-flex items-center gap-1 text-sm px-3 py-1 bg-surface-inset text-secondary hover:bg-surface-hover rounded-lg",
class: "font-medium whitespace-nowrap inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm text-primary border border-secondary bg-transparent hover:bg-surface-hover",
data: { turbo_frame: "modal" } do %>
<%= icon("external-link", class: "w-3 h-3") %>
<span><%= t("reports.transactions_breakdown.export.google_sheets") %></span>
@@ -76,11 +75,11 @@
</div>
<%# Summary Stats %>
<div class="mt-4 text-sm text-secondary">
<div class="mt-4 text-xs text-subdued">
<%= t("reports.transactions_breakdown.pagination.showing", count: transactions.sum { |g| g[:count] }) %>
</div>
<% else %>
<div class="text-center py-8 text-tertiary">
<div class="text-center py-8 text-subdued">
<%= t("reports.transactions_breakdown.no_transactions") %>
</div>
<% end %>

View File

@@ -1,48 +1,49 @@
<div class="space-y-8">
<%# Month-over-Month Trends %>
<div>
<h3 class="text-sm font-medium text-secondary mb-4">
<h3 class="text-lg font-medium mb-4">
<%= t("reports.trends.monthly_breakdown") %>
</h3>
<% if trends_data.any? %>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-tertiary">
<th class="text-left py-2 pr-4 font-medium text-secondary"><%= t("reports.trends.month") %></th>
<th class="text-right py-2 px-4 font-medium text-secondary"><%= t("reports.trends.income") %></th>
<th class="text-right py-2 px-4 font-medium text-secondary"><%= t("reports.trends.expenses") %></th>
<th class="text-right py-2 px-2 font-medium text-secondary"><%= t("reports.trends.net") %></th>
<th class="text-right py-2 pl-4 font-medium text-secondary"><%= t("reports.trends.savings_rate") %></th>
</tr>
</thead>
<tbody>
<% trends_data.each do |trend| %>
<tr class="border-b border-tertiary/50 <%= trend[:is_current_month] ? "font-medium" : "" %>">
<td class="py-3 pr-4 text-primary">
<div class="bg-container-inset rounded-xl p-1 overflow-x-auto">
<div class="w-max sm:w-full">
<div role="columnheader" class="grid grid-cols-5 items-center uppercase text-xs font-medium text-secondary px-4 py-2">
<div class="font-medium text-secondary"><%= t("reports.trends.month") %></div>
<div class="text-right font-medium text-secondary"><%= t("reports.trends.income") %></div>
<div class="text-right font-medium text-secondary"><%= t("reports.trends.expenses") %></div>
<div class="text-right font-medium text-secondary"><%= t("reports.trends.net") %></div>
<div class="text-right font-medium text-secondary"><%= t("reports.trends.savings_rate") %></div>
</div>
<div class="bg-container rounded-lg shadow-border-xs">
<% trends_data.each_with_index do |trend, idx| %>
<div class="grid grid-cols-5 items-center text-secondary text-sm py-3 px-4 lg:px-6">
<div class="flex items-center gap-2 <%= trend[:is_current_month] ? "font-medium" : "" %>">
<%= trend[:month] %>
<% if trend[:is_current_month] %>
<span class="ml-2 text-xs text-tertiary">(<%= t("reports.trends.current") %>)</span>
<span class="text-xs text-tertiary">(<%= t("reports.trends.current") %>)</span>
<% end %>
</td>
<td class="text-right py-3 px-4 text-success">
</div>
<div class="text-right">
<%= Money.new(trend[:income], Current.family.currency).format %>
</td>
<td class="text-right py-3 px-4 text-destructive">
</div>
<div class="text-right">
<%= Money.new(trend[:expenses], Current.family.currency).format %>
</td>
<td class="text-right py-3 px-2 <%= trend[:net] >= 0 ? "text-success" : "text-destructive" %>">
</div>
<div class="text-right <%= trend[:net] >= 0 ? "text-success" : "text-destructive" %>">
<%= Money.new(trend[:net], Current.family.currency).format %>
</td>
<td class="text-right py-3 pl-4 <%= trend[:net] >= 0 ? "text-success" : "text-destructive" %>">
</div>
<div class="text-right <%= trend[:net] >= 0 ? "text-success" : "text-destructive" %>">
<% savings_rate = trend[:income] > 0 ? ((trend[:net].to_f / trend[:income].to_f) * 100).round(1) : 0 %>
<%= savings_rate %>%
</td>
</tr>
</div>
</div>
<% if idx < trends_data.size - 1 %>
<%= render "shared/ruler", classes: "mx-3 lg:mx-4" %>
<% end %>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
<%# Trend Insights %>
@@ -52,21 +53,21 @@
<% avg_net = trends_data.sum { |t| t[:net] } / trends_data.length %>
<div class="p-4 bg-surface-inset rounded-lg">
<p class="text-xs text-tertiary mb-1"><%= t("reports.trends.avg_monthly_income") %></p>
<p class="text-sm text-secondary mb-1"><%= t("reports.trends.avg_monthly_income") %></p>
<p class="text-lg font-semibold text-success">
<%= Money.new(avg_income, Current.family.currency).format %>
</p>
</div>
<div class="p-4 bg-surface-inset rounded-lg">
<p class="text-xs text-tertiary mb-1"><%= t("reports.trends.avg_monthly_expenses") %></p>
<p class="text-sm text-secondary mb-1"><%= t("reports.trends.avg_monthly_expenses") %></p>
<p class="text-lg font-semibold text-destructive">
<%= Money.new(avg_expenses, Current.family.currency).format %>
</p>
</div>
<div class="p-4 bg-surface-inset rounded-lg">
<p class="text-xs text-tertiary mb-1"><%= t("reports.trends.avg_monthly_savings") %></p>
<p class="text-sm text-secondary mb-1"><%= t("reports.trends.avg_monthly_savings") %></p>
<p class="text-lg font-semibold <%= avg_net >= 0 ? "text-success" : "text-destructive" %>">
<%= Money.new(avg_net, Current.family.currency).format %>
</p>

View File

@@ -1,24 +1,36 @@
<% content_for :page_header do %>
<div class="space-y-4 mb-6">
<div class="space-y-1">
<h1 class="text-xl lg:text-3xl font-medium text-primary">
<%= t("reports.index.title") %>
</h1>
<p class="text-sm lg:text-base text-secondary">
<%= t("reports.index.subtitle") %>
</p>
</div>
<%# Flash messages %>
<% if flash[:alert].present? %>
<div class="p-3 rounded-lg bg-destructive-surface text-sm text-destructive">
<%= flash[:alert] %>
<header class="flex justify-between items-center text-primary font-medium gap-4">
<div class="space-y-1">
<h1 class="text-xl lg:text-3xl font-medium text-primary">
<%= t("reports.index.title") %>
</h1>
<p class="text-sm lg:text-base text-secondary">
<%= t("reports.index.subtitle") %>
</p>
</div>
<% end %>
<%# Flash messages %>
<% if flash[:alert].present? %>
<div class="p-3 rounded-lg bg-destructive-surface text-sm text-destructive">
<%= flash[:alert] %>
</div>
<% end %>
<%# Print Report Button %>
<%= link_to print_reports_path(period_type: @period_type, start_date: @start_date, end_date: @end_date),
target: "_blank",
rel: "noopener",
aria: { label: t("reports.index.print_report") },
class: "font-medium whitespace-nowrap inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm text-primary border border-secondary bg-transparent hover:bg-surface-hover" do %>
<%= icon("printer", size: "sm") %>
<span class="hidden sm:inline"><%= t("reports.index.print_report") %></span>
<% end %>
</header>
<%# Period Navigation Tabs %>
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2 overflow-x-auto pb-2">
<div class="flex items-center gap-2 overflow-x-auto no-scrollbar">
<%= render DS::Link.new(
text: t("reports.index.periods.monthly"),
variant: @period_type == :monthly ? "secondary" : "ghost",
@@ -50,16 +62,6 @@
size: :sm
) %>
</div>
<%# Print Report Button %>
<%= link_to print_reports_path(period_type: @period_type, start_date: @start_date, end_date: @end_date),
target: "_blank",
rel: "noopener",
aria: { label: t("reports.index.print_report") },
class: "inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-secondary bg-surface-inset hover:bg-surface-hover rounded-lg transition-colors flex-shrink-0" do %>
<%= icon("printer", size: "sm") %>
<span class="hidden sm:inline"><%= t("reports.index.print_report") %></span>
<% end %>
</div>
<%# Custom Date Range Picker (only shown when custom is selected) %>
@@ -88,7 +90,7 @@
<% end %>
<%# Period Display %>
<div class="text-sm text-secondary">
<div class="text-sm text-subdued">
<%= t("reports.index.showing_period",
start: @start_date.strftime("%b %-d, %Y"),
end: @end_date.strftime("%b %-d, %Y")) %>

View File

@@ -105,8 +105,8 @@ en:
income: Income
uncategorized: Uncategorized
entries:
one: entry
other: entries
one: "%{count} entry"
other: "%{count} entries"
percentage: "% of Total"
pagination:
showing:

View File

@@ -82,7 +82,9 @@ class ReportsControllerTest < ActionDispatch::IntegrationTest
get reports_path(period_type: :monthly)
assert_response :ok
assert_select "h2", text: I18n.t("reports.trends.title")
assert_select "th", text: I18n.t("reports.trends.month")
assert_select '[role="columnheader"]' do
assert_select "div", text: I18n.t("reports.trends.month")
end
end
test "index handles invalid date parameters gracefully" do
@@ -236,6 +238,12 @@ class ReportsControllerTest < ActionDispatch::IntegrationTest
get reports_path(period_type: :monthly)
assert_response :ok
assert_select "table.w-full"
# Parent category
assert_select "div[data-category='category-#{parent_category.id}']", text: /^Entertainment/
# Subcategories
assert_select "div[data-category='category-#{subcategory_movies.id}']", text: /^Movies/
assert_select "div[data-category='category-#{subcategory_games.id}']", text: /^Games/
end
end