Implement Reporting tab (#276)

* First reporting version

* Fixes for all tabs

* Transactions table

* Budget section re-design

* FIX exports

Fix transactions table aggregation

* Add support for google sheets

Remove pdf and xlsx for now

* Multiple fixes

- Trends & Insights now follows top filter
- Transactions Breakdown removed filters, implemented sort by amount.
- The entire section follows top filters.
- Export to CSV adds per month breakdown

* Linter and tests

* Fix amounts

- Correctly handle amounts across the views and controller.
- Pass proper values to do calculation on, and not loose precision

* Update Gemfile.lock

* Add support for api-key on reports

Also fix custom date filter

* Review fixes

* Move budget status calculations out of the view.

* fix ensures that quarterly reports end at the quarter boundary

* Fix bugdet days remaining

Fix raw css style

* Fix test

* Implement google sheets properly with hotwire

* Improve UX on period comparison

* FIX csv export for non API key auth
This commit is contained in:
soky srm
2025-11-05 14:54:45 +01:00
committed by GitHub
parent 9b6ec259bd
commit d9f8d064af
17 changed files with 2398 additions and 52 deletions

View File

@@ -0,0 +1,809 @@
class ReportsController < ApplicationController
include Periodable
# Allow API key authentication for exports (for Google Sheets integration)
# Note: We run authentication_for_export which handles both session and API key auth
skip_authentication only: :export_transactions
before_action :authenticate_for_export, only: :export_transactions
def index
@period_type = params[:period_type]&.to_sym || :monthly
@start_date = parse_date_param(:start_date) || default_start_date
@end_date = parse_date_param(:end_date) || default_end_date
# Build the period
@period = Period.custom(start_date: @start_date, end_date: @end_date)
@previous_period = build_previous_period
# Get aggregated data
@current_income_totals = Current.family.income_statement.income_totals(period: @period)
@current_expense_totals = Current.family.income_statement.expense_totals(period: @period)
@previous_income_totals = Current.family.income_statement.income_totals(period: @previous_period)
@previous_expense_totals = Current.family.income_statement.expense_totals(period: @previous_period)
# Calculate summary metrics
@summary_metrics = build_summary_metrics
# Build comparison data
@comparison_data = build_comparison_data
# Build trend data (last 6 months)
@trends_data = build_trends_data
# Spending patterns (weekday vs weekend)
@spending_patterns = build_spending_patterns
# Transactions breakdown
@transactions = build_transactions_breakdown
@breadcrumbs = [ [ "Home", root_path ], [ "Reports", nil ] ]
end
def export_transactions
@period_type = params[:period_type]&.to_sym || :monthly
@start_date = parse_date_param(:start_date) || default_start_date
@end_date = parse_date_param(:end_date) || default_end_date
@period = Period.custom(start_date: @start_date, end_date: @end_date)
# Build monthly breakdown data for export
@export_data = build_monthly_breakdown_for_export
respond_to do |format|
format.csv do
csv_data = generate_transactions_csv
send_data csv_data,
filename: "transactions_breakdown_#{@start_date.strftime('%Y%m%d')}_to_#{@end_date.strftime('%Y%m%d')}.csv",
type: "text/csv"
end
# Excel and PDF exports require additional gems (caxlsx and prawn)
# Uncomment and install gems if needed:
#
# format.xlsx do
# xlsx_data = generate_transactions_xlsx
# send_data xlsx_data,
# filename: "transactions_breakdown_#{@start_date.strftime('%Y%m%d')}_to_#{@end_date.strftime('%Y%m%d')}.xlsx",
# type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
# end
#
# format.pdf do
# pdf_data = generate_transactions_pdf
# send_data pdf_data,
# filename: "transactions_breakdown_#{@start_date.strftime('%Y%m%d')}_to_#{@end_date.strftime('%Y%m%d')}.pdf",
# type: "application/pdf"
# end
end
end
def google_sheets_instructions
# Re-build the params needed for the export URL
base_params = {
period_type: params[:period_type],
start_date: params[:start_date],
end_date: params[:end_date],
sort_by: params[:sort_by],
sort_direction: params[:sort_direction]
}.compact
# Build the full URL with the API key, if present
@csv_url = export_transactions_reports_url(base_params.merge(format: :csv))
@api_key_present = @csv_url.include?("api_key=")
# This action will render `app/views/reports/google_sheets_instructions.html.erb`
# It will render *inside* the modal frame.
end
private
def ensure_money(value)
return value if value.is_a?(Money)
# Value is numeric (BigDecimal or Integer) in dollars - pass directly to Money.new
Money.new(value, Current.family.currency)
end
def parse_date_param(param_name)
date_string = params[param_name]
return nil if date_string.blank?
Date.parse(date_string)
rescue Date::Error
nil
end
def default_start_date
case @period_type
when :monthly
Date.current.beginning_of_month.to_date
when :quarterly
Date.current.beginning_of_quarter.to_date
when :ytd
Date.current.beginning_of_year.to_date
when :last_6_months
6.months.ago.beginning_of_month.to_date
when :custom
1.month.ago.to_date
else
Date.current.beginning_of_month.to_date
end
end
def default_end_date
case @period_type
when :monthly, :last_6_months
Date.current.end_of_month.to_date
when :quarterly
Date.current.end_of_quarter.to_date
when :ytd
Date.current
when :custom
Date.current
else
Date.current.end_of_month.to_date
end
end
def build_previous_period
duration = (@end_date - @start_date).to_i
previous_end = @start_date - 1.day
previous_start = previous_end - duration.days
Period.custom(start_date: previous_start, end_date: previous_end)
end
def build_summary_metrics
# Ensure we always have Money objects
current_income = ensure_money(@current_income_totals.total)
current_expenses = ensure_money(@current_expense_totals.total)
net_savings = current_income - current_expenses
previous_income = ensure_money(@previous_income_totals.total)
previous_expenses = ensure_money(@previous_expense_totals.total)
# Calculate percentage changes
income_change = calculate_percentage_change(previous_income, current_income)
expense_change = calculate_percentage_change(previous_expenses, current_expenses)
# Get budget performance for current period
budget_percent = calculate_budget_performance
{
current_income: current_income,
income_change: income_change,
current_expenses: current_expenses,
expense_change: expense_change,
net_savings: net_savings,
budget_percent: budget_percent
}
end
def calculate_percentage_change(previous_value, current_value)
return 0 if previous_value.zero?
((current_value - previous_value) / previous_value * 100).round(1)
end
def calculate_budget_performance
# Only calculate if we're looking at current month
return nil unless @period_type == :monthly && @start_date.beginning_of_month.to_date == Date.current.beginning_of_month.to_date
budget = Budget.find_or_bootstrap(Current.family, start_date: @start_date.beginning_of_month.to_date)
return 0 if budget.nil? || budget.allocated_spending.zero?
(budget.actual_spending / budget.allocated_spending * 100).round(1)
rescue StandardError
nil
end
def build_comparison_data
currency_symbol = Money::Currency.new(Current.family.currency).symbol
# Totals are BigDecimal amounts in dollars - pass directly to Money.new()
{
current: {
income: @current_income_totals.total,
expenses: @current_expense_totals.total,
net: @current_income_totals.total - @current_expense_totals.total
},
previous: {
income: @previous_income_totals.total,
expenses: @previous_expense_totals.total,
net: @previous_income_totals.total - @previous_expense_totals.total
},
currency_symbol: currency_symbol
}
end
def build_trends_data
# Generate month-by-month data based on the current period filter
trends = []
# Generate list of months within the period
current_month = @start_date.beginning_of_month
end_of_period = @end_date.end_of_month
while current_month <= end_of_period
month_start = current_month
month_end = current_month.end_of_month
# Ensure we don't go beyond the end date
month_end = @end_date if month_end > @end_date
period = Period.custom(start_date: month_start, end_date: month_end)
income = Current.family.income_statement.income_totals(period: period).total
expenses = Current.family.income_statement.expense_totals(period: period).total
trends << {
month: month_start.strftime("%b %Y"),
income: income,
expenses: expenses,
net: income - expenses
}
current_month = current_month.next_month
end
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
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 })
.includes(entry: :account, category: [])
# Apply filters
transactions = apply_transaction_filters(transactions)
# 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 = {}
all_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"
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
end
# Convert to array
result = grouped_data.map do |key, data|
{
category_name: key[0],
type: key[1],
category_color: key[2],
total: data[:total],
count: data[:count]
}
end
# Sort by amount (total) with the specified direction
if sort_direction == "asc"
result.sort_by { |g| g[:total] }
else
result.sort_by { |g| -g[:total] }
end
end
def apply_transaction_filters(transactions)
# Filter by category
if params[:filter_category_id].present?
transactions = transactions.where(category_id: params[:filter_category_id])
end
# Filter by account
if params[:filter_account_id].present?
transactions = transactions.where(entries: { account_id: params[:filter_account_id] })
end
# Filter by tag
if params[:filter_tag_id].present?
transactions = transactions.joins(:taggings).where(taggings: { tag_id: params[:filter_tag_id] })
end
# Filter by amount range
if params[:filter_amount_min].present?
transactions = transactions.where("ABS(entries.amount) >= ?", params[:filter_amount_min].to_f)
end
if params[:filter_amount_max].present?
transactions = transactions.where("ABS(entries.amount) <= ?", params[:filter_amount_max].to_f)
end
# Filter by date range (within the period)
if params[:filter_date_start].present?
filter_start = Date.parse(params[:filter_date_start])
transactions = transactions.where("entries.date >= ?", filter_start) if filter_start >= @start_date
end
if params[:filter_date_end].present?
filter_end = Date.parse(params[:filter_date_end])
transactions = transactions.where("entries.date <= ?", filter_end) if filter_end <= @end_date
end
transactions
rescue Date::Error
transactions
end
def build_transactions_breakdown_for_export
# Get flat transactions list (not grouped) for export
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 })
.includes(entry: :account, category: [])
transactions = apply_transaction_filters(transactions)
sort_by = params[:sort_by] || "date"
# Whitelist sort_direction to prevent SQL injection
sort_direction = %w[asc desc].include?(params[:sort_direction]&.downcase) ? params[:sort_direction].upcase : "DESC"
case sort_by
when "date"
transactions.order("entries.date #{sort_direction}")
when "amount"
transactions.order("entries.amount #{sort_direction}")
else
transactions.order("entries.date DESC")
end
end
def build_monthly_breakdown_for_export
# Generate list of months in the period
months = []
current_month = @start_date.beginning_of_month
end_of_period = @end_date.end_of_month
while current_month <= end_of_period
months << current_month
current_month = current_month.next_month
end
# Get all transactions in the period
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 })
.includes(entry: :account, category: [])
transactions = apply_transaction_filters(transactions)
# Group transactions by category, type, and month
breakdown = {}
transactions.each do |transaction|
entry = transaction.entry
is_expense = entry.amount > 0
type = is_expense ? "expense" : "income"
category_name = transaction.category&.name || "Uncategorized"
month_key = entry.date.beginning_of_month
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
end
# Convert to array and sort by type and total (descending)
result = breakdown.map do |key, data|
{
category: data[:category],
type: data[:type],
months: data[:months],
total: data[:total]
}
end
# Separate and sort income and expenses
income_data = result.select { |r| r[:type] == "income" }.sort_by { |r| -r[:total] }
expense_data = result.select { |r| r[:type] == "expense" }.sort_by { |r| -r[:total] }
{
months: months,
income: income_data,
expenses: expense_data
}
end
def generate_transactions_csv
require "csv"
CSV.generate do |csv|
# Build header row: Category + Month columns + Total
month_headers = @export_data[:months].map { |m| m.strftime("%b %Y") }
header_row = [ "Category" ] + month_headers + [ "Total" ]
csv << header_row
# Income section
if @export_data[:income].any?
csv << [ "INCOME" ] + Array.new(month_headers.length + 1, "")
@export_data[:income].each do |category_data|
row = [ category_data[:category] ]
# Add amounts for each month
@export_data[:months].each do |month|
amount = category_data[:months][month] || 0
row << Money.new(amount, Current.family.currency).format
end
# Add row total
row << Money.new(category_data[:total], Current.family.currency).format
csv << row
end
# Income totals row
totals_row = [ "TOTAL INCOME" ]
@export_data[:months].each do |month|
month_total = @export_data[:income].sum { |c| c[:months][month] || 0 }
totals_row << Money.new(month_total, Current.family.currency).format
end
grand_income_total = @export_data[:income].sum { |c| c[:total] }
totals_row << Money.new(grand_income_total, Current.family.currency).format
csv << totals_row
# Blank row
csv << []
end
# Expenses section
if @export_data[:expenses].any?
csv << [ "EXPENSES" ] + Array.new(month_headers.length + 1, "")
@export_data[:expenses].each do |category_data|
row = [ category_data[:category] ]
# Add amounts for each month
@export_data[:months].each do |month|
amount = category_data[:months][month] || 0
row << Money.new(amount, Current.family.currency).format
end
# Add row total
row << Money.new(category_data[:total], Current.family.currency).format
csv << row
end
# Expenses totals row
totals_row = [ "TOTAL EXPENSES" ]
@export_data[:months].each do |month|
month_total = @export_data[:expenses].sum { |c| c[:months][month] || 0 }
totals_row << Money.new(month_total, Current.family.currency).format
end
grand_expenses_total = @export_data[:expenses].sum { |c| c[:total] }
totals_row << Money.new(grand_expenses_total, Current.family.currency).format
csv << totals_row
end
end
end
def generate_transactions_xlsx
require "caxlsx"
package = Axlsx::Package.new
workbook = package.workbook
bold_style = workbook.styles.add_style(b: true)
workbook.add_worksheet(name: "Breakdown") do |sheet|
# Build header row: Category + Month columns + Total
month_headers = @export_data[:months].map { |m| m.strftime("%b %Y") }
header_row = [ "Category" ] + month_headers + [ "Total" ]
sheet.add_row header_row, style: bold_style
# Income section
if @export_data[:income].any?
sheet.add_row [ "INCOME" ] + Array.new(month_headers.length + 1, ""), style: bold_style
@export_data[:income].each do |category_data|
row = [ category_data[:category] ]
# Add amounts for each month
@export_data[:months].each do |month|
amount = category_data[:months][month] || 0
row << Money.new(amount, Current.family.currency).format
end
# Add row total
row << Money.new(category_data[:total], Current.family.currency).format
sheet.add_row row
end
# Income totals row
totals_row = [ "TOTAL INCOME" ]
@export_data[:months].each do |month|
month_total = @export_data[:income].sum { |c| c[:months][month] || 0 }
totals_row << Money.new(month_total, Current.family.currency).format
end
grand_income_total = @export_data[:income].sum { |c| c[:total] }
totals_row << Money.new(grand_income_total, Current.family.currency).format
sheet.add_row totals_row, style: bold_style
# Blank row
sheet.add_row []
end
# Expenses section
if @export_data[:expenses].any?
sheet.add_row [ "EXPENSES" ] + Array.new(month_headers.length + 1, ""), style: bold_style
@export_data[:expenses].each do |category_data|
row = [ category_data[:category] ]
# Add amounts for each month
@export_data[:months].each do |month|
amount = category_data[:months][month] || 0
row << Money.new(amount, Current.family.currency).format
end
# Add row total
row << Money.new(category_data[:total], Current.family.currency).format
sheet.add_row row
end
# Expenses totals row
totals_row = [ "TOTAL EXPENSES" ]
@export_data[:months].each do |month|
month_total = @export_data[:expenses].sum { |c| c[:months][month] || 0 }
totals_row << Money.new(month_total, Current.family.currency).format
end
grand_expenses_total = @export_data[:expenses].sum { |c| c[:total] }
totals_row << Money.new(grand_expenses_total, Current.family.currency).format
sheet.add_row totals_row, style: bold_style
end
end
package.to_stream.read
end
def generate_transactions_pdf
require "prawn"
Prawn::Document.new(page_layout: :landscape) do |pdf|
pdf.text "Transaction Breakdown Report", size: 20, style: :bold
pdf.text "Period: #{@start_date.strftime('%b %-d, %Y')} to #{@end_date.strftime('%b %-d, %Y')}", size: 12
pdf.move_down 20
if @export_data[:income].any? || @export_data[:expenses].any?
# Build header row
month_headers = @export_data[:months].map { |m| m.strftime("%b %Y") }
header_row = [ "Category" ] + month_headers + [ "Total" ]
# Income section
if @export_data[:income].any?
pdf.text "INCOME", size: 14, style: :bold
pdf.move_down 10
income_table_data = [ header_row ]
@export_data[:income].each do |category_data|
row = [ category_data[:category] ]
@export_data[:months].each do |month|
amount = category_data[:months][month] || 0
row << Money.new(amount, Current.family.currency).format
end
row << Money.new(category_data[:total], Current.family.currency).format
income_table_data << row
end
# Income totals row
totals_row = [ "TOTAL INCOME" ]
@export_data[:months].each do |month|
month_total = @export_data[:income].sum { |c| c[:months][month] || 0 }
totals_row << Money.new(month_total, Current.family.currency).format
end
grand_income_total = @export_data[:income].sum { |c| c[:total] }
totals_row << Money.new(grand_income_total, Current.family.currency).format
income_table_data << totals_row
pdf.table(income_table_data, header: true, width: pdf.bounds.width, cell_style: { size: 8 }) do
row(0).font_style = :bold
row(0).background_color = "CCFFCC"
row(-1).font_style = :bold
row(-1).background_color = "99FF99"
columns(0).align = :left
columns(1..-1).align = :right
self.row_colors = [ "FFFFFF", "F9F9F9" ]
end
pdf.move_down 20
end
# Expenses section
if @export_data[:expenses].any?
pdf.text "EXPENSES", size: 14, style: :bold
pdf.move_down 10
expenses_table_data = [ header_row ]
@export_data[:expenses].each do |category_data|
row = [ category_data[:category] ]
@export_data[:months].each do |month|
amount = category_data[:months][month] || 0
row << Money.new(amount, Current.family.currency).format
end
row << Money.new(category_data[:total], Current.family.currency).format
expenses_table_data << row
end
# Expenses totals row
totals_row = [ "TOTAL EXPENSES" ]
@export_data[:months].each do |month|
month_total = @export_data[:expenses].sum { |c| c[:months][month] || 0 }
totals_row << Money.new(month_total, Current.family.currency).format
end
grand_expenses_total = @export_data[:expenses].sum { |c| c[:total] }
totals_row << Money.new(grand_expenses_total, Current.family.currency).format
expenses_table_data << totals_row
pdf.table(expenses_table_data, header: true, width: pdf.bounds.width, cell_style: { size: 8 }) do
row(0).font_style = :bold
row(0).background_color = "FFCCCC"
row(-1).font_style = :bold
row(-1).background_color = "FF9999"
columns(0).align = :left
columns(1..-1).align = :right
self.row_colors = [ "FFFFFF", "F9F9F9" ]
end
end
else
pdf.text "No transactions found for this period.", size: 12
end
end.render
end
# Export Authentication - handles both session and API key auth
def authenticate_for_export
if api_key_present?
# Use API key authentication
authenticate_with_api_key
else
# Use normal session authentication
authenticate_user!
end
end
# API Key Authentication Methods
def api_key_present?
params[:api_key].present? || request.headers["X-Api-Key"].present?
end
def authenticate_with_api_key
api_key_value = params[:api_key] || request.headers["X-Api-Key"]
unless api_key_value
render plain: "API key is required", status: :unauthorized
return false
end
@api_key = ApiKey.find_by_value(api_key_value)
unless @api_key && @api_key.active?
render plain: "Invalid or expired API key", status: :unauthorized
return false
end
# Check if API key has read permissions
unless @api_key.scopes&.include?("read") || @api_key.scopes&.include?("read_write")
render plain: "API key does not have read permission", status: :forbidden
return false
end
# Set up the current user and session context
@current_user = @api_key.user
@api_key.update_last_used!
# Set up Current context for API requests (similar to Api::V1::BaseController)
# Return false if setup fails to halt the filter chain
return false unless setup_current_context_for_api_key
true
end
def setup_current_context_for_api_key
unless @current_user
render plain: "User not found for API key", status: :internal_server_error
return false
end
# Find or create a session for this API request
# We need to find or create a persisted session so that Current.user delegation works properly
session = @current_user.sessions.first_or_create!(
user_agent: request.user_agent,
ip_address: request.ip
)
Current.session = session
# Verify the delegation chain works
unless Current.user
render plain: "Failed to establish user context", status: :internal_server_error
return false
end
# Ensure we have a valid family context
unless Current.family
render plain: "User does not have an associated family", status: :internal_server_error
return false
end
true
end
end

