mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 22:34:47 +00:00
Support uncategorized investments (#593)
* Support uncategorized investments * FIX sankey id collision * Fix reports * Fix hardcoded string and i8n * FIX plurals * Remove spending patterns section add net worth section to reports
This commit is contained in:
@@ -154,7 +154,9 @@ class PagesController < ApplicationController
|
||||
percentage = total_income.zero? ? 0 : (val / total_income * 100).round(1)
|
||||
color = ct.category.color.presence || Category::COLORS.sample
|
||||
|
||||
idx = add_node.call("income_#{ct.category.id}", ct.category.name, val, percentage, color)
|
||||
# Use name as fallback key for synthetic categories (no id)
|
||||
node_key = "income_#{ct.category.id || ct.category.name}"
|
||||
idx = add_node.call(node_key, ct.category.name, val, percentage, color)
|
||||
links << { source: idx, target: cash_flow_idx, value: val, color: color, percentage: percentage }
|
||||
end
|
||||
|
||||
@@ -168,7 +170,9 @@ class PagesController < ApplicationController
|
||||
percentage = total_expense.zero? ? 0 : (val / total_expense * 100).round(1)
|
||||
color = ct.category.color.presence || Category::UNCATEGORIZED_COLOR
|
||||
|
||||
idx = add_node.call("expense_#{ct.category.id}", ct.category.name, val, percentage, color)
|
||||
# Use name as fallback key for synthetic categories (no id)
|
||||
node_key = "expense_#{ct.category.id || ct.category.name}"
|
||||
idx = add_node.call(node_key, ct.category.name, val, percentage, color)
|
||||
links << { source: cash_flow_idx, target: idx, value: val, color: color, percentage: percentage }
|
||||
end
|
||||
|
||||
@@ -198,7 +202,8 @@ class PagesController < ApplicationController
|
||||
currency: ct.currency,
|
||||
percentage: ct.weight.round(1),
|
||||
color: ct.category.color.presence || Category::UNCATEGORIZED_COLOR,
|
||||
icon: ct.category.lucide_icon
|
||||
icon: ct.category.lucide_icon,
|
||||
clickable: !ct.category.other_investments?
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ class ReportsController < ApplicationController
|
||||
# Build trend data (last 6 months)
|
||||
@trends_data = build_trends_data
|
||||
|
||||
# Spending patterns (weekday vs weekend)
|
||||
@spending_patterns = build_spending_patterns
|
||||
# Net worth metrics
|
||||
@net_worth_metrics = build_net_worth_metrics
|
||||
|
||||
# Transactions breakdown
|
||||
@transactions = build_transactions_breakdown
|
||||
@@ -124,11 +124,19 @@ class ReportsController < ApplicationController
|
||||
|
||||
def build_reports_sections
|
||||
all_sections = [
|
||||
{
|
||||
key: "net_worth",
|
||||
title: "reports.net_worth.title",
|
||||
partial: "reports/net_worth",
|
||||
locals: { net_worth_metrics: @net_worth_metrics },
|
||||
visible: Current.family.accounts.any?,
|
||||
collapsible: true
|
||||
},
|
||||
{
|
||||
key: "trends_insights",
|
||||
title: "reports.trends.title",
|
||||
partial: "reports/trends_insights",
|
||||
locals: { trends_data: @trends_data, spending_patterns: @spending_patterns },
|
||||
locals: { trends_data: @trends_data },
|
||||
visible: Current.family.transactions.any?,
|
||||
collapsible: true
|
||||
},
|
||||
@@ -310,61 +318,6 @@ class ReportsController < ApplicationController
|
||||
trends
|
||||
end
|
||||
|
||||
def build_spending_patterns
|
||||
# Analyze weekday vs weekend spending
|
||||
weekday_total = 0
|
||||
weekend_total = 0
|
||||
weekday_count = 0
|
||||
weekend_count = 0
|
||||
|
||||
# Build query matching income_statement logic:
|
||||
# Expenses are transactions with positive amounts, regardless of category
|
||||
expense_transactions = Transaction
|
||||
.joins(:entry)
|
||||
.joins(entry: :account)
|
||||
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
|
||||
.where(entries: { entryable_type: "Transaction", excluded: false, date: @period.date_range })
|
||||
.where(kind: [ "standard", "loan_payment" ])
|
||||
.where("entries.amount > 0") # Positive amount = expense (matching income_statement logic)
|
||||
|
||||
# Sum up amounts by weekday vs weekend
|
||||
expense_transactions.each do |transaction|
|
||||
entry = transaction.entry
|
||||
amount = entry.amount.abs
|
||||
|
||||
if entry.date.wday.in?([ 0, 6 ]) # Sunday or Saturday
|
||||
weekend_total += amount
|
||||
weekend_count += 1
|
||||
else
|
||||
weekday_total += amount
|
||||
weekday_count += 1
|
||||
end
|
||||
end
|
||||
|
||||
weekday_avg = weekday_count.positive? ? (weekday_total / weekday_count) : 0
|
||||
weekend_avg = weekend_count.positive? ? (weekend_total / weekend_count) : 0
|
||||
|
||||
{
|
||||
weekday_total: weekday_total,
|
||||
weekend_total: weekend_total,
|
||||
weekday_avg: weekday_avg,
|
||||
weekend_avg: weekend_avg,
|
||||
weekday_count: weekday_count,
|
||||
weekend_count: weekend_count
|
||||
}
|
||||
end
|
||||
|
||||
def default_spending_patterns
|
||||
{
|
||||
weekday_total: 0,
|
||||
weekend_total: 0,
|
||||
weekday_avg: 0,
|
||||
weekend_avg: 0,
|
||||
weekday_count: 0,
|
||||
weekend_count: 0
|
||||
}
|
||||
end
|
||||
|
||||
def build_transactions_breakdown
|
||||
# Base query: all transactions in the period
|
||||
# Exclude transfers, one-time, and CC payments (matching income_statement logic)
|
||||
@@ -379,25 +332,55 @@ class ReportsController < ApplicationController
|
||||
# Apply filters
|
||||
transactions = apply_transaction_filters(transactions)
|
||||
|
||||
# Get trades in the period (matching income_statement logic)
|
||||
trades = Trade
|
||||
.joins(:entry)
|
||||
.joins(entry: :account)
|
||||
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
|
||||
.where(entries: { entryable_type: "Trade", excluded: false, date: @period.date_range })
|
||||
.includes(entry: :account, category: [])
|
||||
|
||||
# Get sort parameters
|
||||
sort_by = params[:sort_by] || "amount"
|
||||
sort_direction = params[:sort_direction] || "desc"
|
||||
|
||||
# Group by category and type
|
||||
all_transactions = transactions.to_a
|
||||
grouped_data = {}
|
||||
family_currency = Current.family.currency
|
||||
|
||||
all_transactions.each do |transaction|
|
||||
# Process transactions
|
||||
transactions.each do |transaction|
|
||||
entry = transaction.entry
|
||||
is_expense = entry.amount > 0
|
||||
type = is_expense ? "expense" : "income"
|
||||
category_name = transaction.category&.name || "Uncategorized"
|
||||
category_color = transaction.category&.color || "#9CA3AF"
|
||||
category_color = transaction.category&.color || Category::UNCATEGORIZED_COLOR
|
||||
|
||||
# Convert to family currency
|
||||
converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency, fallback_rate: 1).amount
|
||||
|
||||
key = [ category_name, type, category_color ]
|
||||
grouped_data[key] ||= { total: 0, count: 0 }
|
||||
grouped_data[key][:count] += 1
|
||||
grouped_data[key][:total] += entry.amount.abs
|
||||
grouped_data[key][:total] += converted_amount
|
||||
end
|
||||
|
||||
# Process trades
|
||||
trades.each do |trade|
|
||||
entry = trade.entry
|
||||
is_expense = entry.amount > 0
|
||||
type = is_expense ? "expense" : "income"
|
||||
# Use "Other Investments" for trades without category
|
||||
category_name = trade.category&.name || Category.other_investments_name
|
||||
category_color = trade.category&.color || Category::OTHER_INVESTMENTS_COLOR
|
||||
|
||||
# Convert to family currency
|
||||
converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency, fallback_rate: 1).amount
|
||||
|
||||
key = [ category_name, type, category_color ]
|
||||
grouped_data[key] ||= { total: 0, count: 0 }
|
||||
grouped_data[key][:count] += 1
|
||||
grouped_data[key][:total] += converted_amount
|
||||
end
|
||||
|
||||
# Convert to array
|
||||
@@ -438,6 +421,39 @@ class ReportsController < ApplicationController
|
||||
}
|
||||
end
|
||||
|
||||
def build_net_worth_metrics
|
||||
balance_sheet = Current.family.balance_sheet
|
||||
currency = Current.family.currency
|
||||
|
||||
# Current net worth
|
||||
current_net_worth = balance_sheet.net_worth
|
||||
total_assets = balance_sheet.assets.total
|
||||
total_liabilities = balance_sheet.liabilities.total
|
||||
|
||||
# Get net worth series for the period to calculate change
|
||||
# The series.trend gives us the change from first to last value in the period
|
||||
net_worth_series = balance_sheet.net_worth_series(period: @period)
|
||||
trend = net_worth_series&.trend
|
||||
|
||||
# Get asset and liability groups for breakdown
|
||||
asset_groups = balance_sheet.assets.account_groups.map do |group|
|
||||
{ name: group.name, total: Money.new(group.total, currency) }
|
||||
end.reject { |g| g[:total].zero? }
|
||||
|
||||
liability_groups = balance_sheet.liabilities.account_groups.map do |group|
|
||||
{ name: group.name, total: Money.new(group.total, currency) }
|
||||
end.reject { |g| g[:total].zero? }
|
||||
|
||||
{
|
||||
current_net_worth: Money.new(current_net_worth, currency),
|
||||
total_assets: Money.new(total_assets, currency),
|
||||
total_liabilities: Money.new(total_liabilities, currency),
|
||||
trend: trend,
|
||||
asset_groups: asset_groups,
|
||||
liability_groups: liability_groups
|
||||
}
|
||||
end
|
||||
|
||||
def apply_transaction_filters(transactions)
|
||||
# Filter by category (including subcategories)
|
||||
if params[:filter_category_id].present?
|
||||
@@ -533,9 +549,19 @@ class ReportsController < ApplicationController
|
||||
|
||||
transactions = apply_transaction_filters(transactions)
|
||||
|
||||
# Group transactions by category, type, and month
|
||||
breakdown = {}
|
||||
# Get trades in the period (matching income_statement logic)
|
||||
trades = Trade
|
||||
.joins(:entry)
|
||||
.joins(entry: :account)
|
||||
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
|
||||
.where(entries: { entryable_type: "Trade", excluded: false, date: @period.date_range })
|
||||
.includes(entry: :account, category: [])
|
||||
|
||||
# Group by category, type, and month
|
||||
breakdown = {}
|
||||
family_currency = Current.family.currency
|
||||
|
||||
# Process transactions
|
||||
transactions.each do |transaction|
|
||||
entry = transaction.entry
|
||||
is_expense = entry.amount > 0
|
||||
@@ -543,11 +569,33 @@ class ReportsController < ApplicationController
|
||||
category_name = transaction.category&.name || "Uncategorized"
|
||||
month_key = entry.date.beginning_of_month
|
||||
|
||||
# Convert to family currency
|
||||
converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency, fallback_rate: 1).amount
|
||||
|
||||
key = [ category_name, type ]
|
||||
breakdown[key] ||= { category: category_name, type: type, months: {}, total: 0 }
|
||||
breakdown[key][:months][month_key] ||= 0
|
||||
breakdown[key][:months][month_key] += entry.amount.abs
|
||||
breakdown[key][:total] += entry.amount.abs
|
||||
breakdown[key][:months][month_key] += converted_amount
|
||||
breakdown[key][:total] += converted_amount
|
||||
end
|
||||
|
||||
# Process trades
|
||||
trades.each do |trade|
|
||||
entry = trade.entry
|
||||
is_expense = entry.amount > 0
|
||||
type = is_expense ? "expense" : "income"
|
||||
# Use "Other Investments" for trades without category
|
||||
category_name = trade.category&.name || Category.other_investments_name
|
||||
month_key = entry.date.beginning_of_month
|
||||
|
||||
# Convert to family currency
|
||||
converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency, fallback_rate: 1).amount
|
||||
|
||||
key = [ category_name, type ]
|
||||
breakdown[key] ||= { category: category_name, type: type, months: {}, total: 0 }
|
||||
breakdown[key][:months][month_key] ||= 0
|
||||
breakdown[key][:months][month_key] += converted_amount
|
||||
breakdown[key][:total] += converted_amount
|
||||
end
|
||||
|
||||
# Convert to array and sort by type and total (descending)
|
||||
|
||||
@@ -31,10 +31,15 @@ class Category < ApplicationRecord
|
||||
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
|
||||
|
||||
UNCATEGORIZED_COLOR = "#737373"
|
||||
OTHER_INVESTMENTS_COLOR = "#e99537"
|
||||
TRANSFER_COLOR = "#444CE7"
|
||||
PAYMENT_COLOR = "#db5a54"
|
||||
TRADE_COLOR = "#e99537"
|
||||
|
||||
# Synthetic category name keys for i18n
|
||||
UNCATEGORIZED_NAME_KEY = "models.category.uncategorized"
|
||||
OTHER_INVESTMENTS_NAME_KEY = "models.category.other_investments"
|
||||
|
||||
class Group
|
||||
attr_reader :category, :subcategories
|
||||
|
||||
@@ -82,12 +87,30 @@ class Category < ApplicationRecord
|
||||
|
||||
def uncategorized
|
||||
new(
|
||||
name: "Uncategorized",
|
||||
name: I18n.t(UNCATEGORIZED_NAME_KEY),
|
||||
color: UNCATEGORIZED_COLOR,
|
||||
lucide_icon: "circle-dashed"
|
||||
)
|
||||
end
|
||||
|
||||
def other_investments
|
||||
new(
|
||||
name: I18n.t(OTHER_INVESTMENTS_NAME_KEY),
|
||||
color: OTHER_INVESTMENTS_COLOR,
|
||||
lucide_icon: "trending-up"
|
||||
)
|
||||
end
|
||||
|
||||
# Helper to get the localized name for uncategorized
|
||||
def uncategorized_name
|
||||
I18n.t(UNCATEGORIZED_NAME_KEY)
|
||||
end
|
||||
|
||||
# Helper to get the localized name for other investments
|
||||
def other_investments_name
|
||||
I18n.t(OTHER_INVESTMENTS_NAME_KEY)
|
||||
end
|
||||
|
||||
private
|
||||
def default_categories
|
||||
[
|
||||
@@ -142,6 +165,21 @@ class Category < ApplicationRecord
|
||||
subcategory? ? "#{parent.name} > #{name}" : name
|
||||
end
|
||||
|
||||
# Predicate: is this the synthetic "Uncategorized" category?
|
||||
def uncategorized?
|
||||
!persisted? && name == I18n.t(UNCATEGORIZED_NAME_KEY)
|
||||
end
|
||||
|
||||
# Predicate: is this the synthetic "Other Investments" category?
|
||||
def other_investments?
|
||||
!persisted? && name == I18n.t(OTHER_INVESTMENTS_NAME_KEY)
|
||||
end
|
||||
|
||||
# Predicate: is this any synthetic (non-persisted) category?
|
||||
def synthetic?
|
||||
uncategorized? || other_investments?
|
||||
end
|
||||
|
||||
private
|
||||
def category_level_limit
|
||||
if (subcategory? && parent.subcategory?) || (parent? && subcategory?)
|
||||
|
||||
@@ -68,13 +68,22 @@ class IncomeStatement
|
||||
classification_total = totals.sum(&:total)
|
||||
|
||||
uncategorized_category = family.categories.uncategorized
|
||||
other_investments_category = family.categories.other_investments
|
||||
|
||||
category_totals = [ *categories, uncategorized_category ].map do |category|
|
||||
category_totals = [ *categories, uncategorized_category, other_investments_category ].map do |category|
|
||||
subcategory = categories.find { |c| c.id == category.parent_id }
|
||||
|
||||
parent_category_total = totals.select { |t| t.category_id == category.id }&.sum(&:total) || 0
|
||||
parent_category_total = if category.uncategorized?
|
||||
# Regular uncategorized: NULL category_id and NOT uncategorized investment
|
||||
totals.select { |t| t.category_id.nil? && !t.is_uncategorized_investment }&.sum(&:total) || 0
|
||||
elsif category.other_investments?
|
||||
# Other investments: NULL category_id AND is_uncategorized_investment
|
||||
totals.select { |t| t.category_id.nil? && t.is_uncategorized_investment }&.sum(&:total) || 0
|
||||
else
|
||||
totals.select { |t| t.category_id == category.id }&.sum(&:total) || 0
|
||||
end
|
||||
|
||||
children_totals = if category == uncategorized_category
|
||||
children_totals = if category.synthetic?
|
||||
0
|
||||
else
|
||||
totals.select { |t| t.parent_category_id == category.id }&.sum(&:total) || 0
|
||||
|
||||
@@ -15,13 +15,14 @@ class IncomeStatement::Totals
|
||||
category_id: row["category_id"],
|
||||
classification: row["classification"],
|
||||
total: row["total"],
|
||||
transactions_count: row["transactions_count"]
|
||||
transactions_count: row["transactions_count"],
|
||||
is_uncategorized_investment: row["is_uncategorized_investment"]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :transactions_count)
|
||||
TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :transactions_count, :is_uncategorized_investment)
|
||||
|
||||
def query_sql
|
||||
ActiveRecord::Base.sanitize_sql_array([
|
||||
@@ -37,6 +38,7 @@ class IncomeStatement::Totals
|
||||
category_id,
|
||||
parent_category_id,
|
||||
classification,
|
||||
is_uncategorized_investment,
|
||||
SUM(total) as total,
|
||||
SUM(entry_count) as transactions_count
|
||||
FROM (
|
||||
@@ -44,7 +46,7 @@ class IncomeStatement::Totals
|
||||
UNION ALL
|
||||
#{trades_subquery_sql}
|
||||
) combined
|
||||
GROUP BY category_id, parent_category_id, classification;
|
||||
GROUP BY category_id, parent_category_id, classification, is_uncategorized_investment;
|
||||
SQL
|
||||
end
|
||||
|
||||
@@ -56,7 +58,8 @@ class IncomeStatement::Totals
|
||||
c.parent_id as parent_category_id,
|
||||
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
|
||||
ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total,
|
||||
COUNT(ae.id) as transactions_count
|
||||
COUNT(ae.id) as transactions_count,
|
||||
false as is_uncategorized_investment
|
||||
FROM (#{@transactions_scope.to_sql}) at
|
||||
JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction'
|
||||
JOIN accounts a ON a.id = ae.account_id
|
||||
@@ -81,7 +84,8 @@ class IncomeStatement::Totals
|
||||
c.parent_id as parent_category_id,
|
||||
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
|
||||
ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total,
|
||||
COUNT(ae.id) as entry_count
|
||||
COUNT(ae.id) as entry_count,
|
||||
false as is_uncategorized_investment
|
||||
FROM (#{@transactions_scope.to_sql}) at
|
||||
JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction'
|
||||
JOIN accounts a ON a.id = ae.account_id
|
||||
@@ -101,14 +105,15 @@ class IncomeStatement::Totals
|
||||
|
||||
def trades_subquery_sql
|
||||
# Get trades for the same family and date range as transactions
|
||||
# Only include trades that have a category assigned
|
||||
# Trades without categories appear as "Uncategorized Investments" (separate from regular uncategorized)
|
||||
<<~SQL
|
||||
SELECT
|
||||
c.id as category_id,
|
||||
c.parent_id as parent_category_id,
|
||||
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
|
||||
ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total,
|
||||
COUNT(ae.id) as entry_count
|
||||
COUNT(ae.id) as entry_count,
|
||||
CASE WHEN t.category_id IS NULL THEN true ELSE false END as is_uncategorized_investment
|
||||
FROM trades t
|
||||
JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Trade'
|
||||
JOIN accounts a ON a.id = ae.account_id
|
||||
@@ -122,8 +127,7 @@ class IncomeStatement::Totals
|
||||
AND a.status IN ('draft', 'active')
|
||||
AND ae.excluded = false
|
||||
AND ae.date BETWEEN :start_date AND :end_date
|
||||
AND t.category_id IS NOT NULL
|
||||
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
|
||||
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END, CASE WHEN t.category_id IS NULL THEN true ELSE false END
|
||||
SQL
|
||||
end
|
||||
|
||||
|
||||
@@ -70,13 +70,9 @@
|
||||
</div>
|
||||
<div class="py-3 shadow-border-xs rounded-lg bg-container font-medium text-sm max-w-full">
|
||||
<% outflows_data[:categories].each_with_index do |category, idx| %>
|
||||
<%= link_to transactions_path(q: { categories: [category[:name]], start_date: period.date_range.first, end_date: period.date_range.last }),
|
||||
class: "flex items-center justify-between mx-3 p-3 rounded-lg cursor-pointer group gap-3",
|
||||
data: {
|
||||
turbo_frame: "_top",
|
||||
category_id: category[:id],
|
||||
action: "mouseenter->donut-chart#highlightSegment mouseleave->donut-chart#unhighlightSegment"
|
||||
} do %>
|
||||
<%
|
||||
category_content = capture do
|
||||
%>
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div class="h-6 w-6 flex-shrink-0 group-hover:scale-105 transition-all duration-300 rounded-full flex justify-center items-center"
|
||||
style="
|
||||
@@ -102,6 +98,24 @@
|
||||
<span class="text-sm text-secondary whitespace-nowrap w-10 lg:w-15"><%= category[:percentage] %>%</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if category[:clickable] != false %>
|
||||
<%= link_to transactions_path(q: { categories: [category[:name]], start_date: period.date_range.first, end_date: period.date_range.last }),
|
||||
class: "flex items-center justify-between mx-3 p-3 rounded-lg cursor-pointer group gap-3",
|
||||
data: {
|
||||
turbo_frame: "_top",
|
||||
category_id: category[:id],
|
||||
action: "mouseenter->donut-chart#highlightSegment mouseleave->donut-chart#unhighlightSegment"
|
||||
} do %>
|
||||
<%= category_content %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="flex items-center justify-between mx-3 p-3 rounded-lg group gap-3"
|
||||
data-category-id="<%= category[:id] %>"
|
||||
data-action="mouseenter->donut-chart#highlightSegment mouseleave->donut-chart#unhighlightSegment">
|
||||
<%= category_content %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if idx < outflows_data[:categories].size - 1 %>
|
||||
<%= render "shared/ruler", classes: "mx-3 lg:mx-4" %>
|
||||
<% end %>
|
||||
|
||||
73
app/views/reports/_net_worth.html.erb
Normal file
73
app/views/reports/_net_worth.html.erb
Normal file
@@ -0,0 +1,73 @@
|
||||
<div class="space-y-6">
|
||||
<%# Main Net Worth Stats %>
|
||||
<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" %>">
|
||||
<%= 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>
|
||||
<% if net_worth_metrics[:trend] %>
|
||||
<% trend = net_worth_metrics[:trend] %>
|
||||
<p class="text-xl font-semibold" 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>
|
||||
<% 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>
|
||||
<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>
|
||||
<span class="text-lg font-semibold text-destructive"><%= net_worth_metrics[:total_liabilities].format %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# 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>
|
||||
<% end %>
|
||||
<% if net_worth_metrics[:asset_groups].empty? %>
|
||||
<p class="text-sm text-tertiary py-2"><%= 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>
|
||||
<% end %>
|
||||
<% if net_worth_metrics[:liability_groups].empty? %>
|
||||
<p class="text-sm text-tertiary py-2"><%= t("reports.net_worth.no_liabilities") %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,7 +85,7 @@
|
||||
<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">(<%= group[:count] %> <%= t("reports.transactions_breakdown.table.transactions") %>)</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">
|
||||
@@ -139,7 +139,7 @@
|
||||
<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">(<%= group[:count] %> <%= t("reports.transactions_breakdown.table.transactions") %>)</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">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<%# Month-over-Month Trends %>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-secondary mb-4">
|
||||
@@ -78,121 +78,5 @@
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%# Spending Patterns %>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-secondary mb-4">
|
||||
<%= t("reports.trends.spending_patterns") %>
|
||||
</h3>
|
||||
|
||||
<% if spending_patterns[:weekday_count] + spending_patterns[:weekend_count] > 0 %>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<%# Weekday Spending %>
|
||||
<div class="p-6 bg-surface-inset rounded-lg">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<%= icon("calendar", class: "w-5 h-5 text-primary") %>
|
||||
<h4 class="font-medium text-primary"><%= t("reports.trends.weekday_spending") %></h4>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<p class="text-xs text-tertiary mb-1"><%= t("reports.trends.total") %></p>
|
||||
<p class="text-xl font-semibold text-primary">
|
||||
<%= Money.new(spending_patterns[:weekday_total], Current.family.currency).format %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-xs text-tertiary mb-1"><%= t("reports.trends.avg_per_transaction") %></p>
|
||||
<p class="text-base font-medium text-secondary">
|
||||
<%= Money.new(spending_patterns[:weekday_avg], Current.family.currency).format %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-xs text-tertiary mb-1"><%= t("reports.trends.transactions") %></p>
|
||||
<p class="text-base font-medium text-secondary">
|
||||
<%= spending_patterns[:weekday_count] %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# Weekend Spending %>
|
||||
<div class="p-6 bg-surface-inset rounded-lg">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<%= icon("calendar-check", class: "w-5 h-5 text-primary") %>
|
||||
<h4 class="font-medium text-primary"><%= t("reports.trends.weekend_spending") %></h4>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<p class="text-xs text-tertiary mb-1"><%= t("reports.trends.total") %></p>
|
||||
<p class="text-xl font-semibold text-primary">
|
||||
<%= Money.new(spending_patterns[:weekend_total], Current.family.currency).format %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-xs text-tertiary mb-1"><%= t("reports.trends.avg_per_transaction") %></p>
|
||||
<p class="text-base font-medium text-secondary">
|
||||
<%= Money.new(spending_patterns[:weekend_avg], Current.family.currency).format %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-xs text-tertiary mb-1"><%= t("reports.trends.transactions") %></p>
|
||||
<p class="text-base font-medium text-secondary">
|
||||
<%= spending_patterns[:weekend_count] %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# Comparison Insight %>
|
||||
<% if spending_patterns[:weekday_avg] > 0 && spending_patterns[:weekend_avg] > 0 %>
|
||||
<div class="mt-4 p-4 bg-container rounded-lg border border-tertiary">
|
||||
<div class="flex items-start gap-3">
|
||||
<%= icon("lightbulb", class: "w-5 h-5 text-warning mt-0.5") %>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-primary mb-1">
|
||||
<%= t("reports.trends.insight_title") %>
|
||||
</p>
|
||||
<p class="text-sm text-secondary">
|
||||
<%
|
||||
weekday = spending_patterns[:weekday_avg].to_f
|
||||
weekend = spending_patterns[:weekend_avg].to_f
|
||||
|
||||
if weekend > weekday
|
||||
percent_diff = ((weekend - weekday) / weekday * 100).round(0)
|
||||
if percent_diff > 20
|
||||
message = t("reports.trends.insight_higher_weekend", percent: percent_diff)
|
||||
else
|
||||
message = t("reports.trends.insight_similar")
|
||||
end
|
||||
elsif weekday > weekend
|
||||
percent_diff = ((weekday - weekend) / weekend * 100).round(0)
|
||||
if percent_diff > 20
|
||||
message = t("reports.trends.insight_higher_weekday", percent: percent_diff)
|
||||
else
|
||||
message = t("reports.trends.insight_similar")
|
||||
end
|
||||
else
|
||||
message = t("reports.trends.insight_similar")
|
||||
end
|
||||
%>
|
||||
<%= message %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="text-center py-8 text-tertiary">
|
||||
<%= t("reports.trends.no_spending_data") %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
6
config/locales/models/category/en.yml
Normal file
6
config/locales/models/category/en.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
en:
|
||||
models:
|
||||
category:
|
||||
uncategorized: Uncategorized
|
||||
other_investments: Other Investments
|
||||
@@ -69,8 +69,8 @@ en:
|
||||
add_transaction: Add Transaction
|
||||
add_account: Add Account
|
||||
transactions_breakdown:
|
||||
title: Transactions Breakdown
|
||||
no_transactions: No transactions found for the selected period and filters
|
||||
title: Activity Breakdown
|
||||
no_transactions: No activity found for the selected period and filters
|
||||
filters:
|
||||
title: Filters
|
||||
category: Category
|
||||
@@ -102,12 +102,25 @@ en:
|
||||
expense: Expenses
|
||||
income: Income
|
||||
uncategorized: Uncategorized
|
||||
transactions: transactions
|
||||
entries:
|
||||
one: entry
|
||||
other: entries
|
||||
percentage: "% of Total"
|
||||
pagination:
|
||||
showing: Showing %{count} transactions
|
||||
showing:
|
||||
one: Showing %{count} entry
|
||||
other: Showing %{count} entries
|
||||
previous: Previous
|
||||
next: Next
|
||||
net_worth:
|
||||
title: Net Worth
|
||||
current_net_worth: Current Net Worth
|
||||
period_change: Period Change
|
||||
assets_vs_liabilities: Assets vs Liabilities
|
||||
total_assets: Assets
|
||||
total_liabilities: Liabilities
|
||||
no_assets: No assets
|
||||
no_liabilities: No liabilities
|
||||
investment_performance:
|
||||
title: Investment Performance
|
||||
portfolio_value: Portfolio Value
|
||||
|
||||
Reference in New Issue
Block a user