diff --git a/app/components/row_component.rb b/app/components/row_component.rb index 00ae80c09939..d9ae018c3877 100644 --- a/app/components/row_component.rb +++ b/app/components/row_component.rb @@ -89,8 +89,12 @@ def scheme :default end - def checkmark(condition) - if condition + def checkmark(condition, primerized: false) + return unless condition + + if primerized + render(Primer::Beta::Octicon.new(icon: :check)) + else helpers.op_icon "icon icon-checkmark" end end diff --git a/app/models/project.rb b/app/models/project.rb index 8d01e969e0c0..ef5be9571b39 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -85,6 +85,8 @@ class Project < ApplicationRecord }, dependent: :destroy has_many :time_entries, dependent: :delete_all has_many :time_entry_activities_projects, dependent: :delete_all + has_many :cost_types_projects, dependent: :delete_all + has_many :cost_types, through: :cost_types_projects has_many :queries, dependent: :destroy has_many :news, -> { includes(:author) }, dependent: :destroy has_many :categories, -> { order("#{Category.table_name}.name") }, dependent: :delete_all @@ -193,7 +195,8 @@ class Project < ApplicationRecord scope :templated, -> { where(templated: true) } scopes :activated_time_activity, - :visible_with_activated_time_activity + :visible_with_activated_time_activity, + :available_cost_types enum :status_code, { on_track: 0, diff --git a/config/initializers/menus.rb b/config/initializers/menus.rb index c1dff6e8bf4d..e63541cb1820 100644 --- a/config/initializers/menus.rb +++ b/config/initializers/menus.rb @@ -784,15 +784,21 @@ }, versions: { caption: :label_version_plural }, repository: { caption: :label_repository }, - time_entry_activities: { caption: :enumeration_activities }, + time_and_costs: { + caption: :"cost_types.settings.time_and_costs", + controller: "/projects/settings/time_entry_activities" + }, storage: { caption: :label_required_disk_storage } } project_menu_items.each do |key, options| menu.push :"settings_#{key}", - { controller: "/projects/settings/#{key}", action: "show" }.merge(options.slice(:action)), + { + controller: options[:controller] || "/projects/settings/#{key}", + action: options[:action] || "show" + }, parent: :settings, - **options.except(:action) + **options.except(:action, :controller) end end diff --git a/db/migrate/20260513120000_add_project_scoping_to_cost_types.rb b/db/migrate/20260513120000_add_project_scoping_to_cost_types.rb new file mode 100644 index 000000000000..c640faa34f61 --- /dev/null +++ b/db/migrate/20260513120000_add_project_scoping_to_cost_types.rb @@ -0,0 +1,43 @@ +# 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 AddProjectScopingToCostTypes < ActiveRecord::Migration[8.0] + def change + add_column :cost_types, :is_for_all, :boolean, default: true, null: false + + create_table :cost_types_projects do |t| + t.references :cost_type, null: false, foreign_key: true + t.references :project, null: false, foreign_key: true + t.timestamps + end + + add_index :cost_types_projects, %i[cost_type_id project_id], unique: true + end +end diff --git a/modules/costs/app/components/admin/cost_types/cost_type_projects/new_cost_type_projects_form_modal_component.html.erb b/modules/costs/app/components/admin/cost_types/cost_type_projects/new_cost_type_projects_form_modal_component.html.erb new file mode 100644 index 000000000000..3cd24755d16b --- /dev/null +++ b/modules/costs/app/components/admin/cost_types/cost_type_projects/new_cost_type_projects_form_modal_component.html.erb @@ -0,0 +1,56 @@ +<%#-- 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. +++#%> + +<%= + component_wrapper do + primer_form_with( + model: @cost_type_project_mapping, + url:, + data: { turbo: true }, + method: :post + ) do |form| + concat( + render( + Primer::Alpha::Dialog::Body.new( + id: dialog_body_id, test_selector: dialog_body_id, aria: { label: title }, + classes: "Overlay-body_autocomplete_height" + ) + ) do + render(::CostTypes::CostTypeProjects::CostTypeMappingForm.new(form, project_mapping: @cost_type_project_mapping)) + end + ) + + concat( + render(Primer::Alpha::Dialog::Footer.new(show_divider: false)) do + concat(render(Primer::Beta::Button.new(data: { "close-dialog-id": dialog_id })) { cancel_button_text }) + concat(render(Primer::Beta::Button.new(scheme: :primary, type: :submit)) { submit_button_text }) + end + ) + end + end +%> diff --git a/modules/costs/app/components/admin/cost_types/cost_type_projects/new_cost_type_projects_form_modal_component.rb b/modules/costs/app/components/admin/cost_types/cost_type_projects/new_cost_type_projects_form_modal_component.rb new file mode 100644 index 000000000000..7ccc1b5c68a2 --- /dev/null +++ b/modules/costs/app/components/admin/cost_types/cost_type_projects/new_cost_type_projects_form_modal_component.rb @@ -0,0 +1,71 @@ +# 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 Admin + module CostTypes + module CostTypeProjects + class NewCostTypeProjectsFormModalComponent < ApplicationComponent + include OpTurbo::Streamable + + DIALOG_ID = "new-cost-type-projects-modal" + DIALOG_BODY_ID = "new-cost-type-projects-modal-body" + + def initialize(cost_type_project_mapping:, cost_type:, **) + @cost_type_project_mapping = cost_type_project_mapping + @cost_type = cost_type + super(@cost_type_project_mapping, **) + end + + private + + def url + url_helpers.admin_cost_type_projects_path(@cost_type) + end + + def dialog_id = DIALOG_ID + def dialog_body_id = DIALOG_BODY_ID + + attr_reader :cost_type_project_mapping, :cost_type + + def title + I18n.t(:label_add_projects) + end + + def cancel_button_text + I18n.t("button_cancel") + end + + def submit_button_text + I18n.t("button_add") + end + end + end + end +end diff --git a/modules/costs/app/components/admin/cost_types/cost_type_projects/new_cost_type_projects_modal_component.html.erb b/modules/costs/app/components/admin/cost_types/cost_type_projects/new_cost_type_projects_modal_component.html.erb new file mode 100644 index 000000000000..6c45cd543eba --- /dev/null +++ b/modules/costs/app/components/admin/cost_types/cost_type_projects/new_cost_type_projects_modal_component.html.erb @@ -0,0 +1,46 @@ +<%#-- 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. +++#%> + +<%= + render( + Primer::Alpha::Dialog.new( + id: dialog_id, + title:, + test_selector: dialog_id, + size: :large + ) + ) do |dialog| + dialog.with_header( + show_divider: false, + visually_hide_title: false, + variant: :large + ) + + render(form_modal_component) + end +%> diff --git a/modules/costs/app/components/admin/cost_types/cost_type_projects/new_cost_type_projects_modal_component.rb b/modules/costs/app/components/admin/cost_types/cost_type_projects/new_cost_type_projects_modal_component.rb new file mode 100644 index 000000000000..ed8bcb69b850 --- /dev/null +++ b/modules/costs/app/components/admin/cost_types/cost_type_projects/new_cost_type_projects_modal_component.rb @@ -0,0 +1,66 @@ +# 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 Admin + module CostTypes + module CostTypeProjects + class NewCostTypeProjectsModalComponent < ApplicationComponent + include OpTurbo::Streamable + + def initialize(cost_type_project_mapping:, cost_type:, **) + @cost_type_project_mapping = cost_type_project_mapping + @cost_type = cost_type + super(@cost_type_project_mapping, **) + end + + def render? + !cost_type.is_for_all? + end + + private + + attr_reader :cost_type_project_mapping, :cost_type + + def dialog_id = NewCostTypeProjectsFormModalComponent::DIALOG_ID + def dialog_body_id = NewCostTypeProjectsFormModalComponent::DIALOG_BODY_ID + + def title + I18n.t(:label_add_projects) + end + + def form_modal_component + Admin::CostTypes::CostTypeProjects::NewCostTypeProjectsFormModalComponent.new( + cost_type_project_mapping:, cost_type: + ) + end + end + end + end +end diff --git a/modules/costs/app/components/admin/cost_types/cost_type_projects/row_component.html.erb b/modules/costs/app/components/admin/cost_types/cost_type_projects/row_component.html.erb new file mode 100644 index 000000000000..c492d478b6c6 --- /dev/null +++ b/modules/costs/app/components/admin/cost_types/cost_type_projects/row_component.html.erb @@ -0,0 +1,41 @@ +<%#-- 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. +++#%> + +<%= component_wrapper(tag: "tr", class: row_css_class, data: { turbo: true }) do %> + <% columns.each do |column| %> + + <%= column_value(column) %> + + <% end %> + + + <% button_links.each do |link| %> + <%= link %> + <% end %> + +<% end %> diff --git a/modules/costs/app/components/admin/cost_types/cost_type_projects/row_component.rb b/modules/costs/app/components/admin/cost_types/cost_type_projects/row_component.rb new file mode 100644 index 000000000000..6fdf303b13a1 --- /dev/null +++ b/modules/costs/app/components/admin/cost_types/cost_type_projects/row_component.rb @@ -0,0 +1,67 @@ +# 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 Admin + module CostTypes + module CostTypeProjects + class RowComponent < ::Projects::RowComponent + include OpTurbo::Streamable + + def wrapper_uniq_by + "project-#{project.id}" + end + + def more_menu_items + @more_menu_items ||= [more_menu_detach_project].compact + end + + private + + def more_menu_detach_project + { + scheme: :default, + icon: nil, + label: I18n.t("projects.settings.project_custom_fields.actions.remove_from_project"), + href: detach_from_project_url, + data: { turbo_method: :delete } + } + end + + def detach_from_project_url + url_helpers.admin_cost_type_project_path( + cost_type_id: @table.params[:cost_type].id, + cost_types_project: { project_id: project.id }, + page: current_page + ) + end + end + end + end +end diff --git a/modules/costs/app/components/admin/cost_types/cost_type_projects/table_component.rb b/modules/costs/app/components/admin/cost_types/cost_type_projects/table_component.rb new file mode 100644 index 000000000000..cae9780d8420 --- /dev/null +++ b/modules/costs/app/components/admin/cost_types/cost_type_projects/table_component.rb @@ -0,0 +1,51 @@ +# 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 Admin + module CostTypes + module CostTypeProjects + class TableComponent < ::Projects::TableComponent + include ::Projects::Concerns::TableComponent::StreamablePaginationLinksConstraints + + def columns + @columns ||= query.selects.grep_v(Queries::Selects::NotExistingSelect) + end + + def sortable? + false + end + + def use_quick_action_table_headers? + false + end + end + end + end +end diff --git a/modules/costs/app/components/admin/cost_types/details_component.html.erb b/modules/costs/app/components/admin/cost_types/details_component.html.erb new file mode 100644 index 000000000000..91345a544047 --- /dev/null +++ b/modules/costs/app/components/admin/cost_types/details_component.html.erb @@ -0,0 +1,47 @@ +<%#-- 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. +++#%> + +<%= settings_primer_form_with( + model: cost_type, + url: form_url, + method: form_method + ) do |f| %> + <%= helpers.error_messages_for "cost_type" %> + <%= helpers.back_url_hidden_field_tag %> + + <%= render(Admin::CostTypes::DetailsForm.new(f)) %> + + <%= render( + Primer::Beta::Button.new( + scheme: :primary, + type: :submit, + mt: 3, + test_selector: "op-cost-type-form--submit" + ) + ) { t(:button_save) } %> +<% end %> diff --git a/modules/costs/app/components/admin/cost_types/details_component.rb b/modules/costs/app/components/admin/cost_types/details_component.rb new file mode 100644 index 000000000000..03d91a355d5f --- /dev/null +++ b/modules/costs/app/components/admin/cost_types/details_component.rb @@ -0,0 +1,49 @@ +# 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 Admin + module CostTypes + class DetailsComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpPrimer::FormHelpers + + alias_method :cost_type, :model + + def form_url + cost_type.persisted? ? admin_cost_type_path(cost_type) : admin_cost_types_path + end + + def form_method + cost_type.persisted? ? :put : :post + end + end + end +end diff --git a/modules/costs/app/components/admin/cost_types/edit_form_header_component.html.erb b/modules/costs/app/components/admin/cost_types/edit_form_header_component.html.erb new file mode 100644 index 000000000000..49fcdfbf6a19 --- /dev/null +++ b/modules/costs/app/components/admin/cost_types/edit_form_header_component.html.erb @@ -0,0 +1,36 @@ +<%#-- 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. +++#%> + +<%= + render(Primer::OpenProject::PageHeader.new(test_selector: "cost-types--page-header")) do |header| + header.with_title { page_title } + header.with_breadcrumbs(breadcrumbs_items, selected_item_font_weight: :normal) + + helpers.render_tab_header_nav(header, tabs, test_selector: :cost_type_detail_header) if @cost_type.persisted? + end +%> diff --git a/modules/costs/app/components/admin/cost_types/edit_form_header_component.rb b/modules/costs/app/components/admin/cost_types/edit_form_header_component.rb new file mode 100644 index 000000000000..ac07de48eb95 --- /dev/null +++ b/modules/costs/app/components/admin/cost_types/edit_form_header_component.rb @@ -0,0 +1,75 @@ +# 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 Admin + module CostTypes + class EditFormHeaderComponent < ApplicationComponent + def initialize(cost_type:, selected:, **) + @cost_type = cost_type + @selected = selected + super(cost_type, **) + end + + def tabs + [ + { + name: "edit", + path: edit_admin_cost_type_path(@cost_type), + label: t(:label_details) + }, + { + name: "rates", + path: rates_admin_cost_type_path(@cost_type), + label: t("cost_types.admin.rates.title") + }, + { + name: "cost_type_projects", + path: admin_cost_type_projects_path(@cost_type), + label: t(:label_project_plural) + } + ] + end + + private + + def page_title + @cost_type.persisted? ? @cost_type.name : "#{t(:label_new)} #{::CostType.model_name.human}" + end + + def breadcrumbs_items + [ + { href: admin_index_path, text: t(:label_administration) }, + { href: admin_cost_types_path, text: t(:label_cost_type_plural) }, + page_title + ] + end + end + end +end diff --git a/modules/costs/app/components/admin/cost_types/rates_component.html.erb b/modules/costs/app/components/admin/cost_types/rates_component.html.erb new file mode 100644 index 000000000000..5c662461a4db --- /dev/null +++ b/modules/costs/app/components/admin/cost_types/rates_component.html.erb @@ -0,0 +1,87 @@ +<%#-- 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. +++#%> + +<%= settings_primer_form_with( + model: cost_type, + url: admin_cost_type_path(cost_type), + method: :put, + data: { controller: "subform" } + ) do |f| %> + <%= helpers.error_messages_for "cost_type" %> + +
+
+ + + + + + + + + + + + + + + <%= render partial: "admin/cost_types/rate", + object: CostRate.new(valid_from: Date.current), + locals: { templated: true } %> + <% sorted_rates.each_with_index do |rate, index| %> + <%= render partial: "admin/cost_types/rate", object: rate, locals: { index: index } %> + <% end %> + +
<%= Rate.human_attribute_name(:valid_from) %><%= Rate.model_name.human %>
+
+
+ +
+ + <%= render( + Primer::Beta::IconButton.new( + icon: :plus, + id: "add_rate_date", + scheme: :invisible, + type: :button, + classes: "add-row-button wp-inline-create--add-link", + aria: { label: t(:button_add_rate) }, + data: { action: "subform#addRow" } + ) + ) %> +
+ + <%= render( + Primer::Beta::Button.new( + scheme: :primary, + type: :submit, + mt: 3, + test_selector: "op-cost-type-rates--submit" + ) + ) { t(:button_save) } %> +<% end %> diff --git a/modules/costs/app/components/admin/cost_types/rates_component.rb b/modules/costs/app/components/admin/cost_types/rates_component.rb new file mode 100644 index 000000000000..b3923e3fe3bb --- /dev/null +++ b/modules/costs/app/components/admin/cost_types/rates_component.rb @@ -0,0 +1,55 @@ +# 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 Admin + module CostTypes + class RatesComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpPrimer::FormHelpers + + alias_method :cost_type, :model + + def sorted_rates + cost_type.rates.sort do |a, b| + if !a.valid? && !b.valid? + 0 + elsif !a.valid? + -1 + elsif !b.valid? + 1 + else + b.valid_from <=> a.valid_from + end + end + end + end + end +end diff --git a/modules/costs/app/components/admin/cost_types/row_component.rb b/modules/costs/app/components/admin/cost_types/row_component.rb new file mode 100644 index 000000000000..5f951ac69ae6 --- /dev/null +++ b/modules/costs/app/components/admin/cost_types/row_component.rb @@ -0,0 +1,113 @@ +# 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 Admin + module CostTypes + class RowComponent < ::RowComponent + delegate :unit, :unit_plural, to: :cost_type + + def cost_type + model + end + + def name + helpers.link_to(cost_type.name, helpers.edit_admin_cost_type_path(cost_type)) + end + + def current_rate + helpers.to_currency_with_empty(cost_type.rate_at(Date.current)) + end + + def default + checkmark(cost_type.is_default?, primerized: true) + end + + def active_projects + if cost_type.is_for_all? + I18n.t("settings.project_attributes.label_for_all_projects") + else + count = Project.active.joins(:cost_types_projects) + .where(cost_types_projects: { cost_type_id: cost_type.id }) + .count + count.zero? ? I18n.t(:label_none) : count + end + end + + def deleted_at + helpers.format_date(cost_type.deleted_at) if cost_type.deleted_at + end + + def column_css_class(column) + column == :current_rate ? "currency" : super + end + + def row_css_id + "cost_type_#{cost_type.id}" + end + + def button_links + table.locked? ? [restore_link] : [lock_link] + end + + def lock_link + render( + Primer::Beta::IconButton.new( + icon: :lock, + scheme: :invisible, + tag: :a, + href: helpers.admin_cost_type_path(cost_type), + "aria-label": t(:button_lock), + tooltip_direction: :w, + test_selector: "op-admin-cost-type-#{cost_type.id}-lock", + data: { + turbo_method: :delete, + turbo_confirm: t(:text_are_you_sure) + } + ) + ) + end + + def restore_link + render( + Primer::Beta::IconButton.new( + icon: :unlock, + scheme: :invisible, + tag: :a, + href: helpers.restore_admin_cost_type_path(cost_type), + "aria-label": t(:button_unlock), + tooltip_direction: :w, + test_selector: "op-admin-cost-type-#{cost_type.id}-restore", + data: { turbo_method: :patch } + ) + ) + end + end + end +end diff --git a/modules/costs/app/components/admin/cost_types/table_component.rb b/modules/costs/app/components/admin/cost_types/table_component.rb new file mode 100644 index 000000000000..3c317aa76fc1 --- /dev/null +++ b/modules/costs/app/components/admin/cost_types/table_component.rb @@ -0,0 +1,79 @@ +# 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 Admin + module CostTypes + class TableComponent < ::TableComponent + options status: "active" + + def columns + if status == "locked" + %i[name unit unit_plural current_rate deleted_at] + else + %i[name unit unit_plural current_rate active_projects default] + end + end + + def sortable_columns + %i[name unit unit_plural] + end + + def initial_sort + %i[name asc] + end + + def headers + columns.map { |column| [column.to_s, { caption: header_caption(column) }] } + end + + def sortable? + true + end + + def locked? + status == "locked" + end + + private + + def header_caption(column) + case column + when :name then CostType.model_name.human + when :unit then CostType.human_attribute_name(:unit) + when :unit_plural then CostType.human_attribute_name(:unit_plural) + when :current_rate then CostType.human_attribute_name(:current_rate) + when :active_projects then I18n.t("cost_types.admin.columns.active_projects") + when :default then I18n.t(:caption_default) + when :deleted_at then I18n.t(:caption_locked_on) + end + end + end + end +end diff --git a/modules/costs/app/components/projects/settings/cost_types/index_component.html.erb b/modules/costs/app/components/projects/settings/cost_types/index_component.html.erb new file mode 100644 index 000000000000..fa52650792dc --- /dev/null +++ b/modules/costs/app/components/projects/settings/cost_types/index_component.html.erb @@ -0,0 +1,98 @@ +<%#-- 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. +++#%> + +<% if empty? %> + <%= + render Primer::Beta::Blankslate.new(border: true) do |c| + c.with_visual_icon(icon: :checklist) + c.with_heading(tag: :h2).with_content(I18n.t("cost_types.settings.cost_types.heading")) + c.with_description { I18n.t("cost_types.admin.cost_type_projects.no_projects.description") } + end + %> +<% else %> + <%= render(Primer::Beta::BorderBox.new(test_selector: "op-project-settings-cost-types")) do |box| %> + <% box.with_header do %> + <%= render(Primer::Beta::Text.new(font_weight: :bold)) { CostType.model_name.human(count: 2) } %> + <% end %> + + <% cost_types.each do |cost_type| %> + <% box.with_row do %> + <%= + flex_layout( + align_items: :center, justify_content: :space_between, + classes: "op-project-cost-type", + data: { test_selector: "project-cost-type-#{cost_type.id}" } + ) do |row| + row.with_column(flex: 1) do + if User.current.admin? + render(Primer::Beta::Link.new(font_weight: :bold, + href: edit_admin_cost_type_path(cost_type))) { cost_type.name } + else + render(Primer::Beta::Text.new) { cost_type.name } + end + end + + row.with_column(py: 1, mr: 2) do + concat( + render( + Primer::Alpha::ToggleSwitch.new( + src: toggle_path(cost_type), + csrf_token: form_authenticity_token, + data: toggle_data_attributes(cost_type), + checked: toggle_checked?(cost_type), + enabled: !toggle_disabled?(cost_type), + size: :small, + status_label_position: :start, + classes: "op-primer-adjustments__toggle-switch--hidden-loading-indicator" + ) + ) + ) + + if toggle_disabled?(cost_type) + concat( + content_tag(:template, id: unique_hovercard_id(cost_type)) do + flex_layout( + classes: "op-project-cost-type--popover", + data: { test_selector: "op-project-cost-type--hover-card-#{cost_type.id}" } + ) do |hover_card| + hover_card.with_column do + render(Primer::Beta::Text.new) do + t("cost_types.settings.cost_types.is_for_all_hint") + end + end + end + end + ) + end + end + end + %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/modules/costs/app/components/projects/settings/cost_types/index_component.rb b/modules/costs/app/components/projects/settings/cost_types/index_component.rb new file mode 100644 index 000000000000..76c0d3097917 --- /dev/null +++ b/modules/costs/app/components/projects/settings/cost_types/index_component.rb @@ -0,0 +1,85 @@ +# 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 Projects + module Settings + module CostTypes + class IndexComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + + def initialize(project:, cost_types:) + super() + @project = project + @cost_types = cost_types + end + + attr_reader :project, :cost_types + + delegate :empty?, to: :cost_types + + def enabled_cost_type_ids + @enabled_cost_type_ids ||= ::CostTypesProject.where(project_id: project.id).pluck(:cost_type_id).to_set + end + + def enabled?(cost_type) + enabled_cost_type_ids.include?(cost_type.id) + end + + def toggle_path(cost_type) + toggle_project_settings_cost_type_path(project, cost_type) + end + + def toggle_checked?(cost_type) + cost_type.is_for_all? || enabled?(cost_type) + end + + def toggle_disabled?(cost_type) + cost_type.is_for_all? + end + + def toggle_data_attributes(cost_type) + { + test_selector: "toggle-project-cost-type-mapping-#{cost_type.id}" + }.tap do |data| + if toggle_disabled?(cost_type) + data[:hover_card_trigger_target] = "trigger" + data[:hover_card_popover_template_id] = unique_hovercard_id(cost_type) + end + end + end + + def unique_hovercard_id(cost_type) + "project-cost-type-#{cost_type.id}-disabled-reason" + end + end + end + end +end diff --git a/modules/costs/app/components/projects/settings/time_and_costs/page_header_component.html.erb b/modules/costs/app/components/projects/settings/time_and_costs/page_header_component.html.erb new file mode 100644 index 000000000000..8a35672b0f4c --- /dev/null +++ b/modules/costs/app/components/projects/settings/time_and_costs/page_header_component.html.erb @@ -0,0 +1,42 @@ +<%#-- 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. +++#%> + +<%= + render(Primer::OpenProject::PageHeader.new(test_selector: "time-and-costs--page-header")) do |header| + header.with_title { page_title } + header.with_breadcrumbs(breadcrumbs_items, selected_item_font_weight: :normal) + + header.with_tab_nav(label: I18n.t("cost_types.settings.time_and_costs"), test_selector: :time_and_costs_tabs) do |tab_nav| + tabs.each do |tab| + tab_nav.with_tab(selected: tab[:name] == @selected, href: tab[:path]) do |t| + t.with_text { tab[:label] } + end + end + end + end +%> diff --git a/modules/costs/app/components/projects/settings/time_and_costs/page_header_component.rb b/modules/costs/app/components/projects/settings/time_and_costs/page_header_component.rb new file mode 100644 index 000000000000..325aaeb09358 --- /dev/null +++ b/modules/costs/app/components/projects/settings/time_and_costs/page_header_component.rb @@ -0,0 +1,70 @@ +# 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 Projects + module Settings + module TimeAndCosts + class PageHeaderComponent < ApplicationComponent + def initialize(project:, selected:, **) + @project = project + @selected = selected + super(project, **) + end + + def tabs + [ + { + name: :time_entry_activities, + path: helpers.project_settings_time_entry_activities_path(@project), + label: I18n.t(:enumeration_activities) + }, + { + name: :cost_types, + path: helpers.project_settings_cost_types_path(@project), + label: I18n.t("cost_types.settings.cost_types.heading") + } + ] + end + + def page_title + I18n.t("cost_types.settings.time_and_costs") + end + + def breadcrumbs_items + [ + { href: helpers.project_overview_path(@project.id), text: @project.name }, + { href: helpers.project_settings_general_path(@project.id), text: I18n.t(:label_project_settings) }, + page_title + ] + end + end + end + end +end diff --git a/modules/costs/app/contracts/cost_types/cost_type_projects/base_contract.rb b/modules/costs/app/contracts/cost_types/cost_type_projects/base_contract.rb new file mode 100644 index 000000000000..993250ea1ae7 --- /dev/null +++ b/modules/costs/app/contracts/cost_types/cost_type_projects/base_contract.rb @@ -0,0 +1,72 @@ +# 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 CostTypes + module CostTypeProjects + class BaseContract < ::ModelContract + include UnchangedProject + + MANAGE_PERMISSION = :manage_project_activities + + attribute :project_id + attribute :cost_type_id + + validate :validate_manage_allowed_in_source_project + validate :validate_manage_allowed_in_destination_project + validate :not_for_all + + def validate_manage_allowed_in_source_project + if model.new_record? + errors.add :base, :error_unauthorized unless user.allowed_in_project?(MANAGE_PERMISSION, model.project) + return + end + + with_unchanged_project_id do + errors.add :base, :error_unauthorized unless user.allowed_in_project?(MANAGE_PERMISSION, model.project) + end + end + + def validate_manage_allowed_in_destination_project + return if model.new_record? + return unless model.project_id_changed? + + unless user.allowed_in_project?(MANAGE_PERMISSION, model.project) + errors.add :base, :error_unauthorized + end + end + + def not_for_all + return if model.cost_type.nil? || !model.cost_type.is_for_all? + + errors.add :cost_type_id, :is_for_all_cannot_modify + end + end + end +end diff --git a/modules/costs/app/contracts/cost_types/cost_type_projects/update_contract.rb b/modules/costs/app/contracts/cost_types/cost_type_projects/update_contract.rb new file mode 100644 index 000000000000..dbe035b2a839 --- /dev/null +++ b/modules/costs/app/contracts/cost_types/cost_type_projects/update_contract.rb @@ -0,0 +1,36 @@ +# 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 CostTypes + module CostTypeProjects + class UpdateContract < BaseContract + end + end +end diff --git a/modules/costs/app/controllers/admin/cost_types/cost_type_projects_controller.rb b/modules/costs/app/controllers/admin/cost_types/cost_type_projects_controller.rb new file mode 100644 index 000000000000..7440388748be --- /dev/null +++ b/modules/costs/app/controllers/admin/cost_types/cost_type_projects_controller.rb @@ -0,0 +1,157 @@ +# 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 Admin::CostTypes::CostTypeProjectsController < ApplicationController + include OpTurbo::ComponentStream + include FlashMessagesOutputSafetyHelper + + layout "admin" + + before_action :require_admin + before_action :find_cost_type + + before_action :available_cost_types_projects_query, only: %i[index destroy] + before_action :initialize_cost_type_project, only: :new + before_action :find_projects_to_activate_for_cost_type, only: :create + before_action :find_cost_type_project_to_destroy, only: :destroy + + menu_item :cost_types + + def index; end + + def new + respond_with_dialog Admin::CostTypes::CostTypeProjects::NewCostTypeProjectsModalComponent.new( + cost_type_project_mapping: @cost_type_project, + cost_type: @cost_type + ) + end + + def create + create_service = ::CostTypes::CostTypeProjects::BulkCreateService + .new(user: current_user, projects: @projects, model: @cost_type, + include_sub_projects: include_sub_projects?) + .call + + create_service.on_success { render_project_list(url_for_action: :index) } + + create_service.on_failure do + render_error_flash_message_via_turbo_stream( + message: join_flash_messages(create_service.errors) + ) + end + + respond_to_with_turbo_streams(status: create_service.success? ? :ok : :unprocessable_entity) + end + + def destroy + delete_service = ::CostTypes::CostTypeProjects::DeleteService + .new(user: current_user, model: @cost_type_project) + .call + + delete_service.on_success { render_project_list(url_for_action: :index) } + + delete_service.on_failure do + render_error_flash_message_via_turbo_stream( + message: join_flash_messages(delete_service.errors.full_messages) + ) + end + + respond_to_with_turbo_streams(status: delete_service.success? ? :ok : :unprocessable_entity) + end + + private + + def render_project_list(url_for_action: action_name) + update_via_turbo_stream( + component: Admin::CostTypes::CostTypeProjects::TableComponent.new( + query: available_cost_types_projects_query, + params: params.merge({ cost_type: @cost_type, url_for_action: }) + ) + ) + end + + def find_cost_type + @cost_type = CostType.find(params[:cost_type_id]) + end + + def find_projects_to_activate_for_cost_type + if (project_ids = params.to_unsafe_h.dig(:cost_types_project, :project_ids)).present? + @projects = Project.visible.find(project_ids) + else + initialize_cost_type_project + @cost_type_project.errors.add(:project_ids, :blank) + update_via_turbo_stream( + component: Admin::CostTypes::CostTypeProjects::NewCostTypeProjectsFormModalComponent.new( + cost_type_project_mapping: @cost_type_project, + cost_type: @cost_type + ), + status: :bad_request + ) + respond_with_turbo_streams + end + rescue ActiveRecord::RecordNotFound + respond_with_project_not_found_turbo_streams + end + + def find_cost_type_project_to_destroy + @cost_type_project = CostTypesProject.find_by!(cost_type: @cost_type, + project: params[:cost_types_project][:project_id]) + rescue ActiveRecord::RecordNotFound + respond_with_project_not_found_turbo_streams + end + + def available_cost_types_projects_query + @available_cost_types_projects_query = ProjectQuery.new( + name: "cost-types-projects-#{@cost_type.id}" + ) do |query| + query.where(:available_cost_types_projects, "=", [@cost_type.id]) + query.select(:name) + query.order("lft" => "asc") + end + end + + def initialize_cost_type_project + @cost_type_project = ::CostTypes::CostTypeProjects::SetAttributesService + .new(user: current_user, model: CostTypesProject.new, contract_class: EmptyContract) + .call(cost_type: @cost_type) + .result + end + + def respond_with_project_not_found_turbo_streams + render_error_flash_message_via_turbo_stream message: t(:notice_project_not_found) + render_project_list(url_for_action: :index) + + respond_with_turbo_streams + end + + def include_sub_projects? + ActiveRecord::Type::Boolean.new.cast(params.to_unsafe_h.dig(:cost_types_project, :include_sub_projects)) + end +end diff --git a/modules/costs/app/controllers/admin/cost_types_controller.rb b/modules/costs/app/controllers/admin/cost_types_controller.rb index 384efe9e95ff..c57f0f475f65 100644 --- a/modules/costs/app/controllers/admin/cost_types_controller.rb +++ b/modules/costs/app/controllers/admin/cost_types_controller.rb @@ -32,12 +32,13 @@ module Admin class CostTypesController < ApplicationController # Allow only admins here before_action :require_admin - before_action :find_cost_type, only: %i[edit update set_rate destroy restore] + before_action :find_cost_type, only: %i[edit update set_rate destroy restore rates] layout "admin" menu_item :cost_types helper :sort include SortHelper + helper :cost_types include CostTypesHelper @@ -48,34 +49,37 @@ def index # rubocop:disable Metrics/AbcSize "unit_plural" => "#{CostType.table_name}.unit_plural" } sort_update sort_columns - @cost_types = CostType.order(sort_clause) - - if params[:clear_filter] - @fixed_date = Time.zone.today - @include_deleted = nil - else - @fixed_date = begin - Date.parse(params[:fixed_date]) - rescue StandardError - Time.zone.today - end - @include_deleted = params[:include_deleted] - end + @status = params[:status] == "locked" ? "locked" : "active" + @cost_types = (@status == "locked" ? CostType.where.not(deleted_at: nil) : CostType.active) + .order(sort_clause) render action: "index", layout: !request.xhr? end + def new + @cost_type = CostType.new + + @cost_type.rates.build(valid_from: Time.zone.today) if @cost_type.rates.empty? + + render action: :edit, layout: !request.xhr? + end + def edit render action: :edit, layout: !request.xhr? end - def update - @cost_type.attributes = permitted_params.cost_type + def rates + render action: :rates, layout: !request.xhr? + end + + def create # rubocop:disable Metrics/AbcSize + @cost_type = CostType.new(permitted_params.cost_type) if @cost_type.save flash[:notice] = t(:notice_successful_update) redirect_back_or_default({ action: "index" }) else + @cost_type.rates.build(valid_from: Time.zone.today) if @cost_type.rates.empty? render action: :edit, status: :unprocessable_entity, layout: !request.xhr? end rescue ActiveRecord::StaleObjectError @@ -83,23 +87,14 @@ def update flash.now[:error] = t(:notice_locking_conflict) end - def new - @cost_type = CostType.new - - @cost_type.rates.build(valid_from: Time.zone.today) if @cost_type.rates.empty? - - render action: :edit, layout: !request.xhr? - end - - def create # rubocop:disable Metrics/AbcSize - @cost_type = CostType.new(permitted_params.cost_type) + def update # rubocop:disable Metrics/AbcSize + @cost_type.attributes = permitted_params.cost_type if @cost_type.save flash[:notice] = t(:notice_successful_update) - redirect_back_or_default({ action: "index" }) + redirect_to redirect_target_after_update else - @cost_type.rates.build(valid_from: Time.zone.today) if @cost_type.rates.empty? - render action: :edit, status: :unprocessable_entity, layout: !request.xhr? + render action: render_action_for_failed_update, status: :unprocessable_entity, layout: !request.xhr? end rescue ActiveRecord::StaleObjectError # Optimistic locking exception @@ -113,7 +108,7 @@ def destroy if @cost_type.save flash[:notice] = t(:notice_successful_lock) - redirect_back_or_default({ action: "index" }) + redirect_back_or_default({ action: "index" }, status: :see_other) end end @@ -124,7 +119,7 @@ def restore if @cost_type.save flash[:notice] = t(:notice_successful_restore) - redirect_back_or_default({ action: "index" }) + redirect_back_or_default({ action: "index" }, status: :see_other) end end @@ -152,5 +147,18 @@ def set_rate # rubocop:disable Metrics/AbcSize def find_cost_type @cost_type = CostType.find(params[:id]) end + + def rates_submission? + params.dig(:cost_type, :new_rate_attributes).present? || + params.dig(:cost_type, :existing_rate_attributes).present? + end + + def redirect_target_after_update + rates_submission? ? rates_admin_cost_type_path(@cost_type) : edit_admin_cost_type_path(@cost_type) + end + + def render_action_for_failed_update + rates_submission? ? :rates : :edit + end end end diff --git a/modules/costs/app/controllers/costlog_controller.rb b/modules/costs/app/controllers/costlog_controller.rb index 0eea746cb9d7..31c6b2ce323c 100644 --- a/modules/costs/app/controllers/costlog_controller.rb +++ b/modules/costs/app/controllers/costlog_controller.rb @@ -37,6 +37,12 @@ class CostlogController < ApplicationController include CostlogHelper def new + unless @project&.cost_types_available? + flash[:error] = I18n.t("cost_types.errors.no_cost_types_available") # rubocop:disable Rails/ActionControllerFlashBeforeRender + redirect_back_or_default(@work_package ? polymorphic_path(@work_package) : project_path(@project)) + return + end + new_default_cost_entry render action: "edit" @@ -140,7 +146,7 @@ def new_default_cost_entry ce.entity = @work_package ce.user = User.current ce.spent_on = Time.zone.today - # notice that cost_type is set to default cost_type in the model + ce.cost_type = CostType.default_for_project(@project) if @project end end diff --git a/modules/costs/app/controllers/projects/settings/cost_types_controller.rb b/modules/costs/app/controllers/projects/settings/cost_types_controller.rb new file mode 100644 index 000000000000..53ce447777cc --- /dev/null +++ b/modules/costs/app/controllers/projects/settings/cost_types_controller.rb @@ -0,0 +1,76 @@ +# 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 Projects::Settings::CostTypesController < Projects::SettingsController + menu_item :settings_time_and_costs + + before_action :find_cost_type, only: :toggle + + def index + @cost_types = CostType.active.order(:name) + end + + def toggle + if @cost_type.is_for_all? + respond_with_status(:unprocessable_entity) + return + end + + mapping = CostTypesProject.find_or_initialize_by(project_id: @project.id, cost_type_id: @cost_type.id) + + if mapping.persisted? + mapping.destroy! + else + mapping.save! + end + + respond_with_status(:ok) + end + + private + + def find_cost_type + @cost_type = CostType.active.find(params[:id]) + end + + def respond_with_status(status) + respond_to do |format| + format.json { render json: {}, status: } + format.html do + if status == :ok + flash[:notice] = I18n.t(:notice_successful_update) + else + flash[:error] = I18n.t("activerecord.errors.messages.is_for_all_cannot_modify") + end + redirect_to project_settings_cost_types_path(@project) + end + end + end +end diff --git a/modules/costs/app/controllers/projects/settings/time_entry_activities_controller.rb b/modules/costs/app/controllers/projects/settings/time_entry_activities_controller.rb index 0a877c0a1f7c..352b579df032 100644 --- a/modules/costs/app/controllers/projects/settings/time_entry_activities_controller.rb +++ b/modules/costs/app/controllers/projects/settings/time_entry_activities_controller.rb @@ -29,7 +29,7 @@ #++ class Projects::Settings::TimeEntryActivitiesController < Projects::SettingsController - menu_item :settings_time_entry_activities + menu_item :settings_time_and_costs def update TimeEntryActivitiesProject.upsert_all(update_params, unique_by: %i[project_id activity_id]) diff --git a/modules/costs/app/forms/admin/cost_types/details_form.rb b/modules/costs/app/forms/admin/cost_types/details_form.rb new file mode 100644 index 000000000000..5408c8fd7ed8 --- /dev/null +++ b/modules/costs/app/forms/admin/cost_types/details_form.rb @@ -0,0 +1,84 @@ +# 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 Admin + module CostTypes + class DetailsForm < ApplicationForm + form do |f| + f.text_field( + name: :name, + label: ::CostType.human_attribute_name(:name), + required: true, + input_width: :large + ) + + f.text_field( + name: :unit, + label: ::CostType.human_attribute_name(:unit), + required: true, + input_width: :medium + ) + + f.text_field( + name: :unit_plural, + label: ::CostType.human_attribute_name(:unit_plural), + required: true, + input_width: :medium + ) + + f.text_field( + name: :current_rate, + label: ::CostType.human_attribute_name(:current_rate), + input_width: :small, + inputmode: :decimal, + value: current_rate_value, + trailing_visual: { text: { text: Setting.costs_currency } } + ) + + f.check_box( + name: :default, + label: ::CostType.human_attribute_name(:default) + ) + + f.check_box( + name: :is_for_all, + label: ::CostType.human_attribute_name(:is_for_all) + ) + end + + def current_rate_value + rate = model.rate_at(Date.current) + return "" unless rate + + helpers.unitless_currency_number(rate.rate.round(2)) + end + end + end +end diff --git a/modules/costs/app/forms/cost_types/cost_type_projects/cost_type_mapping_form.rb b/modules/costs/app/forms/cost_types/cost_type_projects/cost_type_mapping_form.rb new file mode 100644 index 000000000000..b8d447740d5f --- /dev/null +++ b/modules/costs/app/forms/cost_types/cost_type_projects/cost_type_mapping_form.rb @@ -0,0 +1,85 @@ +# 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 CostTypes + module CostTypeProjects + class CostTypeMappingForm < ApplicationForm + include OpPrimer::ComponentHelpers + + form do |form| + form.project_autocompleter( + name: :id, + label: Project.model_name.human, + visually_hide_label: true, + validation_message: project_ids_error_message, + autocomplete_options: { + appendTo: "##{Admin::CostTypes::CostTypeProjects::NewCostTypeProjectsFormModalComponent::DIALOG_ID}", + with_search_icon: true, + openDirectly: false, + focusDirectly: false, + multiple: true, + dropdownPosition: "bottom", + disabledProjects: projects_with_cost_type_mapping, + inputName: "cost_types_project[project_ids]" + } + ) + + form.check_box( + name: :include_sub_projects, + label: I18n.t(:label_include_sub_projects), + checked: false, + label_arguments: { class: "no-wrap" } + ) + end + + def initialize(project_mapping:) + super() + @project_mapping = project_mapping + end + + private + + def project_ids_error_message + @project_mapping + .errors + .messages_for(:project_ids) + .to_sentence + .presence + end + + def projects_with_cost_type_mapping + CostTypesProject + .where(cost_type_id: @project_mapping.cost_type_id) + .pluck(:project_id) + .to_h { |id| [id, id] } + end + end + end +end diff --git a/modules/costs/app/models/cost_type.rb b/modules/costs/app/models/cost_type.rb index 06f76be82801..892969b2a0b7 100644 --- a/modules/costs/app/models/cost_type.rb +++ b/modules/costs/app/models/cost_type.rb @@ -30,21 +30,37 @@ class CostType < ApplicationRecord has_many :material_budget_items has_many :cost_entries, dependent: :destroy has_many :rates, class_name: "CostRate", dependent: :destroy + has_many :cost_types_projects, dependent: :destroy + has_many :projects, through: :cost_types_projects validates :unit, :unit_plural, presence: true validates :name, presence: true, uniqueness: { case_sensitive: false } after_update :save_rates + after_save :persist_current_rate_input include ActiveModel::ForbiddenAttributesProtection scope :active, -> { where(deleted_at: nil) } + scope :for_all, -> { where(is_for_all: true) } + scope :available_for_project, ->(project) { + project_id = project.is_a?(Project) ? project.id : project + where(is_for_all: true) + .or(where(id: CostTypesProject.where(project_id:).select(:cost_type_id))) + } # finds the default CostType def self.default CostType.find_by(default: true) || CostType.first end + # Returns the default cost type for the given project, falling back to the first + # cost type available in that project when the global default is not available there. + def self.default_for_project(project) + available = available_for_project(project).active + available.find_by(default: true) || available.first + end + def is_default? default end @@ -53,8 +69,12 @@ def <=>(other) name.downcase <=> other.name.downcase end + attr_writer :current_rate + def current_rate - rate_at(Date.today) + return @current_rate if instance_variable_defined?(:@current_rate) + + rate_at(Date.current)&.rate end def rate_at(date) @@ -99,4 +119,24 @@ def existing_rate_attributes=(rate_attributes) def save_rates rates.each(&:save!) end + + def persist_current_rate_input + return unless instance_variable_defined?(:@current_rate) + + amount = parse_current_rate_amount(remove_instance_variable(:@current_rate)) + return if amount.nil? + + today = Time.zone.today + if (rate = rate_at(today)) + rate.update!(rate: amount) + else + rates.create!(valid_from: today, rate: amount) + end + end + + def parse_current_rate_amount(value) + return if value.to_s.strip.empty? + + CostRate.parse_number_string_to_number(value.to_s) + end end diff --git a/modules/costs/app/models/cost_types_project.rb b/modules/costs/app/models/cost_types_project.rb new file mode 100644 index 000000000000..018f4a95c122 --- /dev/null +++ b/modules/costs/app/models/cost_types_project.rb @@ -0,0 +1,36 @@ +# 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. +#++ + +# Join table for cost types on projects, used when CostType#is_for_all is not set +# to find which cost types are activated. +class CostTypesProject < ApplicationRecord + belongs_to :cost_type + belongs_to :project +end diff --git a/modules/costs/app/models/projects/scopes/available_cost_types.rb b/modules/costs/app/models/projects/scopes/available_cost_types.rb new file mode 100644 index 000000000000..d283c80d3a33 --- /dev/null +++ b/modules/costs/app/models/projects/scopes/available_cost_types.rb @@ -0,0 +1,52 @@ +# 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 Projects::Scopes + module AvailableCostTypes + extend ActiveSupport::Concern + + class_methods do + def with_available_cost_types(cost_type_ids) + where(id: cost_types_projects_subquery(cost_type_ids:)) + end + + def without_available_cost_types(cost_type_ids) + where.not(id: cost_types_projects_subquery(cost_type_ids:)) + end + + private + + def cost_types_projects_subquery(cost_type_ids:) + CostTypesProject.select(:project_id) + .where(cost_type_id: cost_type_ids) + end + end + end +end diff --git a/modules/costs/app/models/queries/projects/filters/available_cost_types_projects_filter.rb b/modules/costs/app/models/queries/projects/filters/available_cost_types_projects_filter.rb new file mode 100644 index 000000000000..13918576df93 --- /dev/null +++ b/modules/costs/app/models/queries/projects/filters/available_cost_types_projects_filter.rb @@ -0,0 +1,66 @@ +# 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 Queries::Projects::Filters::AvailableCostTypesProjectsFilter < Queries::Projects::Filters::Base + def self.key + :available_cost_types_projects + end + + def type + :list + end + + def allowed_values + @allowed_values ||= CostType.where(is_for_all: false).pluck(:name, :id) + end + + def available? + User.current.admin? + end + + def apply_to(_query_scope) + case operator + when "=" + super.with_available_cost_types(values) + when "!" + super.without_available_cost_types(values) + else + raise "unsupported operator" + end + end + + def where + nil + end + + def human_name + I18n.t(:label_available_cost_types_projects) + end +end diff --git a/modules/costs/app/services/cost_types/cost_type_projects/bulk_create_service.rb b/modules/costs/app/services/cost_types/cost_type_projects/bulk_create_service.rb new file mode 100644 index 000000000000..7cdeaa7e528d --- /dev/null +++ b/modules/costs/app/services/cost_types/cost_type_projects/bulk_create_service.rb @@ -0,0 +1,49 @@ +# 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 CostTypes + module CostTypeProjects + class BulkCreateService < ::BulkServices::ProjectMappings::BaseCreateService + def initialize(user:, projects:, model:, include_sub_projects: false) + mapping_context = ::BulkServices::ProjectMappings::MappingContext.new( + mapping_model_class: CostTypesProject, + model:, + projects:, + model_foreign_key_id:, + include_sub_projects: + ) + super(user:, mapping_context:) + end + + def permission = :manage_project_activities + def model_foreign_key_id = :cost_type_id + end + end +end diff --git a/modules/costs/app/services/cost_types/cost_type_projects/delete_service.rb b/modules/costs/app/services/cost_types/cost_type_projects/delete_service.rb new file mode 100644 index 000000000000..7a6d7e9b2c9c --- /dev/null +++ b/modules/costs/app/services/cost_types/cost_type_projects/delete_service.rb @@ -0,0 +1,37 @@ +# 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 CostTypes + module CostTypeProjects + class DeleteService < ::BaseServices::Delete + def default_contract_class = CostTypes::CostTypeProjects::UpdateContract + end + end +end diff --git a/modules/costs/app/services/cost_types/cost_type_projects/set_attributes_service.rb b/modules/costs/app/services/cost_types/cost_type_projects/set_attributes_service.rb new file mode 100644 index 000000000000..7397e372e36d --- /dev/null +++ b/modules/costs/app/services/cost_types/cost_type_projects/set_attributes_service.rb @@ -0,0 +1,36 @@ +# 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 CostTypes + module CostTypeProjects + class SetAttributesService < ::BaseServices::SetAttributes + end + end +end diff --git a/modules/costs/app/views/admin/cost_types/_list.html.erb b/modules/costs/app/views/admin/cost_types/_list.html.erb deleted file mode 100644 index 299d68a66999..000000000000 --- a/modules/costs/app/views/admin/cost_types/_list.html.erb +++ /dev/null @@ -1,131 +0,0 @@ -<%#-- 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. - -++#%> - -<% cost_types = @cost_types.reject(&:deleted_at) -%> - -<% if cost_types.empty? %> - <%= no_results_box %> -<% else %> -
-
- - - - - - - - - - - - - <%= sort_header_tag "name", caption: CostType.model_name.human %> - <%= sort_header_tag "unit", caption: CostType.human_attribute_name(:unit) %> - <%= sort_header_tag "unit_plural", caption: CostType.human_attribute_name(:unit_plural) %> - - - - - - - - <% cost_types.each do |cost_type| %> - - <%= content_tag :td, link_to(cost_type.name, { controller: "/admin/cost_types", action: "edit", id: cost_type }) %> - <%= content_tag :td, cost_type.unit %> - <%= content_tag :td, cost_type.unit_plural %> - <%= content_tag :td, to_currency_with_empty(cost_type.rate_at(@fixed_date)), class: "currency", id: "cost_type_#{cost_type.id}_rate" %> - - <%= content_tag :td, cost_type.is_default? ? icon_wrapper("icon icon-checkmark", I18n.t(:general_text_Yes)) : "" %> - - - - - <% end %> - -
-
-
- - <%= CostType.human_attribute_name(:current_rate) %> - -
-
-
-
-
- - <%= t(:caption_set_rate) %> - -
-
-
-
-
- - <%= t(:caption_default) %> - -
-
-
- <%= form_for cost_type, url: { controller: "/admin/cost_types", action: "set_rate", id: cost_type }, method: :put, html: { class: "inline-label" } do |f| %> - - <%= content_tag :input, - "", - value: "", - name: :rate, - size: 7, - inputmode: :decimal, - placeholder: t(:label_example_placeholder, decimal: unitless_currency_number(1000.50)), - id: "rate_field_#{cost_type.id}" %> - - <%= Setting.costs_currency %> - - - <% end %> - - <%= form_for cost_type, url: admin_cost_type_path(cost_type), - method: :delete, - html: { id: "delete_cost_type_#{cost_type.id}", - class: "delete_cost_type", - title: t(:button_lock) } do |f| %> - - <% end %> -
- -
-
-<% end %> diff --git a/modules/costs/app/views/admin/cost_types/_list_deleted.html.erb b/modules/costs/app/views/admin/cost_types/_list_deleted.html.erb deleted file mode 100644 index f38ff7c72b6c..000000000000 --- a/modules/costs/app/views/admin/cost_types/_list_deleted.html.erb +++ /dev/null @@ -1,98 +0,0 @@ -<%#-- 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. - -++#%> - -<% cost_types = @cost_types.select(&:deleted_at) -%> - -<% if cost_types.empty? %> - <%= no_results_box %> -<% else %> -
-
- - - - - - - - - - - - <%= sort_header_tag("name", caption: CostType.model_name.human) %> - <%= sort_header_tag("unit", caption: CostType.human_attribute_name(:unit)) %> - <%= sort_header_tag("unit_plural", caption: CostType.human_attribute_name(:unit_plural)) %> - - - - - - - <% cost_types.each do |cost_type| %> - - <%= content_tag :td, cost_type.name %> - <%= content_tag :td, cost_type.unit %> - <%= content_tag :td, cost_type.unit_plural %> - <%= content_tag :td, to_currency_with_empty(cost_type.rate_at(@fixed_date)), class: "currency" %> - <%= content_tag :td, cost_type.deleted_at.to_date %> - - - <% end %> - -
-
-
- - <%= CostType.human_attribute_name(:current_rate) %> - -
-
-
-
-
- - <%= t(:caption_locked_on) %> - -
-
-
- <%= form_for cost_type, url: restore_admin_cost_type_path(cost_type), - method: :patch, - html: { id: "restore_cost_type_#{cost_type.id}", - class: "restore_cost_type" } do |f| %> - - <% end %> -
- -
-
-<% end %> diff --git a/modules/costs/app/views/admin/cost_types/cost_type_projects/index.html.erb b/modules/costs/app/views/admin/cost_types/cost_type_projects/index.html.erb new file mode 100644 index 000000000000..43d5a1887a8d --- /dev/null +++ b/modules/costs/app/views/admin/cost_types/cost_type_projects/index.html.erb @@ -0,0 +1,70 @@ +<%#-- 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. +++#%> + +<% html_title t(:label_administration), "#{CostType.model_name.human} #{h @cost_type.name}", t(:label_project_plural) %> + +<%= + render( + Admin::CostTypes::EditFormHeaderComponent.new( + cost_type: @cost_type, + selected: :cost_type_projects + ) + ) +%> + +<%= + unless @cost_type.is_for_all? + render(Primer::OpenProject::SubHeader.new(test_selector: "add-projects-sub-header")) do |component| + component.with_action_button(scheme: :primary, + leading_icon: :"op-include-projects", + label: I18n.t(:label_add_projects), + tag: :a, + href: new_admin_cost_type_project_path(@cost_type), + data: { controller: "async-dialog" }) do + I18n.t(:label_add_projects) + end + end + end +%> + +<%= + if @cost_type.is_for_all? + render Primer::Beta::Blankslate.new(border: true) do |component| + component.with_visual_icon(icon: :checklist) + component.with_heading(tag: :h2).with_content(I18n.t("cost_types.admin.cost_type_projects.is_for_all_blank_slate.heading")) + component.with_description { I18n.t("cost_types.admin.cost_type_projects.is_for_all_blank_slate.description") } + end + else + render( + Admin::CostTypes::CostTypeProjects::TableComponent.new( + query: @available_cost_types_projects_query, + params: params.merge({ cost_type: @cost_type }) + ) + ) + end +%> diff --git a/modules/costs/app/views/admin/cost_types/edit.html.erb b/modules/costs/app/views/admin/cost_types/edit.html.erb index 7675ce934bb7..3d9ccdaa3105 100644 --- a/modules/costs/app/views/admin/cost_types/edit.html.erb +++ b/modules/costs/app/views/admin/cost_types/edit.html.erb @@ -1,4 +1,4 @@ -<%# -- copyright +<%#-- copyright OpenProject is an open source project management software. Copyright (C) the OpenProject GmbH @@ -21,120 +21,24 @@ 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. +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. +++#%> -++# %> - -<% if(@cost_type.id) %> +<% if @cost_type.id %> <% html_title t(:label_cost_type_specific, id: @cost_type.id, name: @cost_type.name) %> - <% title = @cost_type.name %> <% else %> <% html_title t(:label_administration), t(:label_cost_type_plural) %> - <% title = "#{t(:label_new)} #{I18n.t('activerecord.models.cost_type.one')}" %> <% end %> <%= - render(Primer::OpenProject::PageHeader.new) do |header| - header.with_title { title } - header.with_breadcrumbs( - [ - { href: admin_index_path, text: t("label_administration") }, - { href: admin_time_settings_path, text: t(:project_module_costs) }, - { href: admin_cost_types_path, text: t(:label_cost_type_plural) }, - title - ] + render( + Admin::CostTypes::EditFormHeaderComponent.new( + cost_type: @cost_type, + selected: :edit ) - end + ) %> -<%= labelled_tabular_form_for @cost_type, - url: @cost_type.persisted? ? admin_cost_type_path(@cost_type) : admin_cost_types_path, - data: { controller: "subform" } do |f| %> - <%= error_messages_for "cost_type" %> - <%= back_url_hidden_field_tag %> - -
- <%= f.text_field :name, required: true, container_class: "-wide" %> -
-
- <%= f.text_field :unit, required: true, container_class: "-middle" %> -
-
- <%= f.text_field :unit_plural, required: true, container_class: "-middle" %> -
-
- <%= f.check_box :default %> -
- -