View File

@@ -75,6 +75,34 @@ class BudgetCategory < ApplicationRecord
(actual_spending / budgeted_spending) * 100
end
def bar_width_percent
[ percent_of_budget_spent, 100 ].min
end
def over_budget?
available_to_spend.negative?
end
def near_limit?
!over_budget? && percent_of_budget_spent >= 90
end
# Returns hash with suggested daily spending info or nil if not applicable
def suggested_daily_spending
return nil unless available_to_spend > 0
budget_date = budget.start_date
return nil unless budget_date.month == Date.current.month && budget_date.year == Date.current.year
days_remaining = (budget_date.end_of_month - Date.current).to_i + 1
return nil unless days_remaining > 0
{
amount: Money.new((available_to_spend / days_remaining), budget.family.currency),
days_remaining: days_remaining
}
end
def to_donut_segments_json
unused_segment_id = "unused"
overage_segment_id = "overage"

View File

@@ -1,54 +1,118 @@
<%# locals: (budget_category:) %>
<%= turbo_frame_tag dom_id(budget_category), class: "w-full" do %>
<%= link_to budget_budget_category_path(budget_category.budget, budget_category), class: "group w-full p-4 flex items-center gap-3 bg-container", data: { turbo_frame: "drawer" } do %>
<%= turbo_frame_tag dom_id(budget_category), class: "w-full block" do %>
<%= link_to budget_budget_category_path(budget_category.budget, budget_category), class: "group block w-full p-4 bg-container hover:bg-surface-inset transition-colors", data: { turbo_frame: "drawer" } do %>
<% if budget_category.initialized? %>
<div class="w-10 h-10 group-hover:scale-105 transition-all duration-300">
<%= render "budget_categories/budget_category_donut", budget_category: budget_category %>
<%# Category Header with Status Badge %>
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
<div class="flex items-center gap-2 min-w-0">
<div class="w-3 h-3 rounded-full flex-shrink-0" style="background-color: <%= budget_category.category.color %>"></div>
<h3 class="font-medium text-primary truncate"><%= budget_category.category.name %></h3>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<% if budget_category.over_budget? %>
<span class="inline-flex items-center gap-1 px-2 py-1 bg-danger/10 text-danger text-xs font-medium rounded-full whitespace-nowrap">
<%= icon("alert-circle", class: "w-3 h-3") %>
<%= t("reports.budget_performance.status.over") %>
</span>
<% elsif budget_category.near_limit? %>
<span class="inline-flex items-center gap-1 px-2 py-1 bg-warning/10 text-warning text-xs font-medium rounded-full whitespace-nowrap">
<%= icon("alert-triangle", class: "w-3 h-3") %>
<%= t("reports.budget_performance.status.warning") %>
</span>
<% else %>
<span class="inline-flex items-center gap-1 px-2 py-1 bg-success/10 text-success text-xs font-medium rounded-full whitespace-nowrap">
<%= icon("check-circle", class: "w-3 h-3") %>
<%= t("reports.budget_performance.status.good") %>
</span>
<% end %>
<span class="text-sm font-semibold text-primary whitespace-nowrap">
<%= budget_category.percent_of_budget_spent.round(0) %>%
</span>
</div>
</div>
<%# Progress Bar %>
<div class="mb-3">
<div class="h-3 bg-container-inset rounded-full overflow-hidden">
<% bar_color = budget_category.over_budget? ? "bg-danger" : (budget_category.near_limit? ? "bg-warning" : "bg-success") %>
<div class="h-full <%= bar_color %> rounded-full transition-all duration-500"
style="width: <%= budget_category.bar_width_percent %>%"></div>
</div>
</div>
<%# Budget Details %>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
<div class="whitespace-nowrap">
<span class="text-tertiary"><%= t("reports.budget_performance.spent") %>:</span>
<span class="font-medium text-primary">
<%= format_money(budget_category.actual_spending_money) %>
</span>
</div>
<div class="whitespace-nowrap">
<span class="text-tertiary"><%= t("reports.budget_performance.budgeted") %>:</span>
<span class="font-medium text-secondary">
<%= format_money(budget_category.budgeted_spending_money) %>
</span>
</div>
<div class="whitespace-nowrap ml-auto">
<% if budget_category.available_to_spend >= 0 %>
<span class="text-tertiary"><%= t("reports.budget_performance.remaining") %>:</span>
<span class="font-medium text-success">
<%= format_money(budget_category.available_to_spend_money) %>
</span>
<% else %>
<span class="text-tertiary"><%= t("reports.budget_performance.over_by") %>:</span>
<span class="font-medium text-danger">
<%= format_money(budget_category.available_to_spend_money.abs) %>
</span>
<% end %>
</div>
</div>
<%# Suggested Daily Limit (if remaining days in month) %>
<% if budget_category.suggested_daily_spending.present? %>
<% daily_info = budget_category.suggested_daily_spending %>
<div class="mt-3 pt-3 border-t border-tertiary">
<p class="text-xs text-tertiary break-words">
<%= t("reports.budget_performance.suggested_daily",
amount: daily_info[:amount].format,
days: daily_info[:days_remaining]) %>
</p>
</div>
<% end %>
<% else %>
<div class="w-8 h-8 group-hover:scale-105 transition-all duration-300 rounded-full flex justify-center items-center" style="color: <%= budget_category.category.color %>">
<% if budget_category.category.lucide_icon %>
<%= icon(budget_category.category.lucide_icon, color: "current") %>
<% else %>
<%= render DS::FilledIcon.new(
variant: :text,
hex_color: budget_category.category.color,
text: budget_category.category.name,
size: "sm",
rounded: true
) %>
<% end %>
<%# Uninitialized budget - show simple view %>
<div class="flex items-center gap-3">
<div class="w-8 h-8 flex-shrink-0 group-hover:scale-105 transition-all duration-300 rounded-full flex justify-center items-center" style="color: <%= budget_category.category.color %>">
<% if budget_category.category.lucide_icon %>
<%= icon(budget_category.category.lucide_icon, color: "current") %>
<% else %>
<%= render DS::FilledIcon.new(
variant: :text,
hex_color: budget_category.category.color,
text: budget_category.category.name,
size: "sm",
rounded: true
) %>
<% end %>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-primary truncate"><%= budget_category.category.name %></p>
<p class="text-sm text-secondary font-medium">
<%= budget_category.median_monthly_expense_money.format %> avg
</p>
</div>
<div class="ml-auto text-right flex-shrink-0">
<p class="text-sm font-medium text-primary whitespace-nowrap"><%= format_money(budget_category.actual_spending_money) %></p>
</div>
</div>
<% end %>
<div>
<p class="text-sm font-medium text-primary"><%= budget_category.category.name %></p>
<% if budget_category.initialized? %>
<% if budget_category.available_to_spend.negative? %>
<p class="text-sm font-medium text-red-500"><%= format_money(budget_category.available_to_spend_money.abs) %> over</p>
<% elsif budget_category.available_to_spend.zero? %>
<p class="text-sm font-medium <%= budget_category.budgeted_spending.positive? ? "text-orange-500" : "text-secondary" %>">
<%= format_money(budget_category.available_to_spend_money) %> left
</p>
<% else %>
<p class="text-sm text-secondary font-medium"><%= format_money(budget_category.available_to_spend_money) %> left</p>
<% end %>
<% else %>
<p class="text-sm text-secondary font-medium">
<%= budget_category.median_monthly_expense_money.format %> avg
</p>
<% end %>
</div>
<div class="ml-auto text-right">
<p class="text-sm font-medium text-primary"><%= format_money(budget_category.actual_spending_money) %></p>
<% if budget_category.initialized? %>
<p class="text-sm text-secondary">from <%= format_money(budget_category.budgeted_spending_money) %></p>
<% end %>
</div>
<% end %>
<% end %>

