diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb new file mode 100644 index 000000000..495b13dfd --- /dev/null +++ b/app/controllers/reports_controller.rb @@ -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 diff --git a/app/models/budget_category.rb b/app/models/budget_category.rb index a2a848503..7ef6467db 100644 --- a/app/models/budget_category.rb +++ b/app/models/budget_category.rb @@ -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" diff --git a/app/views/budget_categories/_budget_category.html.erb b/app/views/budget_categories/_budget_category.html.erb index 81e857a2f..b867783a7 100644 --- a/app/views/budget_categories/_budget_category.html.erb +++ b/app/views/budget_categories/_budget_category.html.erb @@ -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? %> -
+ <%= t("reports.budget_performance.suggested_daily", + amount: daily_info[:amount].format, + days: daily_info[:days_remaining]) %> +
+<%= budget_category.category.name %>
++ <%= budget_category.median_monthly_expense_money.format %> avg +
+<%= format_money(budget_category.actual_spending_money) %>
+<%= budget_category.category.name %>
- - <% if budget_category.initialized? %> - <% if budget_category.available_to_spend.negative? %> -<%= format_money(budget_category.available_to_spend_money.abs) %> over
- <% elsif budget_category.available_to_spend.zero? %> -"> - <%= format_money(budget_category.available_to_spend_money) %> left -
- <% else %> -<%= format_money(budget_category.available_to_spend_money) %> left
- <% end %> - <% else %> -- <%= budget_category.median_monthly_expense_money.format %> avg -
- <% end %> -<%= format_money(budget_category.actual_spending_money) %>
- - <% if budget_category.initialized? %> -from <%= format_money(budget_category.budgeted_spending_money) %>
- <% end %> -+ <%= start_date.strftime("%B %Y") %> +
++ <%= t("reports.budget_performance.suggested_daily", + amount: Money.new((budget_item[:remaining] / days_remaining), Current.family.currency).format, + days: days_remaining) %> +
++ <%= t("reports.budget_performance.no_budgets") %> +
++ <%= t("reports.comparison.currency", symbol: comparison_data[:currency_symbol]) %> +
++ <%= t("reports.empty_state.description") %> +
+ ++ <%= metrics[:current_income].format %> +
+ + <% if metrics[:income_change] %> ++ <%= metrics[:current_expenses].format %> +
+ + <% if metrics[:expense_change] %> +"> + <%= metrics[:net_savings].format %> +
+ ++ <%= t("reports.summary.income_minus_expenses") %> +
++ <%= metrics[:budget_percent] %>% +
+ ++ <%= t("reports.summary.of_budget_used") %> +
++ <%= t("reports.summary.no_budget_data") %> +
+ <% end %> +| <%= t("reports.transactions_breakdown.table.category") %> | ++ <%= 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 %> + | +<%= t("reports.transactions_breakdown.table.percentage") %> | +
|---|---|---|
|
+
+
+ <%= group[:category_name] %>
+ (<%= group[:count] %> <%= t("reports.transactions_breakdown.table.transactions") %>)
+
+ |
+ + + <%= Money.new(group[:total], Current.family.currency).format %> + + | ++ + <%= percentage %>% + + | +
| <%= t("reports.transactions_breakdown.table.category") %> | ++ <%= 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 %> + | +<%= t("reports.transactions_breakdown.table.percentage") %> | +
|---|---|---|
|
+
+
+ <%= group[:category_name] %>
+ (<%= group[:count] %> <%= t("reports.transactions_breakdown.table.transactions") %>)
+
+ |
+ + + <%= Money.new(group[:total], Current.family.currency).format %> + + | ++ + <%= percentage %>% + + | +
| <%= t("reports.trends.month") %> | +<%= t("reports.trends.income") %> | +<%= t("reports.trends.expenses") %> | +<%= t("reports.trends.net") %> | +<%= t("reports.trends.savings_rate") %> | +
|---|---|---|---|---|
| + <%= trend[:month] %> + <% if index == trends_data.length - 1 %> + (<%= t("reports.trends.current") %>) + <% end %> + | ++ <%= Money.new(trend[:income], Current.family.currency).format %> + | ++ <%= Money.new(trend[:expenses], Current.family.currency).format %> + | +"> + <%= Money.new(trend[:net], Current.family.currency).format %> + | +"> + <% savings_rate = trend[:income] > 0 ? ((trend[:net].to_f / trend[:income].to_f) * 100).round(1) : 0 %> + <%= savings_rate %>% + | +
<%= t("reports.trends.avg_monthly_income") %>
++ <%= Money.new(avg_income, Current.family.currency).format %> +
+<%= t("reports.trends.avg_monthly_expenses") %>
++ <%= Money.new(avg_expenses, Current.family.currency).format %> +
+<%= t("reports.trends.avg_monthly_savings") %>
+"> + <%= Money.new(avg_net, Current.family.currency).format %> +
+<%= t("reports.trends.total") %>
++ <%= Money.new(spending_patterns[:weekday_total], Current.family.currency).format %> +
+<%= t("reports.trends.avg_per_transaction") %>
++ <%= Money.new(spending_patterns[:weekday_avg], Current.family.currency).format %> +
+<%= t("reports.trends.transactions") %>
++ <%= spending_patterns[:weekday_count] %> +
+<%= t("reports.trends.total") %>
++ <%= Money.new(spending_patterns[:weekend_total], Current.family.currency).format %> +
+<%= t("reports.trends.avg_per_transaction") %>
++ <%= Money.new(spending_patterns[:weekend_avg], Current.family.currency).format %> +
+<%= t("reports.trends.transactions") %>
++ <%= spending_patterns[:weekend_count] %> +
++ <%= t("reports.trends.insight_title") %> +
++ <% + 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 %> +
+<%= t("reports.google_sheets_instructions.ready") %>
+<%= t("reports.google_sheets_instructions.steps") %>
+<%= icon("alert-triangle", class: "w-4 h-4 inline") %> <%= t("reports.google_sheets_instructions.security_warning") %>
+ <% else %> +<%= t("reports.google_sheets_instructions.need_key") %>
+<%= t("reports.google_sheets_instructions.example") %>:
+<%= t("reports.google_sheets_instructions.then_use") %>
+ <% end %> ++ <%= t("reports.index.subtitle") %> +
+