<%= t :caption_rate_history %>

-
-
- - - - - - - - - - - - - - - <%= render partial: "rate", object: CostRate.new(valid_from: Date.current), locals: { templated: true } %> - <% @cost_type.rates.sort do |a, b| - if !a.valid? && !b.valid? - 0 - elsif !a.valid? - -1 - elsif !b.valid? - 1 - else - b.valid_from <=> a.valid_from - end - end.each_with_index do |rate, index| %> - <%= render partial: "rate", object: rate, locals: { index: index } %> - <%- end -%> - -
-
-
- - <%= Rate.human_attribute_name(:valid_from) %> - -
-
-
-
-
- - <%= Rate.model_name.human %> - -
-
-
- -
-
-
- - <%= - render( - Primer::Beta::IconButton.new( - icon: :plus, - id: "add_rate_date", - scheme: :invisible, - type: :button, - classes: "add-row-button wp-inline-create--add-link", - aria: { label: t(:button_add_rate) }, - data: { action: "subform#addRow" } - ) - ) - %> -
- <%= styled_button_tag t(:button_save), class: "-with-icon icon-checkmark" %> -<% end %> +<%= render(Admin::CostTypes::DetailsComponent.new(@cost_type)) %> diff --git a/modules/costs/app/views/admin/cost_types/index.html.erb b/modules/costs/app/views/admin/cost_types/index.html.erb index 952e8403d82b..6ad93cfb5805 100644 --- a/modules/costs/app/views/admin/cost_types/index.html.erb +++ b/modules/costs/app/views/admin/cost_types/index.html.erb @@ -24,7 +24,6 @@ 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. - ++#%> <% html_title t(:label_administration), t(:label_cost_type_plural) %> @@ -43,6 +42,19 @@ See COPYRIGHT and LICENSE files for more details. %> <%= render(Primer::OpenProject::SubHeader.new) do |subheader| %> + <% subheader.with_filter_component do %> + <%= render(Primer::Alpha::SegmentedControl.new("aria-label": t(:label_filter_plural), full_width: false)) do |control| %> + <% control.with_item(tag: :a, + href: admin_cost_types_path(status: "active"), + label: t(:label_active), + selected: @status == "active") %> + <% control.with_item(tag: :a, + href: admin_cost_types_path(status: "locked"), + label: t("members.menu.locked"), + selected: @status == "locked") %> + <% end %> + <% end %> + <% subheader.with_action_button( scheme: :primary, leading_icon: :plus, @@ -52,44 +64,6 @@ See COPYRIGHT and LICENSE files for more details. ) do CostType.model_name.human end %> - - <% subheader.with_bottom_pane_component do %> - <%= styled_form_tag(admin_cost_types_path, { method: :get, id: "query_form" }) do %> -
- <%= t(:label_filter_plural) %> - -
- <% end %> - <% end %> - <% end %> - -<%= render partial: "list" %> - -<% if @include_deleted %> -
-