View File

@@ -5,8 +5,10 @@
next_budget: @next_budget,
latest_budget: @latest_budget %>
<div class="flex flex-col items-start gap-4 md:flex-row">
<div class="w-full md:max-w-[300px] space-y-4">
<div class="space-y-4">
<%# Top Section: Donut and Summary side by side %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<%# Budget Donut %>
<div class="h-[300px] bg-container rounded-xl shadow-border-xs p-8">
<% if @budget.available_to_allocate.negative? %>
<%= render "budgets/over_allocation_warning", budget: @budget %>
@@ -15,8 +17,8 @@
<% end %>
</div>
<div>
<%# Actuals Summary %>
<div class="bg-container rounded-xl shadow-border-xs">
<% if @budget.initialized? && @budget.available_to_allocate.positive? %>
<%= render DS::Tabs.new(active_tab: params[:tab].presence || "budgeted") do |tabs| %>
<% tabs.with_nav do |nav| %>
@@ -37,14 +39,13 @@
<% end %>
<% end %>
<% else %>
<div class="bg-container rounded-xl shadow-border-xs">
<%= render "budgets/actuals_summary", budget: @budget %>
</div>
<%= render "budgets/actuals_summary", budget: @budget %>
<% end %>
</div>
</div>
<div class="w-full grow bg-container rounded-xl shadow-border-xs p-4">
<%# Bottom Section: Categories full width %>
<div class="w-full bg-container rounded-xl shadow-border-xs p-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-medium">Categories</h2>

