diff --git a/app/models/work_package/pdf_export/common/common.rb b/app/models/work_package/pdf_export/common/common.rb index c91d44836ab5..63c055a40044 100644 --- a/app/models/work_package/pdf_export/common/common.rb +++ b/app/models/work_package/pdf_export/common/common.rb @@ -35,7 +35,7 @@ module WorkPackage::PDFExport::Common::Common private - def get_pdf(_language) + def get_pdf ::WorkPackage::PDFExport::Common::View.new(current_language) end @@ -164,6 +164,13 @@ def draw_text_multiline_part(line, text_style, x_position, y_position) measure_text_height(line, text_style) end + def ellipsis_if_longer(text, available_width, text_style) + title_text_width = measure_text_width(text, text_style) + return text if title_text_width < available_width + + truncate_ellipsis(text, available_width, text_style) + end + def truncate_ellipsis(text, available_width, text_style) line = text.dup while line.present? && (measure_text_width("#{line}...", text_style) > available_width) @@ -285,4 +292,12 @@ def footer_date def current_page_nr pdf.page_number + @page_count - (with_cover? ? 1 : 0) end + + def write_horizontal_line(y_position, height, color) + draw_horizontal_line( + y_position, + pdf.bounds.left, pdf.bounds.right, + height, color + ) + end end diff --git a/app/models/work_package/pdf_export/document_generator.rb b/app/models/work_package/pdf_export/document_generator.rb index 37b69c6b4ea0..26a3a46ceb88 100644 --- a/app/models/work_package/pdf_export/document_generator.rb +++ b/app/models/work_package/pdf_export/document_generator.rb @@ -52,7 +52,7 @@ def initialize(work_package, _options = {}) end def setup_page! - self.pdf = get_pdf(current_language) + self.pdf = get_pdf end def export! diff --git a/app/models/work_package/pdf_export/work_package_list_to_pdf.rb b/app/models/work_package/pdf_export/work_package_list_to_pdf.rb index 9a1ca49c949e..9b399c22d0d5 100644 --- a/app/models/work_package/pdf_export/work_package_list_to_pdf.rb +++ b/app/models/work_package/pdf_export/work_package_list_to_pdf.rb @@ -86,7 +86,7 @@ def export! private def setup_page! - self.pdf = get_pdf(current_language) + self.pdf = get_pdf configure_page_size!(wants_report? ? :portrait : :landscape) end diff --git a/app/models/work_package/pdf_export/work_package_to_pdf.rb b/app/models/work_package/pdf_export/work_package_to_pdf.rb index 5dcfa09dd8c8..2006616c0fc4 100644 --- a/app/models/work_package/pdf_export/work_package_to_pdf.rb +++ b/app/models/work_package/pdf_export/work_package_to_pdf.rb @@ -63,7 +63,7 @@ def export! end def setup_page! - self.pdf = get_pdf(current_language) + self.pdf = get_pdf @page_count = 0 configure_page_size!(:portrait) end diff --git a/frontend/src/stimulus/controllers/dynamic/costs/export.controller.ts b/frontend/src/stimulus/controllers/dynamic/costs/export.controller.ts new file mode 100644 index 000000000000..ba839789d487 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/costs/export.controller.ts @@ -0,0 +1,60 @@ +import { Controller } from '@hotwired/stimulus'; +import * as Turbo from '@hotwired/turbo'; +import { HttpErrorResponse } from '@angular/common/http'; + +export default class ExportController extends Controller { + static values = { + jobStatusDialogUrl: String, + }; + + declare jobStatusDialogUrlValue:string; + + jobModalUrl(job_id:string):string { + return this.jobStatusDialogUrlValue.replace('_job_uuid_', job_id); + } + + async showJobModal(job_id:string) { + const response = await fetch(this.jobModalUrl(job_id), { + method: 'GET', + headers: { Accept: 'text/vnd.turbo-stream.html' }, + }); + if (response.ok) { + Turbo.renderStreamMessage(await response.text()); + } else { + throw new Error(response.statusText || 'Invalid response from server'); + } + } + + async requestExport(exportURL:string):Promise { + const response = await fetch(exportURL, { + method: 'GET', + headers: { Accept: 'application/json' }, + credentials: 'same-origin', + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + const result = await response.json() as { job_id:string }; + if (!result.job_id) { + throw new Error('Invalid response from server'); + } + return result.job_id; + } + + get href() { + return this.element.href; + } + + download(evt:CustomEvent) { + evt.preventDefault(); // Don't follow the href + this.requestExport(this.href) + .then((job_id) => this.showJobModal(job_id)) + .catch((error:HttpErrorResponse) => this.handleError(error)); + } + + private handleError(error:HttpErrorResponse) { + void window.OpenProject.getPluginContext().then((pluginContext) => { + pluginContext.services.notifications.addError(error); + }); + } +} diff --git a/modules/reporting/app/components/cost_reports/index_page_header_component.html.erb b/modules/reporting/app/components/cost_reports/index_page_header_component.html.erb index 4c59a126a115..92d133f1d002 100644 --- a/modules/reporting/app/components/cost_reports/index_page_header_component.html.erb +++ b/modules/reporting/app/components/cost_reports/index_page_header_component.html.erb @@ -5,16 +5,39 @@ if show_export_button? header.with_action_button(scheme: :default, - aria: { label: t(:export_to_excel)}, + aria: { label: t(:export_to_excel) }, title: t(:export_to_excel), mobile_icon: "op-file-xls-descriptions", mobile_label: t(:export_to_excel), tag: :a, - href: url_for({ controller: "cost_reports" , action: :index, format: 'xls', project_id: @project }) + href: url_for({ controller: "cost_reports", action: :index, format: 'xls', project_id: @project }), + data: { + controller: "costs--export", + "application-target": "dynamic", + "costs--export-job-status-dialog-url-value": job_status_dialog_path('_job_uuid_'), + action: "click->costs--export#download" + } ) do |button| button.with_leading_visual_icon(icon: "op-file-xls-descriptions") t(:export_to_excel) end + header.with_action_button(scheme: :default, + aria: { label: t("export.timesheet.button") }, + title: t("export.timesheet.button"), + mobile_icon: "op-file-xls-descriptions", + mobile_label: t("export.timesheet.button"), + tag: :a, + href: url_for({ controller: "cost_reports", action: :index, format: 'pdf', project_id: @project }), + data: { + controller: "costs--export", + "application-target": "dynamic", + "costs--export-job-status-dialog-url-value": job_status_dialog_path('_job_uuid_'), + action: "click->costs--export#download" + } + ) do |button| + button.with_leading_visual_icon(icon: "op-file-xls-descriptions") + t("export.timesheet.button") + end call_hook(:view_cost_report_toolbar) end end diff --git a/modules/reporting/app/controllers/cost_reports_controller.rb b/modules/reporting/app/controllers/cost_reports_controller.rb index edb12e8baf26..d66a649596ac 100644 --- a/modules/reporting/app/controllers/cost_reports_controller.rb +++ b/modules/reporting/app/controllers/cost_reports_controller.rb @@ -76,23 +76,38 @@ def check_cache def index table + return if performed? - unless performed? - respond_to do |format| - format.html do - session[report_engine.name.underscore.to_sym].try(:delete, :name) - render locals: { menu_name: project_or_global_menu } - end + respond_to do |format| + format.html { render_html } + format.xls { export(:xls) } + format.pdf { export(:pdf) } + end + end - format.xls do - job_id = ::CostQuery::ScheduleExportService - .new(user: current_user) - .call(filter_params:, project: @project, cost_types: @cost_types) - .result + def render_html + session[session_name].try(:delete, :name) + # get rid of unsaved filters and grouping + store_query(@query) if @query&.id != session[session_name].try(:id) + render locals: { menu_name: project_or_global_menu } + end - redirect_to job_status_path(job_id) - end - end + def export(format) + job_id = ::CostQuery::ScheduleExportService + .new(user: current_user) + .call( + format:, + query_id: @query.id, + query_name: @query.name, + filter_params: filter_params, + project: @project, + cost_types: @cost_types + ).result + + if request.headers["Accept"]&.include?("application/json") + render json: { job_id: } + else + redirect_to job_status_path(job_id) end end @@ -241,7 +256,7 @@ def default_filter_parameters # Get the filter params with an optional project context def filter_params filters = http_filter_parameters if set_filter? - filters ||= session[report_engine.name.underscore.to_sym].try(:[], :filters) + filters ||= session[session_name].try(:[], :filters) filters ||= default_filter_parameters update_project_context!(filters) @@ -253,7 +268,7 @@ def filter_params # Return the active group bys def group_params groups = http_group_parameters if set_filter? - groups ||= session[report_engine.name.underscore.to_sym].try(:[], :groups) + groups ||= session[session_name].try(:[], :groups) groups || default_group_parameters end @@ -358,7 +373,8 @@ def determine_engine # save_private_cost_reports permission as well # # @Override - def allowed_in_report?(action, report, user = User.current) # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity + # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity + def allowed_in_report?(action, report, user = User.current) # admins may do everything return true if user.admin? @@ -390,6 +406,7 @@ def allowed_in_report?(action, report, user = User.current) # rubocop:disable Me Array(permissions).any? { |permission| user.allowed_in_any_project?(permission) } end end + # rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity private @@ -399,8 +416,8 @@ def find_optional_user def get_filter_class(name) filter = report_engine::Filter - .all - .detect { |cls| cls.to_s.demodulize.underscore == name.to_s } + .all + .detect { |cls| cls.to_s.demodulize.underscore == name.to_s } raise ArgumentError.new("Filter with name #{name} does not exist.") unless filter @@ -479,8 +496,8 @@ def force_default? # Prepare the query from the request def prepare_query determine_settings - @query = build_query(session[report_engine.name.underscore.to_sym][:filters], - session[report_engine.name.underscore.to_sym][:groups]) + @query = build_query(session[session_name][:filters], + session[session_name][:groups]) set_cost_type if @unit_id.present? end @@ -489,16 +506,16 @@ def prepare_query # Determine the query settings the current request and save it to # the session. def determine_settings - if force_default? - filters = default_filter_parameters - groups = default_group_parameters - session[report_engine.name.underscore.to_sym].try :delete, :name - else - filters = filter_params - groups = group_params - end - cookie = session[report_engine.name.underscore.to_sym] || {} - session[report_engine.name.underscore.to_sym] = cookie.merge(filters:, groups:) + return reset_settings if force_default? + + cookie = session[session_name] || {} + session[session_name] = cookie.merge(filters: filter_params, groups: group_params) + end + + def reset_settings + session[session_name].try :delete, :name + cookie = session[session_name] || {} + session[session_name] = cookie.merge(filters: default_filter_parameters, groups: default_group_parameters) end ## @@ -524,16 +541,25 @@ def build_query(filters, groups = {}) # Store query in the session def store_query(_query) cookie = {} - cookie[:groups] = @query.group_bys.inject({}) do |h, group| - ((h[:"#{group.type}s"] ||= []) << group.underscore_name.to_sym) && h - end - cookie[:filters] = @query.filters.inject(operators: {}, values: {}) do |h, filter| + cookie[:groups] = cookie_groups + cookie[:filters] = cookie_filters + cookie[:name] = @query.name if @query.name + cookie[:id] = @query.id + session[session_name] = cookie + end + + def cookie_filters + @query.filters.inject(operators: {}, values: {}) do |h, filter| h[:operators][filter.underscore_name.to_sym] = filter.operator.to_s h[:values][filter.underscore_name.to_sym] = filter.values h end - cookie[:name] = @query.name if @query.name - session[report_engine.name.underscore.to_sym] = cookie + end + + def cookie_groups + @query.group_bys.inject({}) do |h, group| + ((h[:"#{group.type}s"] ||= []) << group.underscore_name.to_sym) && h + end end ## @@ -566,4 +592,8 @@ def find_optional_report(query = "1=0") end rescue ActiveRecord::RecordNotFound end + + def session_name + report_engine.name.underscore.to_sym + end end diff --git a/modules/reporting/app/models/cost_query.rb b/modules/reporting/app/models/cost_query.rb index 4d29547b7f6d..5317137f98b3 100644 --- a/modules/reporting/app/models/cost_query.rb +++ b/modules/reporting/app/models/cost_query.rb @@ -85,6 +85,25 @@ def self.exists_in?(project, user) public(project).or(private(project, user)).exists? end + # rubocop:disable Metrics/AbcSize + def self.build_query(project, filters, groups = {}) + query = CostQuery.new(project:) + query.tap do |q| + filters[:operators].each do |filter, operator| + unless filters[:values][filter] == ["<>"] + values = Array(filters[:values][filter]).map { |v| v == "<>" ? nil : v } + q.filter(filter.to_sym, + operator:, + values:) + end + end + end + groups[:columns].try(:reverse_each) { |c| query.column(c) } + groups[:rows].try(:reverse_each) { |r| query.row(r) } + query + end + # rubocop:enable Metrics/AbcSize + def serialize # have to take the reverse group_bys to retain the original order when deserializing self.serialized = { filters: filters.map(&:serialize).reject(&:nil?).sort_by(&:first), diff --git a/modules/reporting/app/models/cost_query/sql_statement.rb b/modules/reporting/app/models/cost_query/sql_statement.rb index 973eaf8d14c7..d43b2dc2920c 100644 --- a/modules/reporting/app/models/cost_query/sql_statement.rb +++ b/modules/reporting/app/models/cost_query/sql_statement.rb @@ -74,6 +74,8 @@ def to_s # cost_type_id | -1 | cost_type_id # type | "TimeEntry" | "CostEntry" # count | 1 | 1 + # start_time | start_time | nil + # time_zone | time_zone | nil # # Also: This _should_ handle joining activities and cost_types, as the logic differs for time_entries # and cost_entries. @@ -102,7 +104,7 @@ def self.unified_entry(model) # # @param [CostQuery::SqlStatement] query The statement to adjust def self.unify_time_entries(query) - query.select :activity_id, :logged_by_id, units: :hours, cost_type_id: -1 + query.select :activity_id, :logged_by_id, :start_time, :time_zone, units: :hours, cost_type_id: -1 query.select cost_type: quoted_label(:caption_labor) end @@ -111,7 +113,7 @@ def self.unify_time_entries(query) # # @param [CostQuery::SqlStatement] query The statement to adjust def self.unify_cost_entries(query) - query.select :units, :cost_type_id, :logged_by_id, activity_id: -1 + query.select :units, :cost_type_id, :logged_by_id, activity_id: -1, start_time: nil, time_zone: nil query.select cost_type: "cost_types.name" query.join CostType end diff --git a/modules/reporting/app/services/cost_query/schedule_export_service.rb b/modules/reporting/app/services/cost_query/schedule_export_service.rb index 991a38334040..2042dcdd7723 100644 --- a/modules/reporting/app/services/cost_query/schedule_export_service.rb +++ b/modules/reporting/app/services/cost_query/schedule_export_service.rb @@ -33,21 +33,24 @@ def initialize(user:) self.user = user end - def call(filter_params:, project:, cost_types:) + def call(format:, query_id:, query_name:, filter_params:, project:, cost_types:) export_storage = ::CostQuery::Export.create - job = schedule_export(export_storage, filter_params, project, cost_types) + job = schedule_export(format, export_storage, query_id, query_name, filter_params, project, cost_types) ServiceResult.success result: job.job_id end private - def schedule_export(export_storage, filter_params, project, cost_types) - ::CostQuery::ExportJob.perform_later(export: export_storage, - user:, - mime_type: :xls, - query: filter_params, - project:, - cost_types:) + def schedule_export(format, export_storage, query_id, query_name, filter_params, project, cost_types) + job = format == :pdf ? ::CostQuery::PDF::ExportJob : ::CostQuery::XLS::ExportJob + job.perform_later(export: export_storage, + user:, + mime_type: format, + query_id:, + query_name:, + query: filter_params, + project:, + cost_types:) end end diff --git a/modules/reporting/app/workers/cost_query/pdf/export_job.rb b/modules/reporting/app/workers/cost_query/pdf/export_job.rb new file mode 100644 index 000000000000..de22ffc5a575 --- /dev/null +++ b/modules/reporting/app/workers/cost_query/pdf/export_job.rb @@ -0,0 +1,44 @@ +require "active_storage/filename" + +class CostQuery::PDF::ExportJob < Exports::ExportJob + self.model = ::CostQuery + + def project + options[:project] + end + + def cost_types + options[:cost_types] + end + + def title + I18n.t("export.timesheet.title") + end + + private + + def export! + handle_export_result(export, pdf_report_result) + end + + def prepare! + CostQuery::Cache.check + self.query = CostQuery.build_query(project, query) + query.name = options[:query_name] + end + + def pdf_report_result + content = generate_timesheet + time = Time.current.strftime("%Y-%m-%d-T-%H-%M-%S") + export_title = "timesheet-#{time}.pdf" + ::Exports::Result.new(format: :pdf, + title: export_title, + mime_type: "application/pdf", + content:) + end + + def generate_timesheet + generator = ::CostQuery::PDF::TimesheetGenerator.new(query, project, cost_types) + generator.generate! + end +end diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb new file mode 100644 index 000000000000..87c5e7ef828d --- /dev/null +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -0,0 +1,336 @@ +class CostQuery::PDF::TimesheetGenerator + include WorkPackage::PDFExport::Common::Common + include WorkPackage::PDFExport::Export::Cover + include WorkPackage::PDFExport::Common::Logo + include WorkPackage::PDFExport::Export::Page + include WorkPackage::PDFExport::Export::Style + + H1_FONT_SIZE = 26 + H1_MARGIN_BOTTOM = 2 + HR_MARGIN_BOTTOM = 16 + TABLE_CELL_FONT_SIZE = 10 + TABLE_CELL_BORDER_COLOR = "BBBBBB" + TABLE_CELL_PADDING = 4 + COMMENT_FONT_COLOR = "636C76" + H2_FONT_SIZE = 20 + H2_MARGIN_BOTTOM = 10 + COLUMN_DATE_WIDTH = 66 + COLUMN_ACTIVITY_WIDTH = 100 + COLUMN_HOURS_WIDTH = 60 + COLUMN_TIME_WIDTH = 80 + COLUMN_WP_WIDTH = 217 + + attr_accessor :pdf + + def initialize(query, project, cost_types) + @query = query + @project = project + @cost_types = cost_types + setup_page! + end + + def heading + query.name || I18n.t(:"export.timesheet.timesheet") + end + + def footer_title + heading + end + + def project + @project + end + + def query + @query + end + + def options + {} + end + + def setup_page! + self.pdf = get_pdf + @page_count = 0 + configure_page_size!(:portrait) + pdf.title = heading + end + + def generate! + render_doc + pdf.render + rescue StandardError => e + Rails.logger.error { "Failed to generate PDF: #{e} #{e.message}}." } + error(I18n.t(:error_pdf_failed_to_export, error: e.message)) + end + + def render_doc + write_cover_page! if with_cover? + write_heading! + write_hr! + write_entries! + write_headers! + write_footers! + end + + def write_entries! + all_entries + .group_by { |r| r.fields["user_id"] } + .each do |user_id, result| + write_table(user_id, result) + end + end + + def all_entries + query + .each_direct_result + .map(&:itself) + .filter { |r| r.fields["type"] == "TimeEntry" } + end + + def build_table_rows(entries) + rows = [table_header_columns] + entries + .group_by { |r| DateTime.parse(r.fields["spent_on"]) } + .sort + .each do |spent_on, lines| + rows.concat(build_table_day_rows(spent_on, lines)) + end + rows + end + + def build_table_day_rows(spent_on, lines) + day_rows = [] + lines.each do |r| + day_rows.push(build_table_row(spent_on, r)) + if r.fields["comments"].present? + day_rows.push(build_table_row_comment(r)) + end + end + day_rows + end + + def build_table_row(spent_on, result_entry) + [ + { content: format_date(spent_on), rowspan: result_entry.fields["comments"].present? ? 2 : 1 }, + wp_subject(result_entry.fields["work_package_id"]), + with_times_column? ? format_spent_on_time(result_entry) : nil, + format_duration(result_entry.fields["units"]), + activity_name(result_entry.fields["activity_id"]) + ].compact + end + + def build_table_row_comment(result_entry) + [{ + content: result_entry.fields["comments"], + text_color: COMMENT_FONT_COLOR, + font_style: :italic, + colspan: table_columns_span + }] + end + + def table_header_columns + [ + { content: I18n.t(:"activerecord.attributes.time_entry.spent_on"), rowspan: 1 }, + I18n.t(:"activerecord.models.work_package"), + with_times_column? ? I18n.t(:"export.timesheet.time") : nil, + I18n.t(:"activerecord.attributes.time_entry.hours"), + I18n.t(:"activerecord.attributes.time_entry.activity") + ].compact + end + + def table_columns_widths + @table_columns_widths ||= with_times_column? ? + [COLUMN_DATE_WIDTH, COLUMN_WP_WIDTH, COLUMN_TIME_WIDTH, COLUMN_HOURS_WIDTH, COLUMN_ACTIVITY_WIDTH] : + [COLUMN_DATE_WIDTH, COLUMN_WP_WIDTH + COLUMN_TIME_WIDTH, COLUMN_HOURS_WIDTH, COLUMN_ACTIVITY_WIDTH] + end + + def table_width + table_columns_widths.sum + end + + def table_columns_span + table_columns_widths.size + end + + # rubocop:disable Metrics/AbcSize + def build_table(rows) + pdf.make_table( + rows, + header: true, + width: table_width, + column_widths: table_columns_widths, + cell_style: { + size: TABLE_CELL_FONT_SIZE, + border_color: TABLE_CELL_BORDER_COLOR, + border_width: 0.5, + borders: %i[top bottom], + padding: [TABLE_CELL_PADDING, TABLE_CELL_PADDING, TABLE_CELL_PADDING + 2, TABLE_CELL_PADDING] + } + ) do |table| + table.columns(0).borders = %i[top bottom left right] + table.columns(-1).style do |c| + c.borders = c.borders + [:right] + end + table.columns(1).style do |c| + if c.colspan > 1 + c.borders = %i[left right bottom] + c.padding = [0, 5, 8, 5] + row_nr = c.row - 1 + values = table.columns(1..-1).rows(row_nr..row_nr) + values.each do |cell| + cell.borders = cell.borders - [:bottom] + end + end + end + table.rows(0).style do |c| + c.borders = c.borders + [:top] + c.font_style = :bold + end + end + end + + def split_group_rows(table_rows) + measure_table = build_table(table_rows) + groups = [] + index = 0 + while index < table_rows.length + row = table_rows[index] + rows = [row] + height = measure_table.row(index).height + index += 1 + if (row[0][:rowspan] || 1) > 1 + rows.push(table_rows[index]) + height += measure_table.row(index).height + index += 1 + end + groups.push({ rows:, height: }) + end + groups + end + + # rubocop:enable Metrics/AbcSize + + def write_table(user_id, entries) + rows = build_table_rows(entries) + # prawn-table does not support splitting a rowspan cell on page break, so we have to merge the first column manually + # for easier handling existing rowspan cells are grouped as one row + grouped_rows = split_group_rows(rows) + # start a new page if the username would be printed alone at the end of the page + pdf.start_new_page if available_space_from_bottom < grouped_rows[0][:height] + grouped_rows[1][:height] + username_height + write_username(user_id) + write_grouped_tables(grouped_rows) + end + + def available_space_from_bottom + margin_bottom = pdf.options[:bottom_margin] + 20 # 20 is the safety margin + pdf.y - margin_bottom + end + + def write_grouped_tables(grouped_rows) + header_row = grouped_rows[0] + current_table = [] + current_table_height = 0 + grouped_rows.each do |grouped_row| + grouped_row_height = grouped_row[:height] + if current_table_height + grouped_row_height >= available_space_from_bottom + write_grouped_row_table(current_table) + pdf.start_new_page + current_table = [header_row] + current_table_height = header_row[:height] + end + current_table.push(grouped_row) + current_table_height += grouped_row_height + end + write_grouped_row_table(current_table) + pdf.move_down(28) + end + + def write_grouped_row_table(grouped_rows) + current_table = [] + merge_first_columns(grouped_rows) + grouped_rows.map! { |row| current_table.concat(row[:rows]) } + build_table(current_table).draw + end + + def merge_first_columns(grouped_rows) + last_row = grouped_rows[1] + index = 2 + while index < grouped_rows.length + grouped_row = grouped_rows[index] + last_row = merge_first_rows(grouped_row, last_row) + index += 1 + end + end + + def merge_first_rows(grouped_row, last_row) + grouped_cell = grouped_row[:rows][0][0] + last_cell = last_row[:rows][0][0] + if grouped_cell[:content] == last_cell[:content] + last_cell[:rowspan] += grouped_cell[:rowspan] + grouped_row[:rows][0].shift + last_row + else + grouped_row + end + end + + def sorted_results + query.each_direct_result.map(&:itself) + end + + def write_hr! + hr_style = styles.cover_header_border + write_horizontal_line(pdf.cursor, hr_style[:height], hr_style[:color]) + pdf.move_down(HR_MARGIN_BOTTOM) + end + + def write_heading! + pdf.formatted_text([{ text: heading, size: H1_FONT_SIZE, style: :bold }]) + pdf.move_down(H1_MARGIN_BOTTOM) + end + + def username_height + 20 + 10 + end + + def write_username(user_id) + pdf.formatted_text([{ text: user_name(user_id), size: H2_FONT_SIZE }]) + pdf.move_down(H2_MARGIN_BOTTOM) + end + + def user_name(user_id) + User.select_for_name.find(user_id).name + end + + def activity_name(activity_id) + TimeEntryActivity.find(activity_id).name + end + + def wp_subject(wp_id) + WorkPackage.find(wp_id).subject + end + + def format_duration(hours) + return "" if hours < 0 + + "#{hours}h" + end + + def format_spent_on_time(_result_entry) + # TODO implement times column + # date = result_entry.fields["spent_on"] + # hours = result_entry.fields["units"] + # start_time = result_entry.fields["start_time"] + # time_zone = result_entry.fields["time_zone"] + "" + end + + def with_times_column? + Setting.allow_tracking_start_and_end_times + end + + def with_cover? + true + end +end diff --git a/modules/reporting/app/workers/cost_query/export_job.rb b/modules/reporting/app/workers/cost_query/xls/export_job.rb similarity index 56% rename from modules/reporting/app/workers/cost_query/export_job.rb rename to modules/reporting/app/workers/cost_query/xls/export_job.rb index baf38d88896b..e9cdda62daab 100644 --- a/modules/reporting/app/workers/cost_query/export_job.rb +++ b/modules/reporting/app/workers/cost_query/xls/export_job.rb @@ -1,12 +1,8 @@ require "active_storage/filename" -class CostQuery::ExportJob < Exports::ExportJob +class CostQuery::XLS::ExportJob < Exports::ExportJob self.model = ::CostQuery - def title - I18n.t("export.cost_reports.title") - end - def project options[:project] end @@ -15,11 +11,15 @@ def cost_types options[:cost_types] end + def title + I18n.t("export.cost_reports.title") + end + private def prepare! CostQuery::Cache.check - self.query = build_query(query) + self.query = CostQuery.build_query(project, query) end def export! @@ -39,24 +39,4 @@ def xls_report_result mime_type: "application/vnd.ms-excel", content:) end - - # rubocop:disable Metrics/AbcSize - def build_query(filters, groups = {}) - query = CostQuery.new(project:) - query.tap do |q| - filters[:operators].each do |filter, operator| - unless filters[:values][filter] == ["<>"] - values = Array(filters[:values][filter]).map { |v| v == "<>" ? nil : v } - q.filter(filter.to_sym, - operator:, - values:) - end - end - end - groups[:columns].try(:reverse_each) { |c| query.column(c) } - groups[:rows].try(:reverse_each) { |r| query.row(r) } - query - end - - # rubocop:enable Metrics/AbcSize end diff --git a/modules/reporting/config/locales/en.yml b/modules/reporting/config/locales/en.yml index f028c008e55a..1b56799a2c02 100644 --- a/modules/reporting/config/locales/en.yml +++ b/modules/reporting/config/locales/en.yml @@ -105,6 +105,11 @@ en: validation_failure_integer: "is not a valid integer" export: + timesheet: + title: "Your PDF timesheet export" + button: "Export PDF timesheet" + timesheet: "Timesheet" + time: "Time" cost_reports: title: "Your Cost Reports XLS export" diff --git a/modules/reporting/spec/workers/cost_query/export_job_spec.rb b/modules/reporting/spec/workers/cost_query/xls/export_job_spec.rb similarity index 99% rename from modules/reporting/spec/workers/cost_query/export_job_spec.rb rename to modules/reporting/spec/workers/cost_query/xls/export_job_spec.rb index 9fd966b4dc1f..9591f99fa78e 100644 --- a/modules/reporting/spec/workers/cost_query/export_job_spec.rb +++ b/modules/reporting/spec/workers/cost_query/xls/export_job_spec.rb @@ -30,7 +30,7 @@ require "spec_helper" -RSpec.describe CostQuery::ExportJob do +RSpec.describe CostQuery::XLS::ExportJob do let(:user) { build_stubbed(:user) } let(:project) { build_stubbed(:project) }