<%= t(:label_locked_cost_types) %>

- <%= render partial: "list_deleted" %> -
<% end %> + +<%= render(Admin::CostTypes::TableComponent.new(rows: @cost_types, status: @status)) %> diff --git a/modules/costs/app/views/admin/cost_types/rates.html.erb b/modules/costs/app/views/admin/cost_types/rates.html.erb new file mode 100644 index 000000000000..5d3a0948b73d --- /dev/null +++ b/modules/costs/app/views/admin/cost_types/rates.html.erb @@ -0,0 +1,40 @@ +<%#-- 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. +++#%> + +<% html_title t(:label_cost_type_specific, id: @cost_type.id, name: @cost_type.name) %> + +<%= + render( + Admin::CostTypes::EditFormHeaderComponent.new( + cost_type: @cost_type, + selected: :rates + ) + ) +%> + +<%= render(Admin::CostTypes::RatesComponent.new(@cost_type)) %> diff --git a/modules/costs/app/views/projects/settings/cost_types/index.html.erb b/modules/costs/app/views/projects/settings/cost_types/index.html.erb new file mode 100644 index 000000000000..0273959624fa --- /dev/null +++ b/modules/costs/app/views/projects/settings/cost_types/index.html.erb @@ -0,0 +1,45 @@ +<%#-- 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. +++#%> + +<%= + render( + Projects::Settings::TimeAndCosts::PageHeaderComponent.new( + project: @project, + selected: :cost_types + ) + ) +%> + +<%= + render( + Projects::Settings::CostTypes::IndexComponent.new( + project: @project, + cost_types: @cost_types + ) + ) +%> diff --git a/modules/costs/app/views/projects/settings/time_entry_activities/show.html.erb b/modules/costs/app/views/projects/settings/time_entry_activities/show.html.erb index 2a5237bea489..3582d7e9aff6 100644 --- a/modules/costs/app/views/projects/settings/time_entry_activities/show.html.erb +++ b/modules/costs/app/views/projects/settings/time_entry_activities/show.html.erb @@ -28,14 +28,12 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <%= - render Primer::OpenProject::PageHeader.new do |header| - header.with_title { t(:enumeration_activities) } - header.with_breadcrumbs( - [{ href: project_overview_path(@project.id), text: @project.name }, - { href: project_settings_general_path(@project.id), text: I18n.t(:label_project_settings) }, - t(:enumeration_activities)] + render( + Projects::Settings::TimeAndCosts::PageHeaderComponent.new( + project: @project, + selected: :time_entry_activities ) - end + ) %> <% if TimeEntryActivity.any? %> diff --git a/modules/costs/config/locales/en.yml b/modules/costs/config/locales/en.yml index cd8642843f37..0d3fed93eaea 100644 --- a/modules/costs/config/locales/en.yml +++ b/modules/costs/config/locales/en.yml @@ -47,6 +47,7 @@ en: unit: "Unit name" unit_plural: "Pluralized unit name" default: "Cost type by default" + is_for_all: "For all projects" work_package: costs_by_type: "Spent units" labor_costs: "Labor costs" @@ -142,7 +143,6 @@ en: label_current_default_rate: "Current default rate" label_date_on: "on" label_deleted_cost_types: "Deleted cost types" - label_locked_cost_types: "Locked cost types" label_display_cost_entries: "Display unit costs" label_display_time_entries: "Display reported hours" label_display_types: "Display types" @@ -252,6 +252,29 @@ en: validation: start_time_different_date: "Date part of startTime (%{start_time}) must be the same as the spentOn (%{spent_on}) date." + label_available_cost_types_projects: "Available cost types projects" + cost_types: + errors: + no_cost_types_available: "No cost types are available in this project. Please contact an administrator." + admin: + columns: + active_projects: "Active projects" + cost_type_projects: + is_for_all_blank_slate: + heading: "This cost type is enabled in all projects" + description: "Uncheck \"For all projects\" on the details tab to limit this cost type to specific projects." + no_projects: + heading: "No projects assigned" + description: "Add projects so this cost type can be used in them." + rates: + title: "Rate history" + settings: + time_and_costs: "Time & Costs" + cost_types: + heading: "Cost types" + none_active: "No cost types are currently active in this project." + is_for_all_hint: "This cost type is enabled in all projects." + costs: widgets: actual_costs: diff --git a/modules/costs/config/routes.rb b/modules/costs/config/routes.rb index e3eea05aa91f..d22516e5598e 100644 --- a/modules/costs/config/routes.rb +++ b/modules/costs/config/routes.rb @@ -62,6 +62,9 @@ scope "projects/:project_id", as: "project", module: "projects" do namespace "settings" do resource :time_entry_activities, only: %i[show update] + resources :cost_types, only: %i[index] do + member { post :toggle } + end end end @@ -92,6 +95,12 @@ # TODO: check if this can be replaced with update method put :set_rate patch :restore + get :rates + end + + scope module: :cost_types do + resources :projects, controller: :cost_type_projects, only: %i[index new create] + resource :project, controller: :cost_type_projects, only: :destroy end end diff --git a/modules/costs/lib/costs/engine.rb b/modules/costs/lib/costs/engine.rb index 1fe57a590fd1..ec6f54e3856f 100644 --- a/modules/costs/lib/costs/engine.rb +++ b/modules/costs/lib/costs/engine.rb @@ -67,7 +67,10 @@ class Engine < ::Rails::Engine require: :member permission :manage_project_activities, - { "projects/settings/time_entry_activities": %i[show update] }, + { + "projects/settings/time_entry_activities": %i[show update], + "projects/settings/cost_types": %i[index toggle] + }, permissible_on: :project, require: :member @@ -222,6 +225,7 @@ class Engine < ::Rails::Engine current_user.allowed_in_project?(:log_own_costs, represented.project) } do next unless represented.costs_enabled? && represented.persisted? + next unless represented.project&.cost_types_available? { href: new_work_packages_cost_entry_path(represented), @@ -349,6 +353,10 @@ class Engine < ::Rails::Engine ::Queries::Register.register(::Query) do select Costs::QueryCurrencySelect end + + ::Queries::Register.register(::ProjectQuery) do + filter ::Queries::Projects::Filters::AvailableCostTypesProjectsFilter + end end end end diff --git a/modules/costs/lib/costs/patches/permitted_params_patch.rb b/modules/costs/lib/costs/patches/permitted_params_patch.rb index 5f5d9f9dd30b..4d8c550ade4b 100644 --- a/modules/costs/lib/costs/patches/permitted_params_patch.rb +++ b/modules/costs/lib/costs/patches/permitted_params_patch.rb @@ -56,6 +56,8 @@ def cost_type :unit, :unit_plural, :default, + :is_for_all, + :current_rate, { new_rate_attributes: %i[valid_from rate] }, existing_rate_attributes: %i[valid_from rate]) end diff --git a/modules/costs/lib/costs/patches/project_patch.rb b/modules/costs/lib/costs/patches/project_patch.rb index b0b1402e338b..aa0f32a1bb77 100644 --- a/modules/costs/lib/costs/patches/project_patch.rb +++ b/modules/costs/lib/costs/patches/project_patch.rb @@ -49,5 +49,9 @@ module InstanceMethods def costs_enabled? module_enabled?(:costs) end + + def cost_types_available? + CostType.available_for_project(self).active.exists? + end end end diff --git a/modules/costs/spec/controllers/costlog_controller_spec.rb b/modules/costs/spec/controllers/costlog_controller_spec.rb index c6b7f11b81c2..c194024171b7 100644 --- a/modules/costs/spec/controllers/costlog_controller_spec.rb +++ b/modules/costs/spec/controllers/costlog_controller_spec.rb @@ -132,7 +132,10 @@ def disable_flash_sweep end describe "WHEN user allowed to create new cost_entry" do + let(:expected_cost_type) { cost_type } + before do + cost_type.save! grant_current_user_permissions user, %i[view_project view_work_packages log_costs] end @@ -154,7 +157,10 @@ def disable_flash_sweep end describe "WHEN user is allowed to create new own cost_entry" do + let(:expected_cost_type) { cost_type } + before do + cost_type.save! grant_current_user_permissions user, %i[view_project view_work_packages log_own_costs] end @@ -172,6 +178,53 @@ def disable_flash_sweep describe "WHEN user is not a project member" do it_behaves_like "not_found new" end + + describe "WHEN no cost type is available in the project" do + let(:scoped_cost_type) { create(:cost_type, is_for_all: false) } + + before do + CostType.destroy_all + scoped_cost_type # only project-scoped cost type, not mapped to this project + grant_current_user_permissions user, %i[view_project view_work_packages log_costs] + get :new, params: + end + + it "redirects with an error flash explaining no cost types are available" do + expect(response).to be_redirect + expect(flash[:error]).to eq(I18n.t("cost_types.errors.no_cost_types_available")) + end + end + + describe "WHEN the project's default cost type is global" do + let(:expected_cost_type) { cost_type } + + before do + CostType.destroy_all + cost_type.is_for_all = true + cost_type.default = true + cost_type.save! + grant_current_user_permissions user, %i[view_project view_work_packages log_costs] + end + + it_behaves_like "successful new" + end + + describe "WHEN the global default cost type is unavailable in the project, " \ + "but another non-global cost type is enabled" do + let(:scoped_cost_type) { create(:cost_type, is_for_all: false) } + let(:global_default) { create(:cost_type, is_for_all: false, default: true) } + let(:expected_cost_type) { scoped_cost_type } + + before do + CostType.destroy_all + global_default + scoped_cost_type + CostTypesProject.create!(project:, cost_type: scoped_cost_type) + grant_current_user_permissions user, %i[view_project view_work_packages log_costs] + end + + it_behaves_like "successful new" + end end describe "GET edit" do diff --git a/modules/costs/spec/features/cost_types/create_cost_type_spec.rb b/modules/costs/spec/features/cost_types/create_cost_type_spec.rb index c01d86075501..33727aff87b4 100644 --- a/modules/costs/spec/features/cost_types/create_cost_type_spec.rb +++ b/modules/costs/spec/features/cost_types/create_cost_type_spec.rb @@ -46,11 +46,11 @@ fill_in "cost_type_name", with: "Test day rate" fill_in "cost_type_unit", with: "dayUnit" fill_in "cost_type_unit_plural", with: "dayUnitPlural" - fill_in "cost_type_new_rate_attributes_0_rate", with: "1,000.25" + fill_in "cost_type_current_rate", with: "1,000.25" sleep 1 - scroll_to_and_click(find("button.-with-icon.icon-checkmark")) + scroll_to_and_click(find("[data-test-selector='op-cost-type-form--submit']")) expect_angular_frontend_initialized expect(page).to have_css ".generic-table", wait: 10 @@ -77,11 +77,11 @@ fill_in "cost_type_name", with: "Test day rate" fill_in "cost_type_unit", with: "dayUnit" fill_in "cost_type_unit_plural", with: "dayUnitPlural" - fill_in "cost_type_new_rate_attributes_0_rate", with: "1.000,25" + fill_in "cost_type_current_rate", with: "1.000,25" sleep 1 - scroll_to_and_click(find("button.-with-icon.icon-checkmark")) + scroll_to_and_click(find("[data-test-selector='op-cost-type-form--submit']")) expect_angular_frontend_initialized expect(page).to have_css ".generic-table", wait: 10 diff --git a/modules/costs/spec/features/cost_types/delete_cost_type_spec.rb b/modules/costs/spec/features/cost_types/delete_cost_type_spec.rb index ca201b229aae..a9a2b6a74d50 100644 --- a/modules/costs/spec/features/cost_types/delete_cost_type_spec.rb +++ b/modules/costs/spec/features/cost_types/delete_cost_type_spec.rb @@ -43,24 +43,21 @@ it "can delete the cost type" do visit admin_cost_types_path - within("#delete_cost_type_#{cost_type.id}") do - scroll_to_and_click(find("button.submit_cost_type")) + accept_confirm do + scroll_to_and_click(find("[data-test-selector='op-admin-cost-type-#{cost_type.id}-lock']")) end - # Expect no results if not locked + # Active list becomes empty expect_angular_frontend_initialized - expect(page).to have_css ".generic-table--no-results-container", wait: 10 + expect(page).to have_css ".generic-table--empty-row", wait: 10 - # Show locked - find_by_id("include_deleted").set true - click_on "Apply" + # Switch to the locked tab via the segmented control + click_on I18n.t(:label_locked) wait_for_network_idle - # Expect no results if not locked - expect(page).to have_text I18n.t(:label_locked_cost_types) - - expect(page).to have_css(".restore_cost_type") - expect(page).to have_css(".cost-types--list-deleted td", text: "Translations") + # The locked cost type appears with a restore action + expect(page).to have_css("[data-test-selector='op-admin-cost-type-#{cost_type.id}-restore']") + expect(page).to have_css("td", text: "Translations") end end diff --git a/modules/costs/spec/lib/api/v3/work_packages/work_package_representer_spec.rb b/modules/costs/spec/lib/api/v3/work_packages/work_package_representer_spec.rb index 1e742c017bbd..1cdba742a74d 100644 --- a/modules/costs/spec/lib/api/v3/work_packages/work_package_representer_spec.rb +++ b/modules/costs/spec/lib/api/v3/work_packages/work_package_representer_spec.rb @@ -73,6 +73,11 @@ embed_links: true) end + # Create a cost type that enables the log unit cost paths + let!(:cost_type) do + create(:cost_type, is_for_all: true) + end + before do allow(User).to receive(:current).and_return user end @@ -301,6 +306,38 @@ end end end + + describe "logCosts gating by available cost types" do + let(:additional_permissions) { %i[log_costs view_time_entries view_cost_entries view_cost_rates] } + + before { CostType.destroy_all } + + context "when at least one cost type is available in the project" do + before { create(:cost_type, is_for_all: true) } + + it "has the logCosts link" do + expect(subject).to have_json_path("_links/logCosts/href") + end + end + + context "when no cost type is available in the project" do + before { create(:cost_type, is_for_all: false) } + + it "omits the logCosts link" do + expect(subject).not_to have_json_path("_links/logCosts/href") + end + end + + context "when a non-global cost type is explicitly enabled in the project" do + let!(:scoped_cost_type) { create(:cost_type, is_for_all: false) } + + before { CostTypesProject.create!(project:, cost_type: scoped_cost_type) } + + it "has the logCosts link" do + expect(subject).to have_json_path("_links/logCosts/href") + end + end + end end describe "costs module disabled" do diff --git a/modules/costs/spec/models/cost_type_spec.rb b/modules/costs/spec/models/cost_type_spec.rb index 2c3b2e5879ec..4f817c4b535f 100644 --- a/modules/costs/spec/models/cost_type_spec.rb +++ b/modules/costs/spec/models/cost_type_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -29,30 +31,21 @@ require_relative "../spec_helper" RSpec.describe CostType do - let(:klass) { CostType } let(:cost_type) do - klass.new name: "ct1", - unit: "singular", - unit_plural: "plural" - end - - before do - # as the spec_helper loads fixtures and they are probably needed by other tests - # we delete them here so they do not interfere. - # on the long run, fixtures should be removed - - CostType.destroy_all + described_class.new name: "ct1", + unit: "singular", + unit_plural: "plural" end describe "class" do describe "active" do describe "WHEN a CostType instance is deleted" do before do - cost_type.deleted_at = Time.now + cost_type.deleted_at = Time.zone.now cost_type.save! end - it { expect(klass.active.size).to eq(0) } + it { expect(described_class.active.size).to eq(0) } end describe "WHEN a CostType instance is not deleted" do @@ -60,8 +53,59 @@ cost_type.save! end - it { expect(klass.active.size).to eq(1) } - it { expect(klass.active[0]).to eq(cost_type) } + it { expect(described_class.active.size).to eq(1) } + it { expect(described_class.active[0]).to eq(cost_type) } + end + end + end + + describe ".available_for_project" do + let(:project) { create(:project) } + let(:other_project) { create(:project) } + let!(:global_ct) { create(:cost_type, is_for_all: true) } + let!(:scoped_ct) { create(:cost_type, is_for_all: false) } + let!(:unrelated_ct) { create(:cost_type, is_for_all: false) } + + before do + CostTypesProject.create!(cost_type: scoped_ct, project: project) + CostTypesProject.create!(cost_type: unrelated_ct, project: other_project) + end + + it "returns global cost types plus those explicitly mapped to the project" do + expect(described_class.available_for_project(project)).to contain_exactly(global_ct, scoped_ct) + end + + it "accepts a project_id integer too" do + expect(described_class.available_for_project(project.id)).to contain_exactly(global_ct, scoped_ct) + end + end + + describe ".default_for_project" do + let(:project) { create(:project) } + + context "when the global default is available in the project" do + let!(:default_ct) { create(:cost_type, is_for_all: true, default: true) } + let!(:_other) { create(:cost_type, is_for_all: true) } + + it "returns the default" do + expect(described_class.default_for_project(project)).to eq(default_ct) + end + end + + context "when the global default is NOT available in the project" do + let!(:default_ct) { create(:cost_type, is_for_all: false, default: true) } + let!(:available) { create(:cost_type, is_for_all: true) } + + it "falls back to the first available cost type" do + expect(described_class.default_for_project(project)).to eq(available) + end + end + + context "when no cost type is available in the project" do + let!(:_unrelated) { create(:cost_type, is_for_all: false) } + + it "returns nil" do + expect(described_class.default_for_project(project)).to be_nil end end end diff --git a/modules/costs/spec/models/cost_types_project_spec.rb b/modules/costs/spec/models/cost_types_project_spec.rb new file mode 100644 index 000000000000..5248e6e5f39c --- /dev/null +++ b/modules/costs/spec/models/cost_types_project_spec.rb @@ -0,0 +1,58 @@ +# 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_relative "../spec_helper" + +RSpec.describe CostTypesProject do + let(:project) { create(:project) } + let(:cost_type) { create(:cost_type, is_for_all: false) } + + it "creates a mapping with both belongs_to associations" do + mapping = described_class.create!(project:, cost_type:) + expect(mapping.project).to eq(project) + expect(mapping.cost_type).to eq(cost_type) + end + + it "is destroyed when the cost type is destroyed" do + described_class.create!(project:, cost_type:) + expect { cost_type.destroy }.to change(described_class, :count).by(-1) + end + + it "is deleted when the project is destroyed" do + described_class.create!(project:, cost_type:) + expect { project.destroy }.to change(described_class, :count).by(-1) + end + + it "enforces uniqueness of (project_id, cost_type_id) at the DB level" do + described_class.create!(project:, cost_type:) + expect { described_class.create!(project:, cost_type:) } + .to raise_error(ActiveRecord::RecordNotUnique) + end +end diff --git a/modules/costs/spec/models/project_cost_types_available_spec.rb b/modules/costs/spec/models/project_cost_types_available_spec.rb new file mode 100644 index 000000000000..83ccadc2af34 --- /dev/null +++ b/modules/costs/spec/models/project_cost_types_available_spec.rb @@ -0,0 +1,58 @@ +# 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_relative "../spec_helper" + +RSpec.describe Project, "#cost_types_available?" do + let(:project) { create(:project) } + + before { CostType.destroy_all } + + it "is true when at least one cost type is for all projects" do + create(:cost_type, is_for_all: true) + expect(project.cost_types_available?).to be true + end + + it "is true when a cost type is explicitly enabled in the project" do + cost_type = create(:cost_type, is_for_all: false) + CostTypesProject.create!(project:, cost_type:) + expect(project.cost_types_available?).to be true + end + + it "is false when there are no cost types enabled in the project" do + create(:cost_type, is_for_all: false) + expect(project.cost_types_available?).to be false + end + + it "ignores soft-deleted cost types" do + create(:cost_type, :deleted, is_for_all: true) + expect(project.cost_types_available?).to be false + end +end diff --git a/modules/costs/spec/models/queries/projects/filters/available_cost_types_projects_filter_spec.rb b/modules/costs/spec/models/queries/projects/filters/available_cost_types_projects_filter_spec.rb new file mode 100644 index 000000000000..d994a6393090 --- /dev/null +++ b/modules/costs/spec/models/queries/projects/filters/available_cost_types_projects_filter_spec.rb @@ -0,0 +1,94 @@ +# 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_relative "../../../../spec_helper" + +RSpec.describe Queries::Projects::Filters::AvailableCostTypesProjectsFilter do + let(:instance) { described_class.create!(name: described_class.key) } + + describe ".key" do + it { expect(described_class.key).to eq(:available_cost_types_projects) } + end + + describe "#allowed_values" do + before { CostType.destroy_all } + + let!(:scoped_a) { create(:cost_type, is_for_all: false, name: "Disk") } + let!(:scoped_b) { create(:cost_type, is_for_all: false, name: "License") } + let!(:_global) { create(:cost_type, is_for_all: true, name: "Travel") } + + it "lists only non-global cost types (so filter validates even before any mapping exists)" do + expect(instance.allowed_values).to contain_exactly( + ["Disk", scoped_a.id], + ["License", scoped_b.id] + ) + end + end + + describe "filter is registered with ProjectQuery" do + it "is in the ProjectQuery filter registry" do + expect(Queries::Register.filters[ProjectQuery]).to include(described_class) + end + end + + describe "applying the filter via ProjectQuery" do + let(:admin) { create(:admin) } + let!(:cost_type) { create(:cost_type, is_for_all: false) } + let!(:mapped_project) { create(:project) } + let!(:unmapped_project) { create(:project) } + + before do + login_as(admin) + CostTypesProject.create!(cost_type:, project: mapped_project) + end + + it "returns only projects mapped to the given cost type" do + query = ProjectQuery.new(name: "t") do |q| + q.where(:available_cost_types_projects, "=", [cost_type.id]) + q.select(:name) + end + + expect(query).to be_valid + expect(query.results.pluck(:id)).to contain_exactly(mapped_project.id) + end + + it "returns no projects when the cost type has no mappings (and query stays valid)" do + cost_type_without_mappings = create(:cost_type, is_for_all: false) + + query = ProjectQuery.new(name: "t2") do |q| + q.where(:available_cost_types_projects, "=", [cost_type_without_mappings.id]) + q.select(:name) + end + + expect(query).to be_valid + expect(query.results).to be_empty + end + end +end diff --git a/modules/costs/spec/requests/projects/settings/cost_types_controller_spec.rb b/modules/costs/spec/requests/projects/settings/cost_types_controller_spec.rb new file mode 100644 index 000000000000..619bfe2f213c --- /dev/null +++ b/modules/costs/spec/requests/projects/settings/cost_types_controller_spec.rb @@ -0,0 +1,89 @@ +# 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_relative "../../../spec_helper" + +RSpec.describe Projects::Settings::CostTypesController, :skip_csrf, type: :rails_request do + shared_let(:project) { create(:project, enabled_module_names: %i[costs]) } + + let(:permissions) { %i[manage_project_activities] } + let(:user) { create(:user, member_with_permissions: { project => permissions }) } + + let!(:global_ct) { create(:cost_type, is_for_all: true) } + let!(:scoped_ct) { create(:cost_type, is_for_all: false) } + + before { login_as(user) } + + describe "GET #index" do + it "renders the list of cost types" do + get project_settings_cost_types_path(project) + expect(response).to have_http_status(:ok) + end + end + + describe "POST #toggle" do + context "with a non-global cost type not yet enabled in the project" do + it "creates the mapping" do + expect do + post toggle_project_settings_cost_type_path(project, scoped_ct) + end.to change { CostTypesProject.where(project:, cost_type: scoped_ct).count }.from(0).to(1) + expect(response).to redirect_to(project_settings_cost_types_path(project)) + end + end + + context "with a non-global cost type already enabled" do + before { CostTypesProject.create!(project:, cost_type: scoped_ct) } + + it "removes the mapping" do + expect do + post toggle_project_settings_cost_type_path(project, scoped_ct) + end.to change { CostTypesProject.where(project:, cost_type: scoped_ct).count }.from(1).to(0) + end + end + + context "with a global cost type" do + it "rejects toggling" do + expect do + post toggle_project_settings_cost_type_path(project, global_ct) + end.not_to change(CostTypesProject, :count) + expect(flash[:error]).to be_present + end + end + end + + context "when user lacks :manage_project_activities" do + let(:permissions) { %i[view_work_packages] } + + it "blocks index" do + get project_settings_cost_types_path(project) + expect(response).to have_http_status(:forbidden) + end + end +end diff --git a/modules/costs/spec/services/cost_types/cost_type_projects/bulk_create_service_spec.rb b/modules/costs/spec/services/cost_types/cost_type_projects/bulk_create_service_spec.rb new file mode 100644 index 000000000000..52034023767b --- /dev/null +++ b/modules/costs/spec/services/cost_types/cost_type_projects/bulk_create_service_spec.rb @@ -0,0 +1,43 @@ +# 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_relative "../../../spec_helper" +require Rails.root.join("spec/services/bulk_services/project_mappings/behaves_like_bulk_project_mapping_create_service") + +RSpec.describe CostTypes::CostTypeProjects::BulkCreateService do + shared_let(:cost_type) { create(:cost_type, is_for_all: false) } + + it_behaves_like "BulkServices project mappings create service" do + let(:model) { cost_type } + let(:model_mapping_class) { CostTypesProject } + let(:model_foreign_key_id) { :cost_type_id } + let(:required_permission) { :manage_project_activities } + end +end diff --git a/modules/costs/spec/services/cost_types/cost_type_projects/delete_service_spec.rb b/modules/costs/spec/services/cost_types/cost_type_projects/delete_service_spec.rb new file mode 100644 index 000000000000..2c0d8e4ecb7d --- /dev/null +++ b/modules/costs/spec/services/cost_types/cost_type_projects/delete_service_spec.rb @@ -0,0 +1,72 @@ +# 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_relative "../../../spec_helper" + +RSpec.describe CostTypes::CostTypeProjects::DeleteService do + shared_let(:project) { create(:project) } + shared_let(:cost_type) { create(:cost_type, is_for_all: false) } + + let!(:mapping) { CostTypesProject.create!(project:, cost_type:) } + + context "with an admin user" do + let(:user) { create(:admin) } + + it "removes the mapping" do + expect { described_class.new(user:, model: mapping).call } + .to change(CostTypesProject, :count).by(-1) + end + end + + context "with a user lacking permissions" do + let(:user) { create(:user, member_with_permissions: { project => %i[view_work_packages] }) } + + it "does not remove the mapping" do + result = described_class.new(user:, model: mapping).call + + expect(result).to be_failure + expect(CostTypesProject.where(id: mapping.id)).to exist + end + end + + context "when the cost type is is_for_all" do + let(:user) { create(:admin) } + + before { cost_type.update!(is_for_all: true) } + + it "refuses to delete the mapping" do + result = described_class.new(user:, model: mapping).call + + expect(result).to be_failure + expect(result.errors.symbols_for(:cost_type_id)).to include(:is_for_all_cannot_modify) + expect(CostTypesProject.where(id: mapping.id)).to exist + end + end +end