View File

@@ -1,6 +1,7 @@
<% mobile_nav_items = [
{ name: "Home", path: root_path, icon: "pie-chart", icon_custom: false, active: page_active?(root_path) },
{ name: "Transactions", path: transactions_path, icon: "credit-card", icon_custom: false, active: page_active?(transactions_path) },
{ name: "Reports", path: reports_path, icon: "chart-bar", icon_custom: false, active: page_active?(reports_path) },
{ name: "Budgets", path: budgets_path, icon: "map", icon_custom: false, active: page_active?(budgets_path) },
{ name: "Assistant", path: chats_path, icon: "icon-assistant", icon_custom: true, active: page_active?(chats_path), mobile_only: true }
] %>

View File

@@ -0,0 +1,117 @@
<div>
<div class="flex items-center justify-between mb-6">
<h2 class="text-lg font-medium text-primary">
<%= t("reports.budget_performance.title") %>
</h2>
<p class="text-sm text-tertiary">
<%= start_date.strftime("%B %Y") %>
</p>
</div>
<% if budget_data.any? %>
<div class="space-y-4">
<% budget_data.each do |budget_item| %>
<div class="p-4 bg-surface-inset rounded-lg">
<%# Category Header %>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full" style="background-color: <%= budget_item[:category_color] %>"></div>
<h3 class="font-medium text-primary"><%= budget_item[:category_name] %></h3>
</div>
<div class="flex items-center gap-3">
<% case budget_item[:status] %>
<% when :over %>
<span class="inline-flex items-center gap-1 px-2 py-1 bg-danger/10 text-danger text-xs font-medium rounded-full">
<%= icon("alert-circle", class: "w-3 h-3") %>
<%= t("reports.budget_performance.status.over") %>
</span>
<% when :warning %>
<span class="inline-flex items-center gap-1 px-2 py-1 bg-warning/10 text-warning text-xs font-medium rounded-full">
<%= icon("alert-triangle", class: "w-3 h-3") %>
<%= t("reports.budget_performance.status.warning") %>
</span>
<% when :good %>
<span class="inline-flex items-center gap-1 px-2 py-1 bg-success/10 text-success text-xs font-medium rounded-full">
<%= icon("check-circle", class: "w-3 h-3") %>
<%= t("reports.budget_performance.status.good") %>
</span>
<% end %>
<span class="text-sm font-semibold text-primary">
<%= budget_item[:percent_used].round(0) %>%
</span>
</div>
</div>
<%# Progress Bar %>
<div class="mb-3">
<div class="h-3 bg-container rounded-full overflow-hidden">
<% bar_width = [budget_item[:percent_used], 100].min %>
<% bar_color = case budget_item[:status]
when :over then "bg-danger"
when :warning then "bg-warning"
else "bg-success"
end %>
<div class="h-full <%= bar_color %> rounded-full transition-all duration-500"
style="width: <%= bar_width %>%"></div>
</div>
</div>
<%# Budget Details %>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-4">
<div>
<span class="text-tertiary"><%= t("reports.budget_performance.spent") %>:</span>
<span class="font-medium text-primary">
<%= Money.new(budget_item[:actual], Current.family.currency).format %>
</span>
</div>
<div>
<span class="text-tertiary"><%= t("reports.budget_performance.budgeted") %>:</span>
<span class="font-medium text-secondary">
<%= Money.new(budget_item[:budgeted], Current.family.currency).format %>
</span>
</div>
</div>
<div>
<% if budget_item[:remaining] >= 0 %>
<span class="text-tertiary"><%= t("reports.budget_performance.remaining") %>:</span>
<span class="font-medium text-success">
<%= Money.new(budget_item[:remaining], Current.family.currency).format %>
</span>
<% else %>
<span class="text-tertiary"><%= t("reports.budget_performance.over_by") %>:</span>
<span class="font-medium text-danger">
<%= Money.new(budget_item[:remaining].abs, Current.family.currency).format %>
</span>
<% end %>
</div>
</div>
<%# Suggested Daily Limit (if remaining days in month) %>
<% if budget_item[:remaining] > 0 && start_date.month == Date.current.month && start_date.year == Date.current.year %>
<% days_remaining = (start_date.end_of_month - Date.current).to_i + 1 %>
<% if days_remaining > 0 %>
<div class="mt-3 pt-3 border-t border-tertiary">
<p class="text-xs text-tertiary">
<%= t("reports.budget_performance.suggested_daily",
amount: Money.new((budget_item[:remaining] / days_remaining), Current.family.currency).format,
days: days_remaining) %>
</p>
</div>
<% end %>
<% end %>
</div>
<% end %>
</div>
<% else %>
<div class="text-center py-12">
<%= icon("gauge", class: "w-12 h-12 text-tertiary mx-auto mb-4") %>
<p class="text-tertiary">
<%= t("reports.budget_performance.no_budgets") %>
</p>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,250 @@
<%
currency = Current.family.currency
# Helper to calculate percentage change and determine if it's good or bad
def comparison_class(current, previous, inverse: false)
return "text-primary" if previous.zero?
change = current - previous
is_positive_change = change > 0
# For expenses, lower is better (inverse logic)
is_good = inverse ? !is_positive_change : is_positive_change
is_good ? "text-green-600" : "text-gray-600"
end
def percentage_change(current, previous)
return 0 if previous.zero?
((current - previous) / previous.abs * 100).round(1)
end
%>
<div>
<div class="flex items-center justify-between mb-6">
<h2 class="text-lg font-medium text-primary">
<%= t("reports.comparison.title") %>
</h2>
<p class="text-sm text-tertiary">
<%= t("reports.comparison.currency", symbol: comparison_data[:currency_symbol]) %>
</p>
</div>
<div class="space-y-6">
<%# Income Comparison %>
<div>
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-medium text-secondary flex items-center gap-2">
<%= icon("trending-up", class: "w-4 h-4 text-success") %>
<%= t("reports.comparison.income") %>
</h3>
</div>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-3">
<span class="text-2xl font-semibold <%= comparison_class(comparison_data[:current][:income], comparison_data[:previous][:income]) %>">
<%= Money.new(comparison_data[:current][:income], currency).format %>
</span>
<% change = percentage_change(comparison_data[:current][:income], comparison_data[:previous][:income]) %>
<% if change != 0 %>
<% income_improved = comparison_data[:current][:income] > comparison_data[:previous][:income] %>
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium <%= comparison_class(comparison_data[:current][:income], comparison_data[:previous][:income]) %>">
<%= change >= 0 ? "+" : "" %><%= change %>%
</span>
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium <%= income_improved ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600' %>">
<%= icon(income_improved ? "trending-up" : "trending-down", class: "w-3 h-3") %>
<%= t(income_improved ? "reports.comparison.status.improved" : "reports.comparison.status.decreased") %>
</span>
</div>
<% end %>
</div>
<span class="text-sm text-tertiary">
<%= t("reports.comparison.previous") %>: <%= Money.new(comparison_data[:previous][:income], currency).format %>
</span>
</div>
<%# Overlapping bars %>
<div class="relative h-10">
<%
current_income_abs = comparison_data[:current][:income].to_f.abs
previous_income_abs = comparison_data[:previous][:income].to_f.abs
max_income = [current_income_abs, previous_income_abs].max
if max_income > 0
current_width = [3, (current_income_abs / max_income * 100)].max
previous_width = [3, (previous_income_abs / max_income * 100)].max
else
current_width = 0
previous_width = 0
end
# Income: green if increased, gray/primary if decreased
income_increased = comparison_data[:current][:income] >= comparison_data[:previous][:income]
income_bar_color = income_increased ? "bg-green-500" : "bg-gray-600"
income_bg_color = income_increased ? "bg-green-200" : "bg-gray-300"
%>
<% if previous_width > 0 || current_width > 0 %>
<%# Previous period bar (background) %>
<% if previous_width > 0 %>
<div class="absolute top-0 left-0 h-10 <%= income_bg_color %> rounded-lg transition-all duration-500"
style="width: <%= previous_width %>%"></div>
<% end %>
<%# Current period bar (foreground) %>
<% if current_width > 0 %>
<div class="absolute top-2 left-0 h-6 <%= income_bar_color %> rounded-lg transition-all duration-500"
style="width: <%= current_width %>%"></div>
<% end %>
<% else %>
<div class="flex items-center justify-center h-10 text-sm text-tertiary">
<%= t("reports.comparison.no_data") %>
</div>
<% end %>
</div>
</div>
<%# Expenses Comparison %>
<div>
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-medium text-secondary flex items-center gap-2">
<%= icon("trending-down", class: "w-4 h-4 text-danger") %>
<%= t("reports.comparison.expenses") %>
</h3>
</div>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-3">
<span class="text-2xl font-semibold <%= comparison_class(comparison_data[:current][:expenses], comparison_data[:previous][:expenses], inverse: true) %>">
<%= Money.new(comparison_data[:current][:expenses], currency).format %>
</span>
<% change = percentage_change(comparison_data[:current][:expenses], comparison_data[:previous][:expenses]) %>
<% if change != 0 %>
<% expenses_improved = comparison_data[:current][:expenses] < comparison_data[:previous][:expenses] %>
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium <%= comparison_class(comparison_data[:current][:expenses], comparison_data[:previous][:expenses], inverse: true) %>">
<%= change >= 0 ? "+" : "" %><%= change %>%
</span>
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium <%= expenses_improved ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600' %>">
<%= icon(expenses_improved ? "trending-down" : "trending-up", class: "w-3 h-3") %>
<%= t(expenses_improved ? "reports.comparison.status.reduced" : "reports.comparison.status.increased") %>
</span>
</div>
<% end %>
</div>
<span class="text-sm text-tertiary">
<%= t("reports.comparison.previous") %>: <%= Money.new(comparison_data[:previous][:expenses], currency).format %>
</span>
</div>
<%# Overlapping bars %>
<div class="relative h-10">
<%
current_expenses_abs = comparison_data[:current][:expenses].to_f.abs
previous_expenses_abs = comparison_data[:previous][:expenses].to_f.abs
max_expenses = [current_expenses_abs, previous_expenses_abs].max
if max_expenses > 0
current_width = [3, (current_expenses_abs / max_expenses * 100)].max
previous_width = [3, (previous_expenses_abs / max_expenses * 100)].max
else
current_width = 0
previous_width = 0
end
# Expenses: green if decreased (inverse logic), gray/primary if increased
expenses_decreased = comparison_data[:current][:expenses] <= comparison_data[:previous][:expenses]
expenses_bar_color = expenses_decreased ? "bg-green-500" : "bg-gray-600"
expenses_bg_color = expenses_decreased ? "bg-green-200" : "bg-gray-300"
%>
<% if previous_width > 0 || current_width > 0 %>
<%# Previous period bar (background) %>
<% if previous_width > 0 %>
<div class="absolute top-0 left-0 h-10 <%= expenses_bg_color %> rounded-lg transition-all duration-500"
style="width: <%= previous_width %>%"></div>
<% end %>
<%# Current period bar (foreground) %>
<% if current_width > 0 %>
<div class="absolute top-2 left-0 h-6 <%= expenses_bar_color %> rounded-lg transition-all duration-500"
style="width: <%= current_width %>%"></div>
<% end %>
<% else %>
<div class="flex items-center justify-center h-10 text-sm text-tertiary">
<%= t("reports.comparison.no_data") %>
</div>
<% end %>
</div>
</div>
<%# Net Savings Comparison %>
<div>
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-medium text-secondary flex items-center gap-2">
<%= icon("piggy-bank", class: "w-4 h-4 text-primary") %>
<%= t("reports.comparison.net_savings") %>
</h3>
</div>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-3">
<span class="text-2xl font-semibold <%= comparison_class(comparison_data[:current][:net], comparison_data[:previous][:net]) %>">
<%= Money.new(comparison_data[:current][:net], currency).format %>
</span>
<% change = percentage_change(comparison_data[:current][:net], comparison_data[:previous][:net]) %>
<% if change != 0 %>
<% net_improved = comparison_data[:current][:net] > comparison_data[:previous][:net] %>
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium <%= comparison_class(comparison_data[:current][:net], comparison_data[:previous][:net]) %>">
<%= change >= 0 ? "+" : "" %><%= change %>%
</span>
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium <%= net_improved ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600' %>">
<%= icon(net_improved ? "trending-up" : "trending-down", class: "w-3 h-3") %>
<%= t(net_improved ? "reports.comparison.status.improved" : "reports.comparison.status.decreased") %>
</span>
</div>
<% end %>
</div>
<span class="text-sm text-tertiary">
<%= t("reports.comparison.previous") %>: <%= Money.new(comparison_data[:previous][:net], currency).format %>
</span>
</div>
<%# Overlapping bars %>
<div class="relative h-10">
<%
current_net_abs = comparison_data[:current][:net].to_f.abs
previous_net_abs = comparison_data[:previous][:net].to_f.abs
max_net = [current_net_abs, previous_net_abs].max
if max_net > 0
current_width = [3, (current_net_abs / max_net * 100)].max
previous_width = [3, (previous_net_abs / max_net * 100)].max
else
current_width = 0
previous_width = 0
end
# Net Savings: green if improved (increased), gray/primary if got worse
net_improved = comparison_data[:current][:net] >= comparison_data[:previous][:net]
net_bar_color = net_improved ? "bg-green-500" : "bg-gray-600"
net_bg_color = net_improved ? "bg-green-200" : "bg-gray-300"
%>
<% if previous_width > 0 || current_width > 0 %>
<%# Previous period bar (background) %>
<% if previous_width > 0 %>
<div class="absolute top-0 left-0 h-10 <%= net_bg_color %> rounded-lg transition-all duration-500"
style="width: <%= previous_width %>%"></div>
<% end %>
<%# Current period bar (foreground) %>
<% if current_width > 0 %>
<div class="absolute top-2 left-0 h-6 <%= net_bar_color %> rounded-lg transition-all duration-500"
style="width: <%= current_width %>%"></div>
<% end %>
<% else %>
<div class="flex items-center justify-center h-10 text-sm text-tertiary">
<%= t("reports.comparison.no_data") %>
</div>
<% end %>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,27 @@
<div class="bg-container rounded-xl shadow-border-xs p-12 text-center">
<%= icon("chart-bar", class: "w-16 h-16 text-tertiary mx-auto mb-6") %>
<h3 class="text-xl font-medium text-primary mb-3">
<%= t("reports.empty_state.title") %>
</h3>
<p class="text-base text-secondary mb-6 max-w-md mx-auto">
<%= t("reports.empty_state.description") %>
</p>
<div class="flex gap-3 justify-center">
<%= render DS::Link.new(
text: t("reports.empty_state.add_transaction"),
href: new_transaction_path,
variant: "primary",
frame: :modal
) %>
<%= render DS::Link.new(
text: t("reports.empty_state.add_account"),
href: new_account_path,
variant: "secondary",
frame: :modal
) %>
</div>
</div>

