diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb index fe8a1874c1..69de0f84f1 100644 --- a/app/controllers/admin/reports_controller.rb +++ b/app/controllers/admin/reports_controller.rb @@ -22,14 +22,12 @@ module Admin def show @report = report_class.new(spree_current_user, params, render: false) @rendering_options = rendering_options - show_report end def create @report = report_class.new(spree_current_user, params, render: true) update_rendering_options - render_in_background end @@ -61,7 +59,9 @@ module Admin @blob = ReportBlob.create_for_upload_later!(report_filename) ReportJob.perform_later( - report_class:, user: spree_current_user, params:, + report_class:, + user: spree_current_user, + params:, format: report_format, blob: @blob, channel: ScopedChannel.for_id(params[:uuid]), diff --git a/app/controllers/concerns/reports_actions.rb b/app/controllers/concerns/reports_actions.rb index 9cf8095aa7..a88b429397 100644 --- a/app/controllers/concerns/reports_actions.rb +++ b/app/controllers/concerns/reports_actions.rb @@ -84,6 +84,7 @@ module ReportsActions else params[:fields_to_show] end, + display_metadata_rows: false, display_summary_row: request.get?, display_header_row: false } @@ -94,6 +95,7 @@ module ReportsActions rendering_options.update( options: { fields_to_show: params[:fields_to_show], + display_metadata_rows: params[:display_metadata_rows].present?, display_summary_row: params[:display_summary_row].present?, display_header_row: params[:display_header_row].present? } diff --git a/app/views/admin/reports/_rendering_options.html.haml b/app/views/admin/reports/_rendering_options.html.haml index cb954eb6eb..60c078c1b4 100644 --- a/app/views/admin/reports/_rendering_options.html.haml +++ b/app/views/admin/reports/_rendering_options.html.haml @@ -1,19 +1,23 @@ - if @report_subtypes.present? && @report_subtypes.count > 1 %input{type: 'hidden', name: 'report_subtype', value: @report_subtype} -.row.rendering-options{ "data-controller": "csv-select" } +.row.rendering-options{ "data-controller": "csv-select metadata-toggle" } .alpha.two.columns = label_tag :report_format, t(".generate_report") .omega.fourteen.columns{ style: "margin-bottom: 1.5em;" } = select_tag :report_format, grouped_options_for_select({ | t('.formatted_data') => { t('.on_screen') => '', "PDF" => 'pdf', t('.spreadsheet') => 'xlsx' }, | t('.raw_data') => { "CSV" => 'csv' }, | - }), { "data-csv-select-target": "reportType", "data-action": "csv-select#handleSelectChange" } + }), { "data-csv-select-target": "reportType", "data-metadata-toggle-target": "reportType", "data-action": "csv-select#handleSelectChange metadata-toggle#handleSelectChange" } - - if @report.header_option? || @report.summary_row_option? + - if @report.header_option? || @report.summary_row_option? || @report.metadata_option? .row .alpha.two.columns= label_tag nil, t(".display") .omega.fourteen.columns + - if @report.metadata_option? + %span.inline-checkbox{ style: "margin-right: 1rem;" } + = check_box_tag :display_metadata_rows, true, @rendering_options.options[:display_metadata_rows], { "disabled": "true", "data-metadata-toggle-target": "checkbox" } + = label_tag :display_metadata_rows, t(".metadata_rows"), {"class": "disabled", "data-metadata-toggle-target": "label" } - if @report.header_option? %span.inline-checkbox{ style: "margin-right: 1rem;" } = check_box_tag :display_header_row, true, @rendering_options.options[:display_header_row] diff --git a/app/webpacker/controllers/csv_select_controller.js b/app/webpacker/controllers/csv_select_controller.js index d01da1bea8..42ab9b011a 100644 --- a/app/webpacker/controllers/csv_select_controller.js +++ b/app/webpacker/controllers/csv_select_controller.js @@ -4,9 +4,9 @@ export default class extends Controller { static targets = ["reportType", "checkbox", "label"]; handleSelectChange() { - this.reportTypeTarget.value == "csv" - ? this.disableField() - : this.enableField(); + this.reportTypeTarget.value == "csv" ? + this.disableField(): + this.enableField(); } disableField() { diff --git a/app/webpacker/controllers/metadata_toggle_controller.js b/app/webpacker/controllers/metadata_toggle_controller.js new file mode 100644 index 0000000000..18c7c9de0f --- /dev/null +++ b/app/webpacker/controllers/metadata_toggle_controller.js @@ -0,0 +1,31 @@ +import { Controller } from "stimulus"; + +export default class extends Controller { + static targets = ["reportType", "checkbox", "label"]; + + handleSelectChange() { + this.reportTypeTarget.value == "csv" ? + this.enableField(): + this.disableField(); + } + + disableField() { + if (this.hasCheckboxTarget) { + this.checkboxTarget.checked = false; + this.checkboxTarget.disabled = true; + } + if (this.hasLabelTarget) { + this.labelTarget.classList.add("disabled"); + } + } + + enableField() { + if (this.hasCheckboxTarget) { + this.checkboxTarget.checked = true; + this.checkboxTarget.disabled = false; + } + if (this.hasLabelTarget) { + this.labelTarget.classList.remove("disabled"); + } + } +} \ No newline at end of file diff --git a/config/locales/en.yml b/config/locales/en.yml index 6460bab0d5..26a7e3f874 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1798,6 +1798,8 @@ en: not_visible: "%{enterprise} is not visible and so cannot be found on the map or in searches" reports: none: none + metadata: + report_title: Report Title deprecated: "This report is deprecated and will be removed in a future release." hidden_field: "< Hidden >" unitsize: UNITSIZE @@ -1900,6 +1902,7 @@ en: display: Display summary_row: Summary Row header_row: Header Row + metadata_rows: Metadata Rows raw_data: Raw Data formatted_data: Formatted Data packing: @@ -2514,12 +2517,14 @@ en: email_confirmed: "Thank you for confirming your email address." email_confirmation_activate_account: "Before we can activate your new account, we need to confirm your email address." email_confirmation_greeting: "Hi, %{contact}!" - email_confirmation_profile_created: "A profile for %{name} has been successfully created! -To activate your Profile we need to confirm this email address." + email_confirmation_profile_created: > + A profile for %{name} has been successfully created! + To activate your Profile we need to confirm this email address. email_confirmation_click_link: "Please click the link below to confirm your email and to continue setting up your profile." email_confirmation_link_label: "Confirm this email address ยป" - email_confirmation_help_html: "After confirming your email you can access your administration account for this enterprise. -See the %{link} to find out more about %{sitename}'s features and to start using your profile or online store." + email_confirmation_help_html: > + After confirming your email you can access your administration account for this enterprise. + See the %{link} to find out more about %{sitename}'s features and to start using your profile or online store." email_confirmation_notice_unexpected: "You received this message because you signed up on %{sitename}, or were invited to sign up by someone you probably know. If you don't understand why you are receiving this email, please write to %{contact}." email_social: "Connect with Us:" email_contact: "Email us:" diff --git a/lib/reporting/report_metadata_builder.rb b/lib/reporting/report_metadata_builder.rb new file mode 100644 index 0000000000..06ce36c270 --- /dev/null +++ b/lib/reporting/report_metadata_builder.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Reporting + class ReportMetadataBuilder + attr_reader :report, :current_user + + def initialize(report, current_user = nil) + @report = report + @current_user = current_user + end + + def rows + rows = [] + rows.concat(title_rows) + rows.concat(date_range_rows) + rows.concat(printed_rows) + rows.concat(other_filter_rows) + rows << [] if rows.any? # spacer only if something was added + rows + end + + private + + DATE_FROM_KEYS = %i[completed_at_gt created_at_gt updated_at_gt].freeze + DATE_TO_KEYS = %i[completed_at_lt created_at_lt updated_at_lt].freeze + + def title_rows + type = params[:report_type] + sub = params[:report_subtype] + return [] if type.blank? + + label = I18n.t("admin.reports.metadata.report_title", default: "Report Title") + type_name = I18n.t("admin.reports.#{type}.name", + default: I18n.t("admin.reports.#{type}", + default: type.to_s.tr('_', ' ').titleize)) + + sub_name = sub.present? ? sub.to_s.tr('_', ' ').titleize : nil + + title = [type_name, sub_name].compact.join(' - ') + [[label, title]] + end + + def date_range_rows + q = indifferent_ransack + from = first_present(q, DATE_FROM_KEYS) + to = first_present(q, DATE_TO_KEYS) + return [] unless from || to + + label = I18n.t("date_range", default: "Date Range") + [[label, [from, to].compact.join(' - ')]] # en dash + end + + def first_present(hash, keys) + keys.map { |k| hash[k] }.find(&:present?) + end + + def indifferent_ransack + (report.ransack_params || {}).with_indifferent_access + end + + def printed_rows + [[I18n.t("printed", default: "Printed"), Time.now.utc.strftime('%F %T %Z')]] + end + + def other_filter_rows + q = indifferent_ransack.except(*DATE_FROM_KEYS, *DATE_TO_KEYS) + q.each_with_object([]) do |(k, v), rows| + next if v.blank? + + rows << [k.to_s.humanize, v.is_a?(Array) ? v.join(', ') : v.to_s] + end + end + + def params + (report.params || {}).with_indifferent_access + end + end +end diff --git a/lib/reporting/report_renderer.rb b/lib/reporting/report_renderer.rb index 05b5a4f45c..b68c3252b5 100644 --- a/lib/reporting/report_renderer.rb +++ b/lib/reporting/report_renderer.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require 'csv' require 'spreadsheet_architect' module Reporting @@ -24,6 +25,10 @@ module Reporting @report.params[:report_format].in?([nil, '', 'pdf']) end + def display_metadata_rows? + @report.params[:display_metadata_rows].present? && raw_render? + end + def display_header_row? @report.params[:display_header_row].present? && !raw_render? end @@ -33,13 +38,22 @@ module Reporting end def table_headers - @report.table_headers || [] + base = @report.table_headers || [] + return base unless display_metadata_rows? + + [*metadata_headers, base] end def table_rows @report.table_rows || [] end + def metadata_headers + return [] unless display_metadata_rows? + + Reporting::ReportMetadataBuilder.new(@report, @report.try(:user)).rows + end + def as_json(_context_controller = nil) @report.rows.map(&:to_h).as_json end diff --git a/lib/reporting/report_ruler.rb b/lib/reporting/report_ruler.rb index d24ea327ea..ff68d54539 100644 --- a/lib/reporting/report_ruler.rb +++ b/lib/reporting/report_ruler.rb @@ -10,6 +10,10 @@ module Reporting @formatted_rules ||= @report.rules.map { |rule| format_rule(rule) } end + def metadata_option? + true + end + def header_option? formatted_rules.find { |rule| rule[:header].present? } end diff --git a/lib/reporting/report_template.rb b/lib/reporting/report_template.rb index 0c511b47fc..f8cfe01e08 100644 --- a/lib/reporting/report_template.rb +++ b/lib/reporting/report_template.rb @@ -13,7 +13,7 @@ module Reporting delegate :available_headers, :table_headers, :fields_to_hide, :fields_to_show, to: :headers_builder - delegate :formatted_rules, :header_option?, :summary_row_option?, to: :ruler + delegate :formatted_rules, :header_option?, :summary_row_option?, :metadata_option?, to: :ruler def initialize(user, params = {}, render: false) unless render diff --git a/spec/lib/reports/report_metadata_builder_spec.rb b/spec/lib/reports/report_metadata_builder_spec.rb new file mode 100644 index 0000000000..7f37b95798 --- /dev/null +++ b/spec/lib/reports/report_metadata_builder_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Reporting::ReportMetadataBuilder do + let(:from_key) { described_class::DATE_FROM_KEYS.first } + let(:to_key) { described_class::DATE_TO_KEYS.first } + + let(:params) do + { report_type: :order_cycle_customer_totals, report_subtype: 'by_distributor' } + end + + let(:ransack_params) do + { + from_key => '2025-01-01', + to_key => '2025-01-31', + :status_in => %w[paid shipped], + :hub_id_eq => '42' + } + end + + let(:report) { instance_double('Report', params:, ransack_params:) } + + subject(:builder) { described_class.new(report, nil) } + + it 'assembles rows with title, date range, printed, other filters, and spacer' do + travel_to(Time.zone.parse('2025-06-13 10:20:30 UTC')) do + rows = builder.rows + + # Title + expect(rows).to include(['Report Title', 'Order Cycle Customer Totals - By Distributor']) + + # Date range + expect(rows).to include(['Date Range', '2025-01-01 - 2025-01-31']) + + # Printed timestamp + printed = rows.find { |r| r.first == 'Printed' } + expect(printed).to eq(['Printed', '2025-06-13 10:20:30 UTC']) + + # Other filters (humanized keys) + expect(rows).to include(['Status in', 'paid, shipped']) + expect(rows).to include(['Hub id eq', '42']) + + # Spacer + expect(rows.last).to eq([]) + end + end +end diff --git a/spec/lib/reports/report_renderer_spec.rb b/spec/lib/reports/report_renderer_spec.rb index 1671f64c20..43e3ff08f4 100644 --- a/spec/lib/reports/report_renderer_spec.rb +++ b/spec/lib/reports/report_renderer_spec.rb @@ -34,4 +34,48 @@ RSpec.describe Reporting::ReportRenderer do expect { subject.render_as("give_me_everything") }.to raise_error end end + + # metadata headers + + describe '#metadata_headers' do + let(:user) { create(:user) } + let(:from_key) { Reporting::ReportMetadataBuilder::DATE_FROM_KEYS.first } + let(:to_key) { Reporting::ReportMetadataBuilder::DATE_TO_KEYS.first } + + let(:meta_report) do + instance_double( + 'MetaReport', + rows: data, + params: { + display_metadata_rows: true, + report_type: :order_cycle_customer_totals, + report_subtype: 'by_distributor', + report_format: 'csv' + }, + ransack_params: { + from_key => '2025-01-01', + to_key => '2025-01-31', + :status_in => %w[paid shipped] + }, + user: + ) + end + + let(:renderer) { described_class.new(meta_report) } + + it 'builds rows via ReportMetadataBuilder when display_metadata_rows? + is true and report_format is csv' do + rows = renderer.metadata_headers + + labels = rows.map(&:first) + expect(labels).to include('Report Title') + expect(labels).to include('Date Range') + expect(labels).to include('Printed') + + values = rows.map(&:second) + expect(values).to include('Order Cycle Customer Totals - By Distributor') + expect(values).to include('2025-01-01 - 2025-01-31') + expect(values).to include(Time.now.utc.strftime('%F %T %Z')) + end + end end