diff --git a/modules/reporting/app/helpers/reporting_helper.rb b/modules/reporting/app/helpers/reporting_helper.rb index 35f5ffe14e62..822fe88b68da 100644 --- a/modules/reporting/app/helpers/reporting_helper.rb +++ b/modules/reporting/app/helpers/reporting_helper.rb @@ -141,6 +141,28 @@ def field_representation_map(key, value) end # rubocop:enable Metrics/AbcSize + def spent_on_time_representation(start_timestamp, hours) + return "" if start_timestamp.nil? + + result = format_time(start_timestamp, include_date: false) + return result if hours.nil? || hours.zero? + + end_timestamp = start_timestamp + hours.hours + days_between_suffix = days_between_representation(start_timestamp, end_timestamp) + "#{result} - #{format_time(end_timestamp, include_date: false)}#{days_between_suffix}" + end + + def days_between_representation(start_timestamp, end_timestamp) + return "" if start_timestamp.nil? || end_timestamp.nil? + + days_between = (end_timestamp.to_date - start_timestamp.to_date).to_i + if days_between.positive? + " (+#{WorkPackage::Exports::Formatters::Days.new(nil) + .format_value(days_between, nil) + .delete(' ')})" + end + end + def custom_value(cf_identifier, value) cf_id = cf_identifier.gsub("custom_field", "").to_i diff --git a/modules/reporting/app/models/cost_query/result.rb b/modules/reporting/app/models/cost_query/result.rb index 32b5d4bb38e5..a04cfa067cf7 100644 --- a/modules/reporting/app/models/cost_query/result.rb +++ b/modules/reporting/app/models/cost_query/result.rb @@ -44,6 +44,7 @@ class Base < Report::Result::Base class DirectResult < Report::Result::DirectResult include BaseAdditions + def display_costs self["display_costs"].to_i end @@ -51,10 +52,32 @@ def display_costs def real_costs (self["real_costs"] || 0).to_d if display_costs? # FIXME: default value here? end + + def start_timestamp + return nil if self["start_time"].blank? || self["time_zone"].blank? || self["spent_on"].blank? + + timestamp(Date.parse(self["spent_on"]), self["start_time"], self["time_zone"]) + end + + def end_timestamp + return nil if self["units"].blank? + + start = start_timestamp + return nil if start.nil? + + start + self["units"].to_i.hours + end + + private + + def timestamp(date, time, time_zone) + ActiveSupport::TimeZone[time_zone].local(date.year, date.month, date.day, time / 60, time % 60) + end end class WrappedResult < Report::Result::WrappedResult include BaseAdditions + def display_costs (sum_for :display_costs) >= 1 ? 1 : 0 end 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/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index a383441f723c..9c28f498f4d7 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -4,6 +4,7 @@ class CostQuery::PDF::TimesheetGenerator include WorkPackage::PDFExport::Common::Logo include WorkPackage::PDFExport::Export::Page include WorkPackage::PDFExport::Export::Style + include ReportingHelper H1_FONT_SIZE = 26 H1_MARGIN_BOTTOM = 2 @@ -347,26 +348,7 @@ def format_hours(hours) end def format_spent_on_time(entry) - start_timestamp = entry.start_timestamp - return "" if start_timestamp.nil? - - result = format_time(start_timestamp, include_date: false) - end_timestamp = entry.end_timestamp - return result if end_timestamp.nil? - - days_between_suffix = format_days_between(start_timestamp, end_timestamp) - "#{result} - #{format_time(end_timestamp, include_date: false)}#{days_between_suffix}" - end - - def format_days_between(start_timestamp, end_timestamp) - days_between = (end_timestamp.to_date - start_timestamp.to_date).to_i - if days_between.positive? - " (+#{days_formatter.format_value(days_between, nil).delete(' ')})" - end - end - - def days_formatter - @days_formatter ||= WorkPackage::Exports::Formatters::Days.new(nil) + spent_on_time_representation(entry.start_timestamp, entry.hours) end def with_times_column? diff --git a/modules/reporting/config/locales/en.yml b/modules/reporting/config/locales/en.yml index 1b56799a2c02..6141e7c3f89f 100644 --- a/modules/reporting/config/locales/en.yml +++ b/modules/reporting/config/locales/en.yml @@ -112,6 +112,8 @@ en: time: "Time" cost_reports: title: "Your Cost Reports XLS export" + start_time: "Start time" + end_time: "End time" reporting: group_by: diff --git a/modules/reporting/lib/open_project/reporting/cost_entry_xls_table.rb b/modules/reporting/lib/open_project/reporting/cost_entry_xls_table.rb index 6cdd22eedb73..5f8217613936 100644 --- a/modules/reporting/lib/open_project/reporting/cost_entry_xls_table.rb +++ b/modules/reporting/lib/open_project/reporting/cost_entry_xls_table.rb @@ -52,22 +52,48 @@ def format_columns number_format: currency_format) end + def cost_fields_columns(result) + cost_entry_attributes + .map { |field| show_field field, result.fields[field.to_s] } + end + + def cost_main_times_columns(result) + [ + format_time(result.start_timestamp, include_date: false), + cost_main_end_time_column(result.start_timestamp, result.end_timestamp) + ] + end + + def cost_main_end_time_column(start_timestamp, end_timestamp) + return "" if start_timestamp.nil? || end_timestamp.nil? + + days_between = (end_timestamp.to_date - start_timestamp.to_date).to_i + day_prefix = days_between > 1 ? "#{end_timestamp.to_date.iso8601} " : "" + "#{day_prefix}#{format_time(end_timestamp, include_date: false)}" + end + + def cost_main_columns(result) + main_cols = [show_field(:spent_on, result.fields[:spent_on.to_s])] + main_cols.concat cost_main_times_columns(result) if with_times_column? + main_cols + end + def cost_row(result) current_cost_type_id = result.fields["cost_type_id"].to_i - cost_entry_attributes - .map { |field| show_field field, result.fields[field.to_s] } - .concat( - [ - show_result(result, current_cost_type_id), # units - cost_type_label(current_cost_type_id, @cost_type), # cost type - show_result(result, 0) # costs/currency - ] + cost_main_columns(result) + .concat(cost_fields_columns(result)) + .push( + + show_result(result, current_cost_type_id), # units + cost_type_label(current_cost_type_id, @cost_type), # cost type + show_result(result, 0) # costs/currency + ) end def build_footer - footer = [""] * cost_entry_attributes.size + footer = [""] * (cost_entry_attributes.size + main_headers.size) footer += if show_result(query, 0) == show_result(query) multiple_unit_types_footer else @@ -84,14 +110,22 @@ def multiple_unit_types_footer ["", "", show_result(query)] end + def main_headers + main = [label_for(:spent_on)] + if with_times_column? + main.push I18n.t(:"export.cost_reports.start_time"), I18n.t(:"export.cost_reports.end_time") + end + main + end + def headers - cost_entry_attributes - .map { |field| label_for(field) } - .concat([CostEntry.human_attribute_name(:units), CostType.model_name.human, CostEntry.human_attribute_name(:costs)]) + main_headers + .concat(cost_entry_attributes.map { |field| label_for(field) }) + .push(CostEntry.human_attribute_name(:units), CostType.model_name.human, CostEntry.human_attribute_name(:costs)) end def cost_entry_attributes - %i[spent_on user_id activity_id work_package_id comments project_id] + %i[user_id activity_id work_package_id comments project_id] end # Returns the results of the query sorted by date the time was spent on and by id @@ -103,4 +137,12 @@ def sorted_results .sort .flat_map { |_, date_results| date_results.sort_by { |r| r.fields["id"] } } end + + def labour_query? + @unit_id == -1 + end + + def with_times_column? + Setting.allow_tracking_start_and_end_times && labour_query? + end end diff --git a/modules/reporting/lib/widget/table/entry_table.rb b/modules/reporting/lib/widget/table/entry_table.rb index 6cab895d47e6..cf5aeaa84702 100644 --- a/modules/reporting/lib/widget/table/entry_table.rb +++ b/modules/reporting/lib/widget/table/entry_table.rb @@ -27,7 +27,9 @@ #++ class Widget::Table::EntryTable < Widget::Table - FIELDS = %i[spent_on user_id activity_id work_package_id comments logged_by_id project_id].freeze + include ReportingHelper + + FIELDS = %i[user_id activity_id work_package_id comments logged_by_id project_id].freeze def render content = content_tag :div, class: "generic-table--container -with-footer" do @@ -47,6 +49,7 @@ def render def colgroup content_tag :colgroup do + concat content_tag(:col, "") FIELDS.each do concat content_tag(:col, "") end @@ -56,33 +59,31 @@ def colgroup end end + def head_column_field(field) + head_column(label_for(field)) + end + + def head_column(label) + content_tag(:th) do + content_tag(:div, class: "generic-table--sort-header-outer") do + content_tag(:div, class: "generic-table--sort-header") do + content_tag(:span, label) + end + end + end + end + # rubocop:disable Metrics/AbcSize def head content_tag :thead do content_tag :tr do + concat head_column_field(:spent_on) + concat head_column(I18n.t("label_time")) if with_times_column? FIELDS.map do |field| - concat content_tag(:th) { - content_tag(:div, class: "generic-table--sort-header-outer") do - content_tag(:div, class: "generic-table--sort-header") do - content_tag(:span, label_for(field)) - end - end - } + concat head_column_field(field) end - concat content_tag(:th) { - content_tag(:div, class: "generic-table--sort-header-outer") do - content_tag(:div, class: "generic-table--sort-header") do - content_tag(:span, cost_type.try(:unit_plural) || I18n.t(:units)) - end - end - } - concat content_tag(:th) { - content_tag(:div, class: "generic-table--sort-header-outer") do - content_tag(:div, class: "generic-table--sort-header") do - content_tag(:span, CostEntry.human_attribute_name(:costs)) - end - end - } + concat head_column(cost_type.try(:unit_plural) || I18n.t(:units)) + concat head_column(CostEntry.human_attribute_name(:costs)) hit = false @subject.each_direct_result do |result| next if hit @@ -101,15 +102,16 @@ def head def foot content_tag :tfoot do content_tag :tr do + main_columns = with_times_column? ? 2 : 1 if show_result(@subject, 0) == show_result(@subject) - concat content_tag(:td, "", colspan: FIELDS.size + 1) + concat content_tag(:td, "", colspan: FIELDS.size + main_columns + 1) concat content_tag(:td) { concat content_tag(:div, show_result(@subject), class: "result generic-table--footer-outer") } else - concat content_tag(:td, "", colspan: FIELDS.size) + concat content_tag(:td, "", colspan: FIELDS.size + main_columns) concat content_tag(:td) { concat content_tag(:div, show_result(@subject), @@ -126,15 +128,25 @@ def foot end end + def body_column_field(field, result) + content_tag(:td, show_field(field, result.fields[field.to_s]), + "raw-data": raw_field(field, result.fields[field.to_s]), + class: "left") + end + def body content_tag :tbody do rows = "".html_safe @subject.each_direct_result do |result| rows << (content_tag(:tr) do + concat body_column_field(:spent_on, result) + if with_times_column? + concat content_tag :td, spent_on_time_representation(result.start_timestamp, result["units"].to_i), + class: "start_time right", + "raw-data": result.start_timestamp.to_s + end FIELDS.each do |field| - concat content_tag(:td, show_field(field, result.fields[field.to_s]), - "raw-data": raw_field(field, result.fields[field.to_s]), - class: "left") + concat body_column_field(field, result) end concat content_tag :td, show_result(result, result.fields["cost_type_id"].to_i), class: "units right", @@ -177,4 +189,13 @@ def icons(result) end # rubocop:enable Metrics/AbcSize + + def labour_query? + cost_type_filter = @subject.filters.detect { |f| f.is_a?(CostQuery::Filter::CostTypeId) } + cost_type_filter&.values&.first.to_i == -1 + end + + def with_times_column? + Setting.allow_tracking_start_and_end_times && labour_query? + end end