View File

@@ -0,0 +1,132 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<%# Total Income Card %>
<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") %>
<h3 class="text-sm font-medium text-secondary">
<%= t("reports.summary.total_income") %>
</h3>
</div>
</div>
<div class="space-y-2">
<p class="text-2xl font-semibold text-primary">
<%= metrics[:current_income].format %>
</p>
<% 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") %>
<span class="text-sm font-medium text-success">
+<%= metrics[:income_change] %>%
</span>
<% else %>
<%= icon("arrow-down", class: "w-4 h-4 text-danger") %>
<span class="text-sm font-medium text-danger">
<%= metrics[:income_change] %>%
</span>
<% end %>
<span class="text-sm text-tertiary">
<%= t("reports.summary.vs_previous") %>
</span>
</div>
<% end %>
</div>
</div>
<%# Total Expenses Card %>
<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-danger") %>
<h3 class="text-sm font-medium text-secondary">
<%= t("reports.summary.total_expenses") %>
</h3>
</div>
</div>
<div class="space-y-2">
<p class="text-2xl font-semibold text-primary">
<%= metrics[:current_expenses].format %>
</p>
<% if metrics[:expense_change] %>
<div class="flex items-center gap-1.5">
<% if metrics[:expense_change] >= 0 %>
<%= icon("arrow-up", class: "w-4 h-4 text-danger") %>
<span class="text-sm font-medium text-danger">
+<%= metrics[:expense_change] %>%
</span>
<% else %>
<%= icon("arrow-down", class: "w-4 h-4 text-success") %>
<span class="text-sm font-medium text-success">
<%= metrics[:expense_change] %>%
</span>
<% end %>
<span class="text-sm text-tertiary">
<%= t("reports.summary.vs_previous") %>
</span>
</div>
<% end %>
</div>
</div>
<%# Net Savings Card %>
<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") %>
<h3 class="text-sm font-medium text-secondary">
<%= t("reports.summary.net_savings") %>
</h3>
</div>
</div>
<div class="space-y-2">
<p class="text-2xl font-semibold <%= metrics[:net_savings] >= 0 ? "text-success" : "text-danger" %>">
<%= metrics[:net_savings].format %>
</p>
<p class="text-sm text-tertiary">
<%= t("reports.summary.income_minus_expenses") %>
</p>
</div>
</div>
<%# Budget Performance Card %>
<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") %>
<h3 class="text-sm font-medium text-secondary">
<%= t("reports.summary.budget_performance") %>
</h3>
</div>
</div>
<div class="space-y-2">
<% if metrics[:budget_percent] %>
<p class="text-2xl font-semibold text-primary">
<%= metrics[:budget_percent] %>%
</p>
<div class="space-y-1.5">
<div class="h-2 bg-surface-inset rounded-full overflow-hidden">
<div class="h-full <%= metrics[:budget_percent] >= 100 ? "bg-danger" : metrics[:budget_percent] >= 80 ? "bg-warning" : "bg-success" %> rounded-full transition-all"
style="width: <%= [metrics[:budget_percent], 100].min %>%"></div>
</div>
<p class="text-sm text-tertiary">
<%= t("reports.summary.of_budget_used") %>
</p>
</div>
<% else %>
<p class="text-base text-tertiary">
<%= t("reports.summary.no_budget_data") %>
</p>
<% end %>
</div>
</div>
</div>

