mirror of
https://github.com/we-promise/sure.git
synced 2026-05-30 07:49:01 +00:00
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:
809
app/controllers/reports_controller.rb
Normal file
809
app/controllers/reports_controller.rb
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 }
|
||||
] %>
|
||||
|
||||
117
app/views/reports/_budget_performance.html.erb
Normal file
117
app/views/reports/_budget_performance.html.erb
Normal 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>
|
||||
250
app/views/reports/_comparison_chart.html.erb
Normal file
250
app/views/reports/_comparison_chart.html.erb
Normal 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>
|
||||
27
app/views/reports/_empty_state.html.erb
Normal file
27
app/views/reports/_empty_state.html.erb
Normal 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>
|
||||
132
app/views/reports/_summary_dashboard.html.erb
Normal file
132
app/views/reports/_summary_dashboard.html.erb
Normal 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>
|
||||
180
app/views/reports/_transactions_breakdown.html.erb
Normal file
180
app/views/reports/_transactions_breakdown.html.erb
Normal 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>
|
||||
203
app/views/reports/_trends_insights.html.erb
Normal file
203
app/views/reports/_trends_insights.html.erb
Normal 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>
|
||||
67
app/views/reports/google_sheets_instructions.html.erb
Normal file
67
app/views/reports/google_sheets_instructions.html.erb
Normal 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 %>
|
||||
122
app/views/reports/index.html.erb
Normal file
122
app/views/reports/index.html.erb
Normal 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>
|
||||
7
config/initializers/mime_types.rb
Normal file
7
config/initializers/mime_types.rb
Normal 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
|
||||
137
config/locales/views/reports/en.yml
Normal file
137
config/locales/views/reports/en.yml
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
196
test/controllers/reports_controller_test.rb
Normal file
196
test/controllers/reports_controller_test.rb
Normal 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
|
||||
Reference in New Issue
Block a user