From 9d282d1800945978d29d07a4c19976c1cb40b254 Mon Sep 17 00:00:00 2001 From: zmagod Date: Wed, 18 Apr 2018 16:47:52 +0200 Subject: [PATCH] adds new reports index page [fixes SCI-2124] --- app/assets/javascripts/reports/index.js | 166 --------------- .../reports/reports_datatable.js.erb | 200 ++++++++++++++++++ app/controllers/reports_controller.rb | 81 ++++--- app/datatables/report_datatable.rb | 50 +++++ app/models/project.rb | 12 ++ app/models/report.rb | 7 + app/models/team.rb | 9 + app/models/user_project.rb | 6 + app/models/user_team.rb | 6 + .../views/datatables/datatables_report.rb | 75 +++++++ app/permissions/project.rb | 8 +- app/permissions/team.rb | 4 + app/views/reports/index.html.erb | 130 +++++++----- app/views/shared/_left_menu_bar.html.erb | 2 +- config/initializers/assets.rb | 2 +- config/locales/en.yml | 8 +- config/routes.rb | 9 +- .../20180416114040_add_team_id_to_reports.rb | 13 ++ ...0180417062042_create_datatables_reports.rb | 7 + db/schema.rb | 39 +++- db/views/datatables_reports_v01.sql | 39 ++++ spec/models/report_spec.rb | 3 + spec/models/team_spec.rb | 1 + .../datatables/datatables_report_spec.rb | 5 + 24 files changed, 614 insertions(+), 268 deletions(-) delete mode 100644 app/assets/javascripts/reports/index.js create mode 100644 app/assets/javascripts/reports/reports_datatable.js.erb create mode 100644 app/datatables/report_datatable.rb create mode 100644 app/models/views/datatables/datatables_report.rb create mode 100644 db/migrate/20180416114040_add_team_id_to_reports.rb create mode 100644 db/migrate/20180417062042_create_datatables_reports.rb create mode 100644 db/views/datatables_reports_v01.sql create mode 100644 spec/models/views/datatables/datatables_report_spec.rb diff --git a/app/assets/javascripts/reports/index.js b/app/assets/javascripts/reports/index.js deleted file mode 100644 index e6a8ee9ea..000000000 --- a/app/assets/javascripts/reports/index.js +++ /dev/null @@ -1,166 +0,0 @@ -(function () { - - var newReportModal = null; - var newReportModalBody = null; - var newReportCreateButton = null; - - var deleteReportsModal = null; - var deleteReportsInput = null; - - var newReportButton = null; - var editReportButton = null; - var deleteReportsButton = null; - var checkAll = null; - var allChecks = null; - var allRows = null; - - var checkedReports = []; - - /** - * Initializes page - */ - function init() { - // Initialize selectors - newReportModal = $('#new-report-modal'); - newReportModalBody = newReportModal.find('.modal-body'); - newReportCreateButton = $('#create-new-report-btn'); - deleteReportsModal = $('#delete-reports-modal'); - deleteReportsInput = $('#report-ids'); - newReportButton = $('#new-report-btn'); - editReportButton = $('#edit-report-btn'); - deleteReportsButton = $('#delete-reports-btn'); - checkAll = $('.check-all-reports'); - allChecks = $('.check-report'); - allRows = $('.report-row'); - - initNewReportModal(); - initCheckboxesAndEditing(); - updateButtons(); - initEditReport(); - initDeleteReports(); - } - - /** - * Initialize the new report modal. - */ - function initNewReportModal() { - // TEMPORARY DISABLED - /** - // Remove modal content when modal window is closed. - newReportModal.on("hidden.bs.modal", function () { - newReportModalBody.html(""); - }); - - // Populate modal content when AJAX call is complete - newReportButton - .on("ajax:before", function () { - newReportModal.modal('show'); - }) - .on("ajax:success", function (e, data) { - newReportModalBody.html(data.html); - }); - - // Before redirecting, pass parameters - newReportCreateButton.click(function(event){ - var url = $(this).closest("form").attr("action"); - - event.preventDefault(); - - // Copy the GET params - var val = newReportModalBody.find(".btn-primary.active > input[type='radio']").attr("value"); - url += "/" + val; - - $(location).attr("href", url); - return false; - }); - */ - } - - /** - * Initialize interaction between checkboxes, editing and deleting. - */ - function initCheckboxesAndEditing() { - checkAll.click(function() { - allChecks.prop("checked", this.checked); - checkedReports = []; - if (this.checked) { - _.each(allRows, function(row) { - checkedReports.push($(row).data("id")); - }); - } - - updateButtons(); - }); - allChecks.click(function() { - checkAll.prop("checked", false); - var id = $(this).closest(".report-row").data("id"); - if (this.checked) { - if (_.indexOf(checkedReports, id) === -1) { - checkedReports.push(id); - } - } else { - var idx = _.indexOf(checkedReports, id); - if (idx !== -1) { - checkedReports.splice(idx, 1); - } - } - - updateButtons(); - }); - } - - /** - * Update edit & delete buttons depending on checking of reports. - */ - function updateButtons() { - if (checkedReports.length === 0) { - editReportButton.addClass("disabled"); - deleteReportsButton.addClass("disabled"); - } else if (checkedReports.length === 1) { - editReportButton.removeClass("disabled"); - deleteReportsButton.removeClass("disabled"); - } else { - editReportButton.addClass("disabled"); - deleteReportsButton.removeClass("disabled"); - } - } - - /** - * Initialize the edit report functionality. - */ - function initEditReport() { - editReportButton.click(function(e) { - animateLoading(); - if (checkedReports.length === 1) { - var id = checkedReports[0]; - var row = $(".report-row[data-id='" + id + "']"); - var url = row.data("edit-link"); - - $(location).attr("href", url); - } - - return false; - }); - } - - /** - * Initialize the deleting of reports. - */ - function initDeleteReports() { - deleteReportsButton.click(function(e) { - if (checkedReports.length > 0) { - // Copy the checked IDs into the hidden input - deleteReportsInput.attr("value", "[" + checkedReports + "]"); - - // Show modal - deleteReportsModal.modal("show"); - } - }); - - $("#confirm-delete-reports-btn").click(function(e) { - animateLoading(); - }); - } - - $(document).ready(init); -}()); diff --git a/app/assets/javascripts/reports/reports_datatable.js.erb b/app/assets/javascripts/reports/reports_datatable.js.erb new file mode 100644 index 000000000..3fdc28838 --- /dev/null +++ b/app/assets/javascripts/reports/reports_datatable.js.erb @@ -0,0 +1,200 @@ +(function(global) { + 'use strict'; + + var DATATABLE; + var CHECKED_REPORTS = []; + + function tableDrowCallback() { + checkboxToggleCallback(); + initToggleAllCheckboxes(); + updateButtons(); + } + + function initSelectPicker() { + $('.selectpicker').selectpicker({liveSearch: true}) + .ajaxSelectPicker({ + ajax: { + url: '<%= Rails.application.routes.url_helpers.reports_visible_projects_path %>', + type: 'POST', + dataType: 'json', + data: function () { + return { q: '{{{q}}}' }; + } + }, + locale: { + emptyTitle: 'Nothing selected' + }, + preprocessData: appendSearchResults, + emptyRequest: true, + clearOnEmpty: false, + preserveSelected: false + }).on('change.bs.select', function(el) { + $('#new-report-reports-btn').attr('data-new-report-path', el.target.value); + }).on('loaded.bs.select', function(el) { + $('#new-report-reports-btn').attr('data-new-report-path', el.target.value); + }); + } + + function appendSearchResults(data) { + var items = []; + if(data.hasOwnProperty('projects')){ + $.each(data.projects, function(index, el) { + debugger; + items.push( + { + 'value': el.path, + 'text': el.name, + 'disabled': false + } + ) + }); + } + return items; + } + + function initRedirectToNewReportPage() { + $('#new-report-reports-btn').on('click', function() { + animateSpinner(); + var url = $(this).attr('data-new-report-path'); + $(location).attr('href', url); + }); + } + + function initToggleAllCheckboxes() { + $('input[name="select_all"]').change(function() { + if($(this).is(':checked')) { + $("[data-action='toggle']").prop('checked', true); + addAllItems(); + } else { + $("[data-action='toggle']").prop('checked', false); + removeAllItems(); + } + updateButtons(); + }); + } + + function addAllItems() { + $.each($("[data-action='toggle']"), function(i, el) { + CHECKED_REPORTS.push($(el).attr('data-report-id')); + }) + } + + function removeAllItems() { + CHECKED_REPORTS = []; + } + + function renderCheckboxHTML(data) { + var html; + html = ""; + return html; + } + + function appendEditPathToRow(row, data) { + $(row).addClass('report-row') + .attr('data-edit-path', data['edit']) + .attr('data-id', data['0']); + } + + function checkboxToggleCallback() { + $("[data-action='toggle']").change(function() { + var id = $(this).attr('data-report-id'); + if($(this).is(':checked')) { + CHECKED_REPORTS.push(id); + } else { + var index = CHECKED_REPORTS.indexOf(id); + if(index != -1) { + CHECKED_REPORTS.splice(index, 1); + } + } + updateButtons(); + }); + } + + function updateButtons() { + var editReportButton = $('#edit-report-btn'); + var deleteReportsButton = $('#delete-reports-btn'); + if (CHECKED_REPORTS.length === 0) { + editReportButton.addClass("disabled"); + deleteReportsButton.addClass("disabled"); + } else if (CHECKED_REPORTS.length === 1) { + editReportButton.removeClass("disabled"); + deleteReportsButton.removeClass("disabled"); + } else { + editReportButton.addClass("disabled"); + deleteReportsButton.removeClass("disabled"); + } + } + + // INIT + + function initDatatable() { + var $table = $('#reports-table') + DATATABLE = $table.dataTable({ + 'order': [[2, 'desc']], + 'processing': true, + 'serverSide': true, + 'ajax': $table.data('source'), + 'pagingType': 'simple_numbers', + 'colReorder': { + 'fixedColumnsLeft': 1000000 // Disable reordering + }, + 'columnDefs': [{ + 'targets': 0, + 'searchable': false, + 'orderable': false, + 'className': 'dt-body-center', + 'sWidth': '1%', + 'render': renderCheckboxHTML + }], + 'fnDrawCallback': tableDrowCallback, + 'createdRow': appendEditPathToRow + }); + } + + function initEditReport() { + $('#edit-report-btn').click(function(e) { + e.preventDefault(); + animateSpinner(); + if (CHECKED_REPORTS.length === 1) { + var id = CHECKED_REPORTS[0]; + var row = $(".report-row[data-id='" + id + "']"); + var url = row.attr('data-edit-path'); + $(location).attr('href', url); + } + }); + } + + function initDeleteReports() { + $('#delete-reports-btn').click(function(e) { + if (CHECKED_REPORTS.length > 0) { + $('#report-ids').attr("value", "[" + CHECKED_REPORTS + "]"); + $('#delete-reports-modal').modal("show"); + } + }); + + $("#confirm-delete-reports-btn").click(function(e) { + animateLoading(); + }); + } + + function initNewReportModal() { + $('#new-report-btn').on('click', function() { + $('#new-report-modal').modal('show').promise().done(function() { + initSelectPicker(); + initRedirectToNewReportPage(); + }); + }); + } + + function init() { + $(document).ready(function() { + initDatatable(); + initEditReport(); + initDeleteReports(); + initNewReportModal(); + }); + } + + init(); +})(window); diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index 83cd7850e..bd1768087 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -5,37 +5,11 @@ class ReportsController < ApplicationController # used via target='_blank') protect_from_forgery with: :exception, except: :generate - before_action :load_vars, only: [ - :edit, - :update - ] - before_action :load_vars_nested, only: [ - :index, - :new, - :create, - :edit, - :update, - :generate, - :destroy, - :save_modal, - :project_contents_modal, - :experiment_contents_modal, - :module_contents_modal, - :step_contents_modal, - :result_contents_modal, - :project_contents, - :module_contents, - :step_contents, - :result_contents - ] - - before_action :check_view_permissions, only: :index - before_action :check_manage_permissions, only: %i( + BEFORE_ACTION_METHODS = %i( new create edit update - destroy generate save_modal project_contents_modal @@ -47,12 +21,30 @@ class ReportsController < ApplicationController module_contents step_contents result_contents - ) + ).freeze + + before_action :load_vars, only: %i(edit update) + before_action :load_vars_nested, only: BEFORE_ACTION_METHODS + before_action :load_visible_projects, only: %i(index visible_projects) + + # before_action :check_view_permissions, only: :index + before_action :check_manage_permissions, only: BEFORE_ACTION_METHODS layout 'fluid' # Index showing all reports of a single project - def index + def index; end + + def datatable + respond_to do |format| + format.json do + render json: ::ReportDatatable.new( + view_context, + current_user, + current_team.datatables_reports.visible_by(current_user, current_team) + ) + end + end end # Report grouped by modules @@ -72,6 +64,7 @@ class ReportsController < ApplicationController @report = Report.new(report_params) @report.project = @project @report.user = current_user + @report.team = current_team @report.last_modified_by = current_user if continue && @report.save_with_contents(report_contents) @@ -88,7 +81,7 @@ class ReportsController < ApplicationController ) respond_to do |format| format.json do - render json: { url: project_reports_path(@project) }, status: :ok + render json: { url: reports_path }, status: :ok end end else @@ -155,7 +148,7 @@ class ReportsController < ApplicationController report_ids.each do |report_id| report = Report.find_by_id(report_id) - next unless report.present? + next unless report.present? && can_manage_reports?(current_team) # record an activity Activity.create( type_of: :delete_report, @@ -170,7 +163,7 @@ class ReportsController < ApplicationController report.destroy end - redirect_to project_reports_path(@project) + redirect_to reports_path end # Generation action @@ -434,8 +427,14 @@ class ReportsController < ApplicationController end end + def visible_projects + render json: { projects: @visible_projects }, status: :ok + end + private + VisibleProject = Struct.new(:path, :name) + def load_vars @report = Report.find_by_id(params[:id]) render_404 unless @report @@ -451,11 +450,27 @@ class ReportsController < ApplicationController end def check_manage_permissions - render_403 unless can_manage_reports?(@project) + render_403 unless can_manage_reports?(@project.team) + end + + def load_visible_projects + render_404 unless current_team + projects = current_team.projects.visible_by(current_user) + .where('projects.name ILIKE ?', + "%#{search_params[:q]}%") + .limit(Constants::SEARCH_LIMIT) + .select(:id, :name) + @visible_projects = projects.collect do |project| + VisibleProject.new(new_project_reports_path(project), project.name) + end end def report_params params.require(:report) .permit(:name, :description, :grouped_by, :report_contents) end + + def search_params + params.permit(:q) + end end diff --git a/app/datatables/report_datatable.rb b/app/datatables/report_datatable.rb new file mode 100644 index 000000000..3edd0e934 --- /dev/null +++ b/app/datatables/report_datatable.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class ReportDatatable < CustomDatatable + TABLE_COLUMNS = %w( + Views::Datatables::DatatablesReport.project_name + Views::Datatables::DatatablesReport.name + Views::Datatables::DatatablesReport.created_by + Views::Datatables::DatatablesReport.last_modified_by + Views::Datatables::DatatablesReport.created_at + Views::Datatables::DatatablesReport.updated_at + ).freeze + + def_delegator :@view, :edit_project_report_path + def initialize(view, user, reports) + super(view) + @user = user + @reports = reports + end + + def sortable_columns + @sortable_columns ||= TABLE_COLUMNS + end + + def searchable_columns + @searchable_columns ||= TABLE_COLUMNS + end + + private + + def data + records.map do |record| + { + '0' => record.id, + '1' => record.project_name, + '2' => record.name, + '3' => record.created_by, + '4' => record.last_modified_by, + '5' => I18n.l(record.created_at, format: :full), + '6' => I18n.l(record.updated_at, format: :full), + 'edit' => edit_project_report_path(record.project_id, record.id) + } + end + end + + def get_raw_records + @reports + end + + # ==== Insert 'presenter'-like methods below if necessary +end diff --git a/app/models/project.rb b/app/models/project.rb index 3fcc8b0c3..9c68b096f 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -38,6 +38,18 @@ class Project < ApplicationRecord has_many :reports, inverse_of: :project, dependent: :destroy has_many :report_elements, inverse_of: :project, dependent: :destroy + after_commit do + Scenic.database.refresh_materialized_view(:datatables_reports, + concurrently: true, + cascade: false) + end + + scope :visible_by, -> (user) { + joins(:user_projects).where( + 'user_projects.user_id = ? AND projects.archived = false', user.id + ) + } + def self.search( user, include_archived, diff --git a/app/models/report.rb b/app/models/report.rb index aa44da7a8..777a962f2 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -12,6 +12,7 @@ class Report < ApplicationRecord belongs_to :project, inverse_of: :reports, optional: true belongs_to :user, inverse_of: :reports, optional: true + belongs_to :team, inverse_of: :reports belongs_to :last_modified_by, foreign_key: 'last_modified_by_id', class_name: 'User', @@ -21,6 +22,12 @@ class Report < ApplicationRecord # or many module elements (if grouped by module) has_many :report_elements, inverse_of: :report, dependent: :destroy + after_commit do + Scenic.database.refresh_materialized_view(:datatables_reports, + concurrently: true, + cascade: false) + end + def self.search( user, include_archived, diff --git a/app/models/team.rb b/app/models/team.rb index d06cc5364..56b906894 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -32,6 +32,15 @@ class Team < ApplicationRecord has_many :protocol_keywords, inverse_of: :team, dependent: :destroy has_many :tiny_mce_assets, inverse_of: :team, dependent: :destroy has_many :repositories, dependent: :destroy + has_many :reports, inverse_of: :team, dependent: :destroy + has_many :datatables_reports, + class_name: 'Views::Datatables::DatatablesReport' + + after_commit do + Scenic.database.refresh_materialized_view(:datatables_reports, + concurrently: true, + cascade: false) + end def search_users(query = nil) a_query = "%#{query}%" diff --git a/app/models/user_project.rb b/app/models/user_project.rb index 6dd2879cf..3cac87183 100644 --- a/app/models/user_project.rb +++ b/app/models/user_project.rb @@ -14,6 +14,12 @@ class UserProject < ApplicationRecord before_destroy :destroy_associations + after_commit do + Scenic.database.refresh_materialized_view(:datatables_reports, + concurrently: true, + cascade: false) + end + def role_str I18n.t("user_projects.enums.role.#{role.to_s}") end diff --git a/app/models/user_team.rb b/app/models/user_team.rb index 6850ac154..2bc3fe686 100644 --- a/app/models/user_team.rb +++ b/app/models/user_team.rb @@ -15,6 +15,12 @@ class UserTeam < ApplicationRecord before_destroy :destroy_associations after_create :create_samples_table_state + after_commit do + Scenic.database.refresh_materialized_view(:datatables_reports, + concurrently: true, + cascade: false) + end + def role_str I18n.t("user_teams.enums.role.#{role}") end diff --git a/app/models/views/datatables/datatables_report.rb b/app/models/views/datatables/datatables_report.rb new file mode 100644 index 000000000..82c3120ef --- /dev/null +++ b/app/models/views/datatables/datatables_report.rb @@ -0,0 +1,75 @@ +module Views + module Datatables + class DatatablesReport < ApplicationRecord + belongs_to :team + + # this isn't strictly necessary, but it will prevent + # rails from calling save, which would fail anyway. + def readonly? + true + end + + class << self + def visible_by(user, team) + permitted_by_team = get_permitted_by_team_tokenized + permitted_by_project = get_permitted_by_project_tokenized + if user.is_admin_of_team? team + allowed_ids = for_admin( + user, permitted_by_team, permitted_by_project + ) + else + allowed_ids = for_non_admin( + user, permitted_by_team, permitted_by_project + ) + end + where(id: allowed_ids).where(project_archived: false) + end + + private + + PermissionItem = Struct.new(:report_id, :users_ids) + + def tokenize(items) + items.collect do |item| + PermissionItem.new(item[0], item[1]) + end + end + + def get_permitted_by_team_tokenized + tokenize(pluck(:id, :users_with_team_read_permissions)) + end + + def get_permitted_by_project_tokenized + tokenize(pluck(:id, :users_with_project_read_permissions)) + end + + def get_by_project_item(permitted_by_project, item) + permitted_by_project.select { |el| el.report_id == item.report_id } + .first + end + + def for_admin(user, permitted_by_team, permitted_by_project) + allowed_ids = [] + permitted_by_team.each do |item| + next unless user.id.in? item.users_ids + by_project = get_by_project_item(permitted_by_project, item) + next unless user.id.in? by_project.users_ids + allowed_ids << item.report_id + end + allowed_ids + end + + def for_non_admin(user, permitted_by_team, permitted_by_project) + allowed_ids = [] + permitted_by_team.each do |item| + next unless user.id.in? item.users_ids + by_project = get_by_project_item(permitted_by_project, item) + next unless user.id.in? by_project.users_ids + allowed_ids << item.report_id + end + allowed_ids + end + end + end + end +end diff --git a/app/permissions/project.rb b/app/permissions/project.rb index e3701db6d..74ec68e18 100644 --- a/app/permissions/project.rb +++ b/app/permissions/project.rb @@ -5,8 +5,7 @@ Canaid::Permissions.register_for(Project) do archive_project create_experiments create_comments_in_project - manage_tags - manage_reports) + manage_tags) .each do |perm| can perm do |_, project| project.active? @@ -54,11 +53,6 @@ Canaid::Permissions.register_for(Project) do can :manage_tags do |user, project| user.is_user_or_higher_of_project?(project) end - - # reports: create, delete - can :manage_reports do |user, project| - user.is_technician_or_higher_of_project?(project) - end end Canaid::Permissions.register_for(ProjectComment) do diff --git a/app/permissions/team.rb b/app/permissions/team.rb index a27c56585..c7d288cc9 100644 --- a/app/permissions/team.rb +++ b/app/permissions/team.rb @@ -61,6 +61,10 @@ Canaid::Permissions.register_for(Team) do can :create_repository_columns do |user, team| user.is_normal_user_or_admin_of_team?(team) end + + can :manage_reports do |user, team| + user.is_normal_user_or_admin_of_team?(team) + end end Canaid::Permissions.register_for(Protocol) do diff --git a/app/views/reports/index.html.erb b/app/views/reports/index.html.erb index af60f381e..80b1e7f0b 100644 --- a/app/views/reports/index.html.erb +++ b/app/views/reports/index.html.erb @@ -1,64 +1,54 @@ -<% provide(:head_title, t("projects.reports.index.head_title", project: h(@project.name)).html_safe) %> +<% provide(:head_title, t('projects.reports.index.head_title').html_safe) %> +<%= stylesheet_link_tag 'datatables' %> <%= render partial: "shared/sidebar" %> -<%= render partial: "shared/secondary_navigation" %> -
-
- <% if can_manage_reports?(@project) %> - <%= link_to new_project_reports_path(@project), class: 'btn btn-primary', id: 'new-report-btn', 'data-no-turbolink' => true do %> - - - <% end %> - <%= link_to "", remote: true, class: "btn btn-default", id: "edit-report-btn" do %> - - - <% end %> - <%= link_to "", remote: true, class: "btn btn-default", id: "delete-reports-btn" do %> - - - <% end %> - <% end %> -
- - - - - - - - - - - - - - - <% if @project.reports.count > 0 %> - <% @project.reports.each do |report| %> - - - - - - - - +
+
+
+ <% if can_manage_reports?(current_team) %> + <%= link_to '#', class: 'btn btn-primary', id: 'new-report-btn', 'data-no-turbolink' => true do %> + + + <% end %> + <%= link_to "", remote: true, class: "btn btn-default", id: "edit-report-btn" do %> + + + <% end %> + <%= link_to "", remote: true, class: "btn btn-default", id: "delete-reports-btn" do %> + + <% end %> - <% else %> -
<% end %> - -
<%=t "projects.reports.index.thead_name" %><%=t "projects.reports.index.thead_created_by" %><%=t "projects.reports.index.thead_last_modified_by" %><%=t "projects.reports.index.thead_created_at" %><%=t "projects.reports.index.thead_updated_at" %>
<%= report.name %><%= report.user.full_name %><%= report.last_modified_by ? report.last_modified_by.full_name : report.user.full_name %><%=l report.created_at, format: :full %><%=l report.updated_at, format: :full %>
<%=t "projects.reports.index.no_reports" %>
- +
+ +
+
+
+
+ + + + + + + + + + + + + +
<%=t 'projects.reports.index.thead_project_name' %><%=t 'projects.reports.index.thead_name' %><%=t 'projects.reports.index.thead_created_by' %><%=t 'projects.reports.index.thead_last_modified_by' %><%=t 'projects.reports.index.thead_created_at' %><%=t 'projects.reports.index.thead_updated_at' %>
+
+
+