View File

@@ -0,0 +1,180 @@
<div>
<%# Header %>
<div class="flex items-center justify-between mb-6">
<h2 class="text-lg font-medium text-primary">
<%= t("reports.transactions_breakdown.title") %>
</h2>
</div>
<%# Export Controls %>
<div class="flex items-center justify-end mb-4 flex-wrap gap-3">
<%
# Build params hash for links
base_params = {
period_type: period_type,
start_date: start_date,
end_date: end_date,
sort_by: params[:sort_by],
sort_direction: params[:sort_direction]
}.compact
%>
<%# 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 %>
<%= 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",
data: { turbo_frame: "modal" } do %>
<%= icon("external-link", class: "w-3 h-3") %>
<span><%= t("reports.transactions_breakdown.export.google_sheets") %></span>
<% end %>
</div>
</div>
<%# Transactions Tables - Split by Income and Expenses %>
<% if transactions.any? %>
<%
# Separate income and expenses
income_groups = transactions.select { |g| g[:type] == "income" }
expense_groups = transactions.select { |g| g[:type] == "expense" }
# Calculate totals
income_total = income_groups.sum { |g| g[:total] }
expense_total = expense_groups.sum { |g| g[:total] }
# Determine sort direction for Amount column
current_sort_by = params[:sort_by]
current_sort_direction = params[:sort_direction]
# Toggle sort direction: if currently sorting by amount desc, switch to asc; otherwise default to desc
next_sort_direction = (current_sort_by == "amount" && current_sort_direction == "desc") ? "asc" : "desc"
# Build params for amount sort link
amount_sort_params = base_params.merge(sort_by: "amount", sort_direction: next_sort_direction)
%>
<div class="space-y-8">
<%# Income Section %>
<% if income_groups.any? %>
<div>
<h3 class="text-base font-semibold text-success mb-4 flex items-center gap-2">
<%= icon("trending-up", class: "w-5 h-5") %>
<%= t("reports.transactions_breakdown.table.income") %>
<span class="text-sm font-normal text-tertiary">(<%= Money.new(income_total, Current.family.currency).format %>)</span>
</h3>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-tertiary">
<th class="text-left py-3 pr-4 font-medium text-secondary"><%= t("reports.transactions_breakdown.table.category") %></th>
<th class="text-right py-3 px-4 font-medium text-secondary">
<%= link_to reports_path(amount_sort_params), class: "inline-flex items-center gap-1 hover:text-primary" do %>
<%= t("reports.transactions_breakdown.table.amount") %>
<% if current_sort_by == "amount" %>
<%= icon(current_sort_direction == "desc" ? "chevron-down" : "chevron-up", class: "w-3 h-3") %>
<% end %>
<% end %>
</th>
<th class="text-right py-3 pl-4 font-medium text-secondary"><%= t("reports.transactions_breakdown.table.percentage") %></th>
</tr>
</thead>
<tbody>
<% income_groups.each do |group| %>
<% percentage = income_total.zero? ? 0 : (group[:total].to_f / income_total * 100).round(1) %>
<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">(<%= group[:count] %> <%= t("reports.transactions_breakdown.table.transactions") %>)</span>
</div>
</td>
<td class="py-3 px-4 text-right">
<span class="font-semibold text-success">
<%= 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 %>
</tbody>
</table>
</div>
</div>
<% end %>
<%# Expenses Section %>
<% if expense_groups.any? %>
<div>
<h3 class="text-base font-semibold text-danger mb-4 flex items-center gap-2">
<%= icon("trending-down", class: "w-5 h-5") %>
<%= t("reports.transactions_breakdown.table.expense") %>
<span class="text-sm font-normal text-tertiary">(<%= Money.new(expense_total, Current.family.currency).format %>)</span>
</h3>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-tertiary">
<th class="text-left py-3 pr-4 font-medium text-secondary"><%= t("reports.transactions_breakdown.table.category") %></th>
<th class="text-right py-3 px-4 font-medium text-secondary">
<%= link_to reports_path(amount_sort_params), class: "inline-flex items-center gap-1 hover:text-primary" do %>
<%= t("reports.transactions_breakdown.table.amount") %>
<% if current_sort_by == "amount" %>
<%= icon(current_sort_direction == "desc" ? "chevron-down" : "chevron-up", class: "w-3 h-3") %>
<% end %>
<% end %>
</th>
<th class="text-right py-3 pl-4 font-medium text-secondary"><%= t("reports.transactions_breakdown.table.percentage") %></th>
</tr>
</thead>
<tbody>
<% expense_groups.each do |group| %>
<% percentage = expense_total.zero? ? 0 : (group[:total].to_f / expense_total * 100).round(1) %>
<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">(<%= group[:count] %> <%= t("reports.transactions_breakdown.table.transactions") %>)</span>
</div>
</td>
<td class="py-3 px-4 text-right">
<span class="font-semibold text-danger">
<%= 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 %>
</tbody>
</table>
</div>
</div>
<% end %>
</div>
<%# Summary Stats %>
<div class="mt-4 text-sm text-secondary">
<%= t("reports.transactions_breakdown.pagination.showing", count: transactions.sum { |g| g[:count] }) %>
</div>
<% else %>
<div class="text-center py-8 text-tertiary">
<%= t("reports.transactions_breakdown.no_transactions") %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,203 @@
<div>
<h2 class="text-lg font-medium text-primary mb-6">
<%= t("reports.trends.title") %>
</h2>
<div class="space-y-8">
<%# Month-over-Month Trends %>
<div>
<h3 class="text-sm font-medium text-secondary 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_with_index do |trend, index| %>
<tr class="border-b border-tertiary/50 <%= index == trends_data.length - 1 ? "font-medium" : "" %>">
<td class="py-3 pr-4 text-primary">
<%= trend[:month] %>
<% if index == trends_data.length - 1 %>
<span class="ml-2 text-xs text-tertiary">(<%= t("reports.trends.current") %>)</span>
<% end %>
</td>
<td class="text-right py-3 px-4 text-success">
<%= Money.new(trend[:income], Current.family.currency).format %>
</td>
<td class="text-right py-3 px-4 text-danger">
<%= Money.new(trend[:expenses], Current.family.currency).format %>
</td>
<td class="text-right py-3 px-2 <%= trend[:net] >= 0 ? "text-success" : "text-danger" %>">
<%= Money.new(trend[:net], Current.family.currency).format %>
</td>
<td class="text-right py-3 pl-4 <%= trend[:net] >= 0 ? "text-success" : "text-danger" %>">
<% savings_rate = trend[:income] > 0 ? ((trend[:net].to_f / trend[:income].to_f) * 100).round(1) : 0 %>
<%= savings_rate %>%
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<%# Trend Insights %>
<div class="mt-6 grid grid-cols-1 md:grid-cols-3 gap-4">
<% avg_income = trends_data.sum { |t| t[:income] } / trends_data.length %>
<% avg_expenses = trends_data.sum { |t| t[:expenses] } / trends_data.length %>
<% 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-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-lg font-semibold text-danger">
<%= 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-lg font-semibold <%= avg_net >= 0 ? "text-success" : "text-danger" %>">
<%= Money.new(avg_net, Current.family.currency).format %>
</p>
</div>
</div>
<% else %>
<div class="text-center py-8 text-tertiary">
<%= t("reports.trends.no_data") %>
</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>

