mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 03:54:08 +00:00
Merge pull request #744 from alessiocappa/FT-UpdateReportPageUI
feat: Improve report page UI
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
50
app/views/reports/_category_row.html.erb
Normal file
50
app/views/reports/_category_row.html.erb
Normal 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>
|
||||
@@ -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| %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")) %>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user