mirror of
https://github.com/opf/openproject.git
synced 2026-06-13 19:20:00 +00:00
Merge pull request #18583 from opf/feature/61896-overview-of-time-logged-per-day-per-user-in-pdf-timesheet
[#61896] Overview of time logged per day per user in PDF timesheet
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) the OpenProject GmbH
|
||||
@@ -85,6 +87,10 @@ module WorkPackage::PDFExport::Common::Common
|
||||
"<link anchor=\"#{anchor}\">#{caption}</link>"
|
||||
end
|
||||
|
||||
def make_link_href(href, caption)
|
||||
"<link href=\"#{href}\">#{caption}</link>"
|
||||
end
|
||||
|
||||
def link_target_at_current_y(id)
|
||||
pdf_dest = pdf.dest_xyz(0, pdf.y)
|
||||
pdf.add_dest(id.to_s, pdf_dest)
|
||||
@@ -295,7 +301,7 @@ module WorkPackage::PDFExport::Common::Common
|
||||
end
|
||||
|
||||
def make_link_href_cell(href, caption)
|
||||
"<color rgb='#{styles.link_color}'><link href='#{href}'>#{caption}</link></color>"
|
||||
"<color rgb='#{styles.link_color}'>#{make_link_href(href, caption)}</color>"
|
||||
end
|
||||
|
||||
def get_id_column_cell(work_package, value)
|
||||
@@ -310,4 +316,17 @@ module WorkPackage::PDFExport::Common::Common
|
||||
def wp_status_prawn_color(work_package)
|
||||
work_package.status.color&.hexcode&.sub("#", "") || "F0F0F0"
|
||||
end
|
||||
|
||||
def add_pdf_table_anchors(prawn_table)
|
||||
# prawn table does not support anchors, so we have to add them manually,
|
||||
# @see `lib/open_project/patches/prawn_table_cell.rb` for cell_id attribute
|
||||
prawn_table.before_rendering_page do |cells|
|
||||
cells.each do |cell|
|
||||
if cell.respond_to?(:cell_id) && cell.cell_id.present?
|
||||
pdf_dest = @pdf.dest_xyz(@pdf.bounds.absolute_left, @pdf.y + cell.y)
|
||||
@pdf.add_dest(cell.cell_id, pdf_dest)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -103,6 +103,11 @@ module ChronicDuration
|
||||
if %i[hours_only hours_and_minutes].include?(opts[:format])
|
||||
hours = seconds / 3600.0
|
||||
seconds = 0
|
||||
elsif opts[:format] == :hours_colon_minutes
|
||||
hours = (seconds / hour).to_i
|
||||
minutes = (seconds % hour / minute).to_i
|
||||
minutes += 1 if (seconds % hour % minute) > 0
|
||||
seconds = 0
|
||||
elsif seconds >= SECONDS_PER_YEAR && seconds % year < seconds % month
|
||||
years = seconds / year
|
||||
months = seconds % year / month
|
||||
@@ -187,6 +192,19 @@ module ChronicDuration
|
||||
|
||||
minutes = ((hours % 1) * 60).round
|
||||
hours = hours.floor
|
||||
when :hours_colon_minutes
|
||||
dividers = {
|
||||
hours: ":", minutes: "", keep_zero: true
|
||||
}
|
||||
process = lambda do |str|
|
||||
# Pad zeros on minutes
|
||||
divider = ":"
|
||||
result = str.split(divider).each_with_index.map do |n, index|
|
||||
n.rjust(index > 0 ? 2 : 1, "0")
|
||||
end.join(divider)
|
||||
"#{result} h"
|
||||
end
|
||||
joiner = ""
|
||||
when :chrono
|
||||
dividers = {
|
||||
years: ":", months: ":", weeks: ":", days: ":", hours: ":", minutes: ":", seconds: ":", keep_zero: true
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) the OpenProject GmbH
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License version 3.
|
||||
#
|
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
# Copyright (C) 2010-2013 the ChiliProject Team
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
#
|
||||
# See COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
module OpenProject::Patches::PrawnTableCellPatch
|
||||
attr_accessor :cell_id
|
||||
end
|
||||
|
||||
Prawn::Table::Cell.prepend(OpenProject::Patches::PrawnTableCellPatch)
|
||||
@@ -1,3 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) the OpenProject GmbH
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License version 3.
|
||||
#
|
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
# Copyright (C) 2010-2013 the ChiliProject Team
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
#
|
||||
# See COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
require "active_storage/filename"
|
||||
|
||||
class CostQuery::PDF::ExportTimesheetJob < Exports::ExportJob
|
||||
|
||||
@@ -1,3 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) the OpenProject GmbH
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License version 3.
|
||||
#
|
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
# Copyright (C) 2010-2013 the ChiliProject Team
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
#
|
||||
# See COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
class CostQuery::PDF::TimesheetGenerator
|
||||
include WorkPackage::PDFExport::Common::Common
|
||||
include WorkPackage::PDFExport::Common::Attachments
|
||||
@@ -10,18 +40,22 @@ class CostQuery::PDF::TimesheetGenerator
|
||||
H1_FONT_SIZE = 26
|
||||
H1_MARGIN_BOTTOM = 2
|
||||
HR_MARGIN_BOTTOM = 16
|
||||
PARAGRAPH_MARGIN_BOTTOM = 16
|
||||
TABLE_MARGIN_BOTTOM = 28
|
||||
TABLE_CELL_FONT_SIZE = 10
|
||||
TABLE_CELL_BORDER_COLOR = "BBBBBB".freeze
|
||||
TABLE_CELL_BORDER_COLOR = "BBBBBB"
|
||||
TABLE_CELL_PADDING = 4
|
||||
COMMENT_FONT_COLOR = "636C76".freeze
|
||||
COMMENT_FONT_COLOR = "636C76"
|
||||
H2_FONT_SIZE = 20
|
||||
H2_MARGIN_BOTTOM = 10
|
||||
SUM_TABLE_FIRST_COLUMN_WIDTH = 90
|
||||
SUM_TABLE_MAX_USER_COLUMNS = 6
|
||||
|
||||
COLUMN_DATE_WIDTH = 66
|
||||
COLUMN_DATE_WIDTH = 90
|
||||
COLUMN_ACTIVITY_WIDTH = 100
|
||||
COLUMN_HOURS_WIDTH = 60
|
||||
COLUMN_TIME_WIDTH = 110
|
||||
COLUMN_WP_WIDTH = 190
|
||||
COLUMN_WP_WIDTH = 164
|
||||
|
||||
attr_accessor :pdf
|
||||
|
||||
@@ -113,6 +147,13 @@ class CostQuery::PDF::TimesheetGenerator
|
||||
.group_by(&:user)
|
||||
end
|
||||
|
||||
def all_users
|
||||
all_entries
|
||||
.uniq { |entry| entry.user.id }
|
||||
.map(&:user)
|
||||
.sort_by(&:name)
|
||||
end
|
||||
|
||||
def all_entries
|
||||
@all_entries ||= begin
|
||||
ids = query
|
||||
@@ -124,7 +165,7 @@ class CostQuery::PDF::TimesheetGenerator
|
||||
end
|
||||
end
|
||||
|
||||
def build_table_rows(entries)
|
||||
def build_table_rows(entries, wants_sum_row)
|
||||
rows = [table_header_columns]
|
||||
entries
|
||||
.group_by(&:spent_on)
|
||||
@@ -132,7 +173,7 @@ class CostQuery::PDF::TimesheetGenerator
|
||||
.each do |spent_on, lines|
|
||||
rows.concat(build_table_day_rows(spent_on, lines))
|
||||
end
|
||||
rows.push(build_table_row_sum(entries))
|
||||
rows.push(build_table_row_sum(entries)) if wants_sum_row
|
||||
rows
|
||||
end
|
||||
|
||||
@@ -140,29 +181,56 @@ class CostQuery::PDF::TimesheetGenerator
|
||||
day_rows = []
|
||||
entries.each do |entry|
|
||||
day_rows.push(build_table_row(spent_on, entry))
|
||||
if entry.comments.present?
|
||||
day_rows.push(build_table_row_comment(entry))
|
||||
end
|
||||
day_rows.push(build_table_row_comment(entry)) if entry.comments.present?
|
||||
end
|
||||
day_rows.push(build_table_row_day_sum(spent_on, entries)) if entries.length > 1
|
||||
day_rows
|
||||
end
|
||||
|
||||
def build_table_row(spent_on, entry)
|
||||
[
|
||||
{ content: format_date(spent_on), rowspan: entry.comments.present? ? 2 : 1 },
|
||||
entry.work_package&.subject || "",
|
||||
{
|
||||
content: format_date_with_weekday(spent_on),
|
||||
rowspan: (entry.comments.present? ? 2 : 1),
|
||||
cell_id: spent_on_day_id(entry.user, spent_on)
|
||||
},
|
||||
build_table_subject_cell(entry),
|
||||
with_times_column? ? format_spent_on_time(entry) : nil,
|
||||
format_hours(entry.hours || 0),
|
||||
{ content: format_hours(entry.hours || 0), align: :right },
|
||||
entry.activity&.name || ""
|
||||
].compact
|
||||
end
|
||||
|
||||
def build_table_row_day_sum(spent_on, entries)
|
||||
[
|
||||
{ content: format_date_with_weekday(spent_on), rowspan: 1 },
|
||||
"",
|
||||
with_times_column? ? "" : nil,
|
||||
{ content: format_sum_time_entries(entries), font_style: :bold, align: :right },
|
||||
""
|
||||
].compact
|
||||
end
|
||||
|
||||
def spent_on_day_id(user, spent_on)
|
||||
"entry_#{user.id}_#{spent_on}"
|
||||
end
|
||||
|
||||
def build_table_subject_cell(entry)
|
||||
return "" if entry.work_package.nil?
|
||||
|
||||
href = url_helpers.work_package_url(entry.work_package)
|
||||
{
|
||||
content: "#{make_link_href(href, "##{entry.work_package.id}")} #{entry.work_package.subject || ''}",
|
||||
inline_format: true
|
||||
}
|
||||
end
|
||||
|
||||
def build_table_row_sum(entries)
|
||||
[
|
||||
{ content: "", rowspan: 1 },
|
||||
"",
|
||||
with_times_column? ? "" : nil,
|
||||
{ content: format_sum_time_entries(entries), font_style: :bold },
|
||||
{ content: format_sum_time_entries(entries), font_style: :bold, align: :right },
|
||||
""
|
||||
].compact
|
||||
end
|
||||
@@ -189,7 +257,7 @@ class CostQuery::PDF::TimesheetGenerator
|
||||
{ content: TimeEntry.human_attribute_name(:spent_on), rowspan: 1 },
|
||||
I18n.t(:"activerecord.models.work_package"),
|
||||
with_times_column? ? I18n.t(:"export.timesheet.time") : nil,
|
||||
TimeEntry.human_attribute_name(:hours),
|
||||
{ content: TimeEntry.human_attribute_name(:hours), align: :right },
|
||||
TimeEntry.human_attribute_name(:activity)
|
||||
].compact
|
||||
end
|
||||
@@ -204,7 +272,7 @@ class CostQuery::PDF::TimesheetGenerator
|
||||
end
|
||||
end
|
||||
|
||||
def build_table(rows, has_sum_row)
|
||||
def build_table(rows, has_sum_row, with_anchors)
|
||||
pdf.make_table(
|
||||
rows,
|
||||
header: true,
|
||||
@@ -223,6 +291,7 @@ class CostQuery::PDF::TimesheetGenerator
|
||||
adjust_borders_spanned_column(table)
|
||||
adjust_border_header_row(table)
|
||||
adjust_border_sum_row(table) if has_sum_row
|
||||
add_pdf_table_anchors(table) if with_anchors
|
||||
end
|
||||
end
|
||||
|
||||
@@ -263,8 +332,8 @@ class CostQuery::PDF::TimesheetGenerator
|
||||
end
|
||||
end
|
||||
|
||||
def split_group_rows(table_rows)
|
||||
measure_table = build_table(table_rows, true)
|
||||
def split_group_rows(table_rows, has_sum_row)
|
||||
measure_table = build_table(table_rows, has_sum_row, false)
|
||||
groups = []
|
||||
index = 0
|
||||
while index < table_rows.length
|
||||
@@ -283,14 +352,23 @@ class CostQuery::PDF::TimesheetGenerator
|
||||
end
|
||||
|
||||
def write_table(user, entries)
|
||||
rows = build_table_rows(entries)
|
||||
wants_sum_row = more_than_one_day?(entries)
|
||||
rows = build_table_rows(entries, wants_sum_row)
|
||||
# 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)
|
||||
grouped_rows = split_group_rows(rows, wants_sum_row)
|
||||
# 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
|
||||
pdf.start_new_page if available_space_from_bottom < grouped_table_height(grouped_rows)
|
||||
write_username(user)
|
||||
write_grouped_tables(grouped_rows)
|
||||
write_grouped_tables(grouped_rows, wants_sum_row)
|
||||
end
|
||||
|
||||
def grouped_table_height(grouped_rows)
|
||||
grouped_rows[0][:height] + grouped_rows[1][:height] + username_height
|
||||
end
|
||||
|
||||
def more_than_one_day?(entries)
|
||||
entries.map(&:spent_on).uniq.length > 1
|
||||
end
|
||||
|
||||
def available_space_from_bottom
|
||||
@@ -298,7 +376,7 @@ class CostQuery::PDF::TimesheetGenerator
|
||||
pdf.y - margin_bottom
|
||||
end
|
||||
|
||||
def write_grouped_tables(grouped_rows)
|
||||
def write_grouped_tables(grouped_rows, has_sum_row)
|
||||
header_row = grouped_rows[0]
|
||||
current_table = []
|
||||
current_table_height = 0
|
||||
@@ -313,15 +391,15 @@ class CostQuery::PDF::TimesheetGenerator
|
||||
current_table.push(grouped_row)
|
||||
current_table_height += grouped_row_height
|
||||
end
|
||||
write_grouped_row_table(current_table, true)
|
||||
pdf.move_down(28)
|
||||
write_grouped_row_table(current_table, has_sum_row)
|
||||
pdf.move_down(TABLE_MARGIN_BOTTOM)
|
||||
end
|
||||
|
||||
def write_grouped_row_table(grouped_rows, has_sum_row)
|
||||
current_table = []
|
||||
merge_first_columns(grouped_rows)
|
||||
grouped_rows.map! { |row| current_table.concat(row[:rows]) }
|
||||
build_table(current_table, has_sum_row).draw
|
||||
build_table(current_table, has_sum_row, true).draw
|
||||
end
|
||||
|
||||
def merge_first_columns(grouped_rows)
|
||||
@@ -357,21 +435,102 @@ class CostQuery::PDF::TimesheetGenerator
|
||||
end
|
||||
|
||||
def write_overview!
|
||||
groups = grouped_by_user_entries
|
||||
return if groups.size <= 1
|
||||
users = all_users
|
||||
return if users.size <= 1
|
||||
|
||||
write_heading!
|
||||
write_hr!
|
||||
write_overview_table!(overview_table_rows(groups))
|
||||
|
||||
write_total_sum!
|
||||
write_sums_tables!(users)
|
||||
start_new_page_if_needed
|
||||
end
|
||||
|
||||
def write_overview_table!(rows)
|
||||
pdf.make_table(
|
||||
def write_sums_tables!(users)
|
||||
start_date, end_date = all_entries.map(&:spent_on).minmax
|
||||
num_groups = (users.length / SUM_TABLE_MAX_USER_COLUMNS.to_f).ceil
|
||||
users
|
||||
.in_groups(num_groups, false) do |users_chunk|
|
||||
write_sum_table!(users_chunk.compact, start_date, end_date)
|
||||
pdf.move_down(TABLE_MARGIN_BOTTOM)
|
||||
end
|
||||
end
|
||||
|
||||
def write_total_sum!
|
||||
pdf.formatted_text(
|
||||
[
|
||||
{ text: "#{I18n.t('export.timesheet.total_sum')}: ", size: TABLE_CELL_FONT_SIZE },
|
||||
{ text: format_sum_time_entries(all_entries), size: TABLE_CELL_FONT_SIZE, styles: [:bold] }
|
||||
]
|
||||
)
|
||||
pdf.move_down(PARAGRAPH_MARGIN_BOTTOM)
|
||||
end
|
||||
|
||||
def build_sum_table_footer_rows(users, users_sums)
|
||||
[
|
||||
[{ content: I18n.t("export.timesheet.sums_hours"), font_style: :bold }] + users.map do |user|
|
||||
{ content: format_hours(users_sums[user.id]), align: :right, font_style: :bold }
|
||||
end
|
||||
]
|
||||
end
|
||||
|
||||
def build_sum_table_row(date, users, users_sums)
|
||||
row = []
|
||||
users.each do |user|
|
||||
sum = calc_sum_for_user_on_day(user, date)
|
||||
users_sums[user.id] = (users_sums[user.id] || 0) + sum
|
||||
row.push(sum > 0 ? build_sum_table_sum_cell(sum, user, date) : "")
|
||||
end
|
||||
return nil unless row.any? { |column| !column.empty? }
|
||||
|
||||
[format_date_with_weekday(date)] + row
|
||||
end
|
||||
|
||||
def build_sum_table_sum_cell(sum, user, date)
|
||||
{
|
||||
content: make_link_anchor(spent_on_day_id(user, date), format_hours(sum)),
|
||||
align: :right, inline_format: true
|
||||
}
|
||||
end
|
||||
|
||||
def format_date_with_weekday(date)
|
||||
"#{format_date(date)}, #{I18n.l(date.to_date, format: '%a')}"
|
||||
end
|
||||
|
||||
def calc_sum_for_user_on_day(user, date)
|
||||
all_entries.select { |entry| entry.user == user && entry.spent_on == date }.sum(&:hours)
|
||||
end
|
||||
|
||||
def build_sum_table_rows(users, start_date, end_date)
|
||||
rows = []
|
||||
users_sums = {}
|
||||
(start_date..end_date).each do |date|
|
||||
row = build_sum_table_row(date, users, users_sums)
|
||||
rows.push(row) unless row.nil?
|
||||
end
|
||||
rows = rows + build_sum_table_footer_rows(users, users_sums) unless rows.empty?
|
||||
rows
|
||||
end
|
||||
|
||||
def build_sum_table_header_row(users)
|
||||
[{ content: TimeEntry.human_attribute_name(:spent_on), font_style: :bold }] +
|
||||
users.map do |user|
|
||||
{
|
||||
content: make_link_anchor("user_#{user.id}", user.name),
|
||||
inline_format: true, font_style: :bold
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def write_sum_table!(users, start_date, end_date)
|
||||
rows = build_sum_table_rows(users, start_date, end_date)
|
||||
return if rows.empty?
|
||||
|
||||
rows.unshift(build_sum_table_header_row(users))
|
||||
pdf.table(
|
||||
rows,
|
||||
header: true,
|
||||
width: pdf.bounds.width,
|
||||
column_widths: sum_table_column_widths(users),
|
||||
cell_style: {
|
||||
size: TABLE_CELL_FONT_SIZE,
|
||||
border_color: TABLE_CELL_BORDER_COLOR,
|
||||
@@ -379,9 +538,13 @@ class CostQuery::PDF::TimesheetGenerator
|
||||
borders: %i[top bottom left right],
|
||||
padding: [TABLE_CELL_PADDING, TABLE_CELL_PADDING, TABLE_CELL_PADDING + 2, TABLE_CELL_PADDING]
|
||||
}
|
||||
) do |table|
|
||||
adjust_overview_border_sum_row(table)
|
||||
end.draw
|
||||
)
|
||||
end
|
||||
|
||||
def sum_table_column_widths(users)
|
||||
[SUM_TABLE_FIRST_COLUMN_WIDTH].concat(
|
||||
[(pdf.bounds.width - SUM_TABLE_FIRST_COLUMN_WIDTH) / users.length] * users.length
|
||||
)
|
||||
end
|
||||
|
||||
def adjust_overview_border_sum_row(table)
|
||||
@@ -390,22 +553,6 @@ class CostQuery::PDF::TimesheetGenerator
|
||||
row.columns(-1).style { |c| c.borders = c.borders - [:left] }
|
||||
end
|
||||
|
||||
def overview_table_rows(groups)
|
||||
rows = [
|
||||
[
|
||||
{ content: TimeEntry.human_attribute_name(:user), font_style: :bold },
|
||||
{ content: I18n.t("export.timesheet.sum_hours"), font_style: :bold }
|
||||
]
|
||||
]
|
||||
groups.each do |user, entries|
|
||||
rows.push([user.name, format_sum_time_entries(entries)])
|
||||
end
|
||||
|
||||
total = groups.sum { |_user, entries| entries.sum(&:hours) }
|
||||
rows.push(["", { content: format_hours(total), font_style: :bold }])
|
||||
rows
|
||||
end
|
||||
|
||||
def write_heading!
|
||||
pdf.formatted_text([{ text: heading, size: H1_FONT_SIZE, style: :bold }])
|
||||
pdf.move_down(H1_MARGIN_BOTTOM)
|
||||
@@ -416,6 +563,7 @@ class CostQuery::PDF::TimesheetGenerator
|
||||
end
|
||||
|
||||
def write_username(user)
|
||||
link_target_at_current_y("user_#{user.id}")
|
||||
pdf.formatted_text([{ text: user.name, size: H2_FONT_SIZE }])
|
||||
pdf.move_down(H2_MARGIN_BOTTOM)
|
||||
end
|
||||
@@ -431,7 +579,7 @@ class CostQuery::PDF::TimesheetGenerator
|
||||
def format_hours(hours)
|
||||
return "" if hours.nil? || hours < 0
|
||||
|
||||
DurationConverter.output(hours)
|
||||
DurationConverter.output(hours, format: :hours_colon_minutes)
|
||||
end
|
||||
|
||||
def format_spent_on_time(entry)
|
||||
|
||||
@@ -112,7 +112,8 @@ en:
|
||||
button: "Export PDF timesheet"
|
||||
timesheet: "Timesheet"
|
||||
time: "Time"
|
||||
sum_hours: Sum
|
||||
sums_hours: Sums
|
||||
total_sum: Total sum
|
||||
cost_reports:
|
||||
title: "Your Cost Reports XLS export"
|
||||
start_time: "Start time"
|
||||
|
||||
@@ -47,6 +47,7 @@ RSpec.describe CostQuery::PDF::TimesheetGenerator do
|
||||
user: user,
|
||||
spent_on: Date.new(2024, 12, 0o1),
|
||||
start_time: 8 * 60,
|
||||
hours: 28,
|
||||
time_zone: "UTC")
|
||||
end
|
||||
let(:time_entry) do
|
||||
@@ -124,12 +125,14 @@ RSpec.describe CostQuery::PDF::TimesheetGenerator do
|
||||
end
|
||||
|
||||
def expected_entry_row(t_entry, with_times_column)
|
||||
[format_date(t_entry.spent_on)].concat(expected_entry_columns(t_entry, with_times_column))
|
||||
result = [generator.format_date_with_weekday(t_entry.spent_on)]
|
||||
result.concat(expected_entry_columns(t_entry, with_times_column))
|
||||
end
|
||||
|
||||
def expected_entry_columns(t_entry, with_times_column)
|
||||
time_column = generator.format_spent_on_time(t_entry)
|
||||
[
|
||||
"##{t_entry.work_package.id} ",
|
||||
t_entry.work_package&.subject || "",
|
||||
with_times_column && time_column.present? ? time_column : nil,
|
||||
generator.format_hours(t_entry.hours),
|
||||
@@ -139,21 +142,35 @@ RSpec.describe CostQuery::PDF::TimesheetGenerator do
|
||||
end
|
||||
|
||||
def expected_overview_page_content
|
||||
result = [query.name,
|
||||
TimeEntry.human_attribute_name(:user), I18n.t("export.timesheet.sum_hours")]
|
||||
time_entries.group_by(&:user).each do |user, entries|
|
||||
result << user.name
|
||||
result << generator.format_hours(entries.sum(&:hours))
|
||||
end
|
||||
result << generator.format_hours(time_entries.sum(&:hours))
|
||||
[
|
||||
query.name,
|
||||
"#{I18n.t('export.timesheet.total_sum')}: ",
|
||||
generator.format_hours(time_entries.sum(&:hours)),
|
||||
|
||||
TimeEntry.human_attribute_name(:spent_on),
|
||||
user.name,
|
||||
time_entry_user.name,
|
||||
|
||||
generator.format_date_with_weekday(user_time_entry.spent_on),
|
||||
generator.format_hours(user_time_entry.hours), generator.format_hours(time_entry.hours + other_time_entry.hours),
|
||||
|
||||
generator.format_date_with_weekday(time_entry_with_comment.spent_on),
|
||||
generator.format_hours(time_entry_with_comment.hours),
|
||||
|
||||
generator.format_date_with_weekday(time_entry_without_time.spent_on),
|
||||
generator.format_hours(time_entry_without_time.hours),
|
||||
|
||||
I18n.t("export.timesheet.sums_hours"),
|
||||
generator.format_hours(time_entries.select { |entry| entry.user == user }.sum(&:hours)),
|
||||
generator.format_hours(time_entries.select { |entry| entry.user == time_entry_user }.sum(&:hours))
|
||||
]
|
||||
end
|
||||
|
||||
def expected_first_user_table(with_times_column)
|
||||
[
|
||||
user.name,
|
||||
*expected_table_header(with_times_column),
|
||||
*expected_entry_row(user_time_entry, with_times_column),
|
||||
*expected_sum_row(user, with_times_column)
|
||||
*expected_entry_row(user_time_entry, with_times_column)
|
||||
]
|
||||
end
|
||||
|
||||
@@ -161,9 +178,10 @@ RSpec.describe CostQuery::PDF::TimesheetGenerator do
|
||||
[
|
||||
time_entry.user.name,
|
||||
*expected_table_header(with_times_column),
|
||||
format_date(time_entry.spent_on), # merged date rows
|
||||
generator.format_date_with_weekday(time_entry.spent_on), # merged date rows
|
||||
*expected_entry_columns(time_entry, with_times_column),
|
||||
*expected_entry_columns(other_time_entry, with_times_column),
|
||||
generator.format_hours(time_entry.hours + other_time_entry.hours),
|
||||
*expected_entry_row(time_entry_with_comment, with_times_column),
|
||||
*expected_entry_row(time_entry_without_time, with_times_column),
|
||||
*expected_sum_row(time_entry.user, with_times_column)
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
require "rspec_helper"
|
||||
require "chronic_duration"
|
||||
|
||||
INACCURATE_FORMATS = %i[days_and_hours hours_only hours_and_minutes].freeze
|
||||
INACCURATE_FORMATS = %i[days_and_hours hours_only hours_and_minutes hours_colon_minutes].freeze
|
||||
|
||||
RSpec.describe ChronicDuration do
|
||||
describe ".parse" do
|
||||
@@ -152,6 +152,7 @@ RSpec.describe ChronicDuration do
|
||||
days_and_hours: "0.02h",
|
||||
hours_only: "0.02h",
|
||||
hours_and_minutes: "1m",
|
||||
hours_colon_minutes: "0:02 h",
|
||||
chrono: "1:20"
|
||||
},
|
||||
(60 + 20.51) =>
|
||||
@@ -163,6 +164,7 @@ RSpec.describe ChronicDuration do
|
||||
days_and_hours: "0.02h",
|
||||
hours_only: "0.02h",
|
||||
hours_and_minutes: "1m",
|
||||
hours_colon_minutes: "0:02 h",
|
||||
chrono: "1:20.51"
|
||||
},
|
||||
(60 + 20.51928) =>
|
||||
@@ -174,6 +176,7 @@ RSpec.describe ChronicDuration do
|
||||
days_and_hours: "0.02h",
|
||||
hours_only: "0.02h",
|
||||
hours_and_minutes: "1m",
|
||||
hours_colon_minutes: "0:02 h",
|
||||
chrono: "1:20.51928"
|
||||
},
|
||||
((4 * 3600) + 60 + 1) =>
|
||||
@@ -185,6 +188,7 @@ RSpec.describe ChronicDuration do
|
||||
days_and_hours: "4.02h",
|
||||
hours_only: "4.02h",
|
||||
hours_and_minutes: "4h 1m",
|
||||
hours_colon_minutes: "4:02 h",
|
||||
chrono: "4:01:01"
|
||||
},
|
||||
((2 * 3600) + (20 * 60)) =>
|
||||
@@ -196,6 +200,7 @@ RSpec.describe ChronicDuration do
|
||||
days_and_hours: "2.33h",
|
||||
hours_only: "2.33h",
|
||||
hours_and_minutes: "2h 20m",
|
||||
hours_colon_minutes: "2:20 h",
|
||||
chrono: "2:20:00"
|
||||
},
|
||||
((8 * 24 * 3600) + (3 * 3600) + (30 * 60)) =>
|
||||
@@ -207,6 +212,7 @@ RSpec.describe ChronicDuration do
|
||||
days_and_hours: "8d 3.5h",
|
||||
hours_only: "195.5h",
|
||||
hours_and_minutes: "195h 30m",
|
||||
hours_colon_minutes: "195:30 h",
|
||||
chrono: "8:03:30:00"
|
||||
},
|
||||
((6 * 30 * 24 * 3600) + (24 * 3600)) =>
|
||||
@@ -218,6 +224,7 @@ RSpec.describe ChronicDuration do
|
||||
days_and_hours: "181d 0h",
|
||||
hours_only: "4344h",
|
||||
hours_and_minutes: "4344h",
|
||||
hours_colon_minutes: "4344:00 h",
|
||||
chrono: "6:01:00:00:00" # Yuck. FIXME
|
||||
},
|
||||
((365.25 * 24 * 3600) + (24 * 3600)).to_i =>
|
||||
@@ -229,6 +236,7 @@ RSpec.describe ChronicDuration do
|
||||
days_and_hours: "366d 0h",
|
||||
hours_only: "8790h",
|
||||
hours_and_minutes: "8790h",
|
||||
hours_colon_minutes: "8790:00 h",
|
||||
chrono: "1:00:01:00:00:00"
|
||||
},
|
||||
((3 * 365.25 * 24 * 3600) + (24 * 3600)).to_i =>
|
||||
@@ -240,6 +248,7 @@ RSpec.describe ChronicDuration do
|
||||
days_and_hours: "1096d 0h",
|
||||
hours_only: "26322h",
|
||||
hours_and_minutes: "26322h",
|
||||
hours_colon_minutes: "26322:00 h",
|
||||
chrono: "3:00:01:00:00:00"
|
||||
},
|
||||
((6 * 365.25 * 24 * 3600) + (3 * 3600)).to_i =>
|
||||
@@ -251,6 +260,7 @@ RSpec.describe ChronicDuration do
|
||||
days_and_hours: "2191d 3h",
|
||||
hours_only: "52599h",
|
||||
hours_and_minutes: "52599h",
|
||||
hours_colon_minutes: "52599:00 h",
|
||||
chrono: "6:00:00:03:00:00"
|
||||
},
|
||||
(3600 * 24 * 30 * 18) =>
|
||||
@@ -262,6 +272,7 @@ RSpec.describe ChronicDuration do
|
||||
days_and_hours: "540d 0h",
|
||||
hours_only: "12960h",
|
||||
hours_and_minutes: "12960h",
|
||||
hours_colon_minutes: "12960:00 h",
|
||||
chrono: "18:00:00:00:00"
|
||||
}
|
||||
}
|
||||
@@ -282,6 +293,7 @@ RSpec.describe ChronicDuration do
|
||||
default: "0 secs",
|
||||
long: "0 seconds",
|
||||
days_and_hours: "0h",
|
||||
hours_colon_minutes: "0:00 h",
|
||||
chrono: "0"
|
||||
},
|
||||
false =>
|
||||
@@ -291,6 +303,7 @@ RSpec.describe ChronicDuration do
|
||||
default: nil,
|
||||
long: nil,
|
||||
days_and_hours: "0h",
|
||||
hours_colon_minutes: "0:00 h",
|
||||
chrono: "0"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user