View File

@@ -0,0 +1,67 @@
<%= render DS::Dialog.new(variant: "modal", width: "md") do |dialog| %>
<% dialog.with_body do %>
<div class="space-y-4">
<div class="flex items-center justify-between gap-2">
<h3 class="font-medium text-primary">
<% if @api_key_present %>
<%= t("reports.google_sheets_instructions.title_with_key") %>
<% else %>
<%= t("reports.google_sheets_instructions.title_no_key") %>
<% end %>
</h3>
<%= icon("x", as_button: true, data: { action: "DS--dialog#close" }, class: "text-subdued hover:text-primary") %>
</div>
<div class="text-sm text-secondary space-y-3 whitespace-pre-line">
<% if @api_key_present %>
<p><%= t("reports.google_sheets_instructions.ready") %></p>
<p><%= t("reports.google_sheets_instructions.steps") %></p>
<div class="bg-surface-inset p-3 rounded-lg font-mono text-xs break-all">
=IMPORTDATA("<%= @csv_url %>")
</div>
<p class="text-warning"><%= icon("alert-triangle", class: "w-4 h-4 inline") %> <%= t("reports.google_sheets_instructions.security_warning") %></p>
<% else %>
<p><%= t("reports.google_sheets_instructions.need_key") %></p>
<ol class="list-decimal list-inside space-y-2">
<li><%= t("reports.google_sheets_instructions.step1") %></li>
<li><%= t("reports.google_sheets_instructions.step2") %></li>
<li><%= t("reports.google_sheets_instructions.step3") %></li>
<li><%= t("reports.google_sheets_instructions.step4") %></li>
</ol>
<p><strong><%= t("reports.google_sheets_instructions.example") %>:</strong></p>
<div class="bg-surface-inset p-3 rounded-lg font-mono text-xs break-all">
<%= @csv_url %>&api_key=YOUR_API_KEY_HERE
</div>
<p><%= t("reports.google_sheets_instructions.then_use") %></p>
<% end %>
</div>
<div class="flex gap-2">
<% if @api_key_present %>
<%= render DS::Button.new(
text: t("reports.google_sheets_instructions.open_sheets"),
variant: "primary",
full_width: true,
href: "https://sheets.google.com/create",
target: "_blank",
data: { action: "click->DS--dialog#close" }
) %>
<% else %>
<%= render DS::Button.new(
text: t("reports.google_sheets_instructions.go_to_api_keys"),
variant: "primary",
full_width: true,
href: settings_api_key_path,
frame: "_top"
) %>
<% end %>
<%= render DS::Button.new(
text: t("reports.google_sheets_instructions.close"),
variant: "outline",
full_width: true,
data: { action: "DS--dialog#close" }
) %>
</div>
</div>
<% end %>
<% end %>

View File

@@ -0,0 +1,122 @@
<% 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>
<%# Period Navigation Tabs %>
<div class="flex items-center gap-2 overflow-x-auto pb-2">
<%= render DS::Link.new(
text: t("reports.index.periods.monthly"),
variant: @period_type == :monthly ? "secondary" : "ghost",
href: reports_path(period_type: :monthly),
size: :sm
) %>
<%= render DS::Link.new(
text: t("reports.index.periods.quarterly"),
variant: @period_type == :quarterly ? "secondary" : "ghost",
href: reports_path(period_type: :quarterly),
size: :sm
) %>
<%= render DS::Link.new(
text: t("reports.index.periods.ytd"),
variant: @period_type == :ytd ? "secondary" : "ghost",
href: reports_path(period_type: :ytd),
size: :sm
) %>
<%= render DS::Link.new(
text: t("reports.index.periods.last_6_months"),
variant: @period_type == :last_6_months ? "secondary" : "ghost",
href: reports_path(period_type: :last_6_months),
size: :sm
) %>
<%= render DS::Link.new(
text: t("reports.index.periods.custom"),
variant: @period_type == :custom ? "secondary" : "ghost",
href: reports_path(period_type: :custom),
size: :sm
) %>
</div>
<%# Custom Date Range Picker (only shown when custom is selected) %>
<% if @period_type == :custom %>
<%= form_with url: reports_path, method: :get, data: { controller: "auto-submit-form" }, class: "flex items-center gap-3 bg-surface-inset p-3 rounded-lg" do |f| %>
<%= f.hidden_field :period_type, value: :custom %>
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-secondary"><%= t("reports.index.date_range.from") %></label>
<%= f.date_field :start_date,
value: @start_date.strftime("%Y-%m-%d"),
data: { auto_submit_form_target: "auto" },
autocomplete: "off",
class: "px-3 py-1.5 border border-primary rounded-lg text-sm" %>
</div>
<span class="text-secondary">—</span>
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-secondary"><%= t("reports.index.date_range.to") %></label>
<%= f.date_field :end_date,
value: @end_date.strftime("%Y-%m-%d"),
data: { auto_submit_form_target: "auto" },
autocomplete: "off",
class: "px-3 py-1.5 border border-primary rounded-lg text-sm" %>
</div>
<% end %>
<% end %>
<%# Period Display %>
<div class="text-sm text-secondary">
<%= t("reports.index.showing_period",
start: @start_date.strftime("%b %-d, %Y"),
end: @end_date.strftime("%b %-d, %Y")) %>
</div>
</div>
<% end %>
<div class="w-full space-y-6 pb-24">
<% if Current.family.transactions.any? %>
<%# Summary Dashboard %>
<section>
<%= render partial: "reports/summary_dashboard", locals: {
metrics: @summary_metrics,
period_type: @period_type
} %>
</section>
<%# Comparison Chart %>
<section class="bg-container py-4 px-6 rounded-xl shadow-border-xs">
<%= render partial: "reports/comparison_chart", locals: {
comparison_data: @comparison_data,
period_type: @period_type,
start_date: @start_date
} %>
</section>
<%# Trends & Insights %>
<section class="bg-container py-4 px-6 rounded-xl shadow-border-xs">
<%= render partial: "reports/trends_insights", locals: {
trends_data: @trends_data,
spending_patterns: @spending_patterns
} %>
</section>
<%# Transactions Breakdown %>
<section class="bg-container py-4 px-6 rounded-xl shadow-border-xs">
<%= render partial: "reports/transactions_breakdown", locals: {
transactions: @transactions,
period_type: @period_type,
start_date: @start_date,
end_date: @end_date
} %>
</section>
<% else %>
<%# Empty State %>
<section>
<%= render partial: "reports/empty_state" %>
</section>
<% end %>
</div>

View File

@@ -0,0 +1,7 @@
# Be sure to restart your server when you modify this file.
# Add new mime types for use in respond_to blocks:
# Mime::Type.register "text/richtext", :rtf
Mime::Type.register "text/csv", :csv
Mime::Type.register "application/pdf", :pdf
Mime::Type.register "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", :xlsx

View File

@@ -0,0 +1,137 @@
---
en:
reports:
index:
title: Reports
subtitle: Comprehensive insights into your financial health
export: Export CSV
periods:
monthly: Monthly
quarterly: Quarterly
ytd: Year to Date
last_6_months: Last 6 Months
custom: Custom Range
date_range:
from: From
to: To
showing_period: "Showing data from %{start} to %{end}"
summary:
total_income: Total Income
total_expenses: Total Expenses
net_savings: Net Savings
budget_performance: Budget Performance
vs_previous: vs previous period
income_minus_expenses: Income minus expenses
of_budget_used: of budget used
no_budget_data: No budget data for this period
comparison:
title: Period Comparison
currency: "Currency: %{symbol}"
income: Income
expenses: Expenses
net_savings: Net Savings
current: Current Period
previous: Previous Period
no_data: No data available
status:
improved: Improved
decreased: Decreased
reduced: Reduced
increased: Increased
budget_performance:
title: Budget Performance
spent: Spent
budgeted: Budgeted
remaining: Remaining
over_by: Over by
suggested_daily: "%{amount} suggested per day for %{days} remaining days"
no_budgets: No budget categories set up for this month
status:
good: On Track
warning: Near Limit
over: Over Budget
trends:
title: Trends & Insights
monthly_breakdown: Monthly Breakdown
month: Month
income: Income
expenses: Expenses
net: Net
savings_rate: Savings Rate
current: current
avg_monthly_income: Avg. Monthly Income
avg_monthly_expenses: Avg. Monthly Expenses
avg_monthly_savings: Avg. Monthly Savings
no_data: No trend data available
spending_patterns: Spending Patterns
weekday_spending: Weekday Spending
weekend_spending: Weekend Spending
total: Total
avg_per_transaction: Avg. per transaction
transactions: Transactions
insight_title: Insight
insight_higher_weekend: "You spend %{percent}% more per transaction on weekends than weekdays"
insight_higher_weekday: "You spend %{percent}% more per transaction on weekdays than weekends"
insight_similar: "Your spending per transaction is similar on weekdays and weekends"
no_spending_data: No spending data available for this period
empty_state:
title: No Data Available
description: Start tracking your finances by adding transactions or connecting your accounts to see comprehensive reports
add_transaction: Add Transaction
add_account: Add Account
transactions_breakdown:
title: Transactions Breakdown
no_transactions: No transactions found for the selected period and filters
filters:
title: Filters
category: Category
account: Account
tag: Tag
amount_min: Min Amount
amount_max: Max Amount
date_range: Date Range
all_categories: All Categories
all_accounts: All Accounts
all_tags: All Tags
apply: Apply Filters
clear: Clear Filters
sort:
label: Sort by
date_desc: Date (Newest)
amount_desc: Amount (High to Low)
amount_asc: Amount (Low to High)
export:
label: Export
csv: CSV
excel: Excel
pdf: PDF
google_sheets: Open in Google Sheets
table:
category: Category
amount: Amount
type: Type
expense: Expenses
income: Income
uncategorized: Uncategorized
transactions: transactions
percentage: "% of Total"
pagination:
showing: Showing %{count} transactions
previous: Previous
next: Next
google_sheets_instructions:
title_with_key: "✅ Copy URL for Google Sheets"
title_no_key: "⚠️ API Key Required"
ready: Your CSV URL (with API key) is ready.
steps: "To import into Google Sheets:\n1. Create a new Google Sheet\n2. In cell A1, enter the formula shown below\n3. Press Enter"
security_warning: "This URL includes your API key. Keep it secure!"
need_key: To import data into Google Sheets, you need an API key.
step1: "Go to Settings → API Keys"
step2: "Create a new API key with \"read\" permission"
step3: Copy the API key
step4: "Add it to this URL as: ?api_key=YOUR_KEY"
example: Example
then_use: Then use the full URL with =IMPORTDATA() in Google Sheets.
open_sheets: Open Google Sheets
go_to_api_keys: Go to API Keys
close: Got it

View File

@@ -103,6 +103,11 @@ Rails.application.routes.draw do
delete :destroy_all, on: :collection
end
resources :reports, only: %i[index] do
get :export_transactions, on: :collection
get :google_sheets_instructions, on: :collection
end
resources :budgets, only: %i[index show edit update], param: :month_year do
get :picker, on: :collection

View File

@@ -0,0 +1,196 @@
require "test_helper"
class ReportsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
@family = @user.family
end
test "index renders successfully" do
get reports_path
assert_response :ok
end
test "index with monthly period" do
get reports_path(period_type: :monthly)
assert_response :ok
assert_select "h1", text: I18n.t("reports.index.title")
end
test "index with quarterly period" do
get reports_path(period_type: :quarterly)
assert_response :ok
end
test "index with ytd period" do
get reports_path(period_type: :ytd)
assert_response :ok
end
test "index with custom period and date range" do
get reports_path(
period_type: :custom,
start_date: 1.month.ago.to_date.to_s,
end_date: Date.current.to_s
)
assert_response :ok
end
test "index with last 6 months period" do
get reports_path(period_type: :last_6_months)
assert_response :ok
end
test "index shows empty state when no transactions" do
# Delete all transactions for the family by deleting from accounts
@family.accounts.each { |account| account.entries.destroy_all }
get reports_path
assert_response :ok
assert_select "h3", text: I18n.t("reports.empty_state.title")
end
test "index with budget performance for current month" do
# Create a budget for current month
budget = Budget.find_or_bootstrap(@family, start_date: Date.current.beginning_of_month)
category = @family.categories.expenses.first
# Fail fast if test setup is incomplete
assert_not_nil category, "Test setup failed: no expense category found for family"
assert_not_nil budget, "Test setup failed: budget could not be created or found"
# Find or create budget category to avoid duplicate errors
budget_category = budget.budget_categories.find_or_initialize_by(category: category)
budget_category.budgeted_spending = Money.new(50000, @family.currency)
budget_category.save!
get reports_path(period_type: :monthly)
assert_response :ok
end
test "index calculates summary metrics correctly" do
get reports_path(period_type: :monthly)
assert_response :ok
assert_select "h3", text: I18n.t("reports.summary.total_income")
assert_select "h3", text: I18n.t("reports.summary.total_expenses")
assert_select "h3", text: I18n.t("reports.summary.net_savings")
end
test "index builds comparison data" do
get reports_path(period_type: :monthly)
assert_response :ok
assert_select "h2", text: I18n.t("reports.comparison.title")
assert_select "h3", text: I18n.t("reports.comparison.income")
assert_select "h3", text: I18n.t("reports.comparison.expenses")
end
test "index builds trends data" do
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")
end
test "index handles invalid date parameters gracefully" do
get reports_path(
period_type: :custom,
start_date: "invalid-date",
end_date: "also-invalid"
)
assert_response :ok # Should not crash, uses defaults
end
test "spending patterns returns data when expense transactions exist" do
# Create expense category
expense_category = @family.categories.create!(
name: "Test Groceries",
classification: "expense"
)
# Create account
account = @family.accounts.first
# Create expense transaction on a weekday (Monday)
weekday_date = Date.current.beginning_of_month + 2.days
weekday_date = weekday_date.next_occurring(:monday)
entry = account.entries.create!(
name: "Grocery shopping",
date: weekday_date,
amount: -50.00,
currency: "USD",
entryable: Transaction.new(
category: expense_category,
kind: "standard"
)
)
# Create expense transaction on a weekend (Saturday)
weekend_date = weekday_date.next_occurring(:saturday)
weekend_entry = account.entries.create!(
name: "Weekend shopping",
date: weekend_date,
amount: -75.00,
currency: "USD",
entryable: Transaction.new(
category: expense_category,
kind: "standard"
)
)
get reports_path(period_type: :monthly)
assert_response :ok
# Verify spending patterns shows data (not the "no data" message)
assert_select ".text-center.py-8.text-tertiary", { text: /No spending data/, count: 0 }, "Should not show 'No spending data' message when transactions exist"
end
test "export transactions with API key authentication" do
# Use an active API key with read permissions
api_key = api_keys(:active_key)
# Make sure the API key has the correct source
api_key.update!(source: "web") unless api_key.source == "web"
get export_transactions_reports_path(
format: :csv,
period_type: :ytd,
start_date: Date.current.beginning_of_year,
end_date: Date.current,
api_key: api_key.plain_key
)
assert_response :ok
assert_equal "text/csv", @response.media_type
assert_match /Category/, @response.body
end
test "export transactions with invalid API key" do
get export_transactions_reports_path(
format: :csv,
period_type: :ytd,
api_key: "invalid_key"
)
assert_response :unauthorized
assert_match /Invalid or expired API key/, @response.body
end
test "export transactions without API key uses session auth" do
# Should use normal session-based authentication
# The setup already signs in @user = users(:family_admin)
assert_not_nil @user, "User should be set in test setup"
assert_not_nil @family, "Family should be set in test setup"
get export_transactions_reports_path(
format: :csv,
period_type: :ytd,
start_date: Date.current.beginning_of_year,
end_date: Date.current
)
assert_response :ok, "Export should work with session auth. Response: #{@response.body}"
assert_equal "text/csv", @response.media_type
end
end