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/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..4bc09708f8ff
--- /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)
+ 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..905695a8dd25
--- /dev/null
+++ b/modules/costs/app/components/admin/cost_types/edit_form_header_component.rb
@@ -0,0 +1,77 @@
+# 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
+ return [] unless @cost_type.persisted?
+
+ [
+ {
+ 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/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..72087d81f289 100644
--- a/modules/costs/app/controllers/admin/cost_types_controller.rb
+++ b/modules/costs/app/controllers/admin/cost_types_controller.rb
@@ -38,6 +38,7 @@ class CostTypesController < ApplicationController
helper :sort
include SortHelper
+
helper :cost_types
include CostTypesHelper
@@ -65,17 +66,26 @@ def index # rubocop:disable Metrics/AbcSize
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 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,22 +93,13 @@ 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 edit_admin_cost_type_path(@cost_type)
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
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..445861be6f90
--- /dev/null
+++ b/modules/costs/app/controllers/projects/settings/cost_types_controller.rb
@@ -0,0 +1,68 @@
+# 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_redirect(error: I18n.t("activerecord.errors.messages.is_for_all_cannot_modify"))
+ 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_redirect
+ end
+
+ private
+
+ def find_cost_type
+ @cost_type = CostType.active.find(params[:id])
+ end
+
+ def respond_redirect(error: nil)
+ flash[:error] = error if error
+ flash[:notice] = I18n.t(:notice_successful_update) unless error
+ redirect_to project_settings_cost_types_path(@project)
+ 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/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..01903cd5cda9 100644
--- a/modules/costs/app/models/cost_type.rb
+++ b/modules/costs/app/models/cost_type.rb
@@ -30,6 +30,8 @@ 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 }
@@ -39,12 +41,25 @@ class CostType < ApplicationRecord
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
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/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..d540009caadf 100644
--- a/modules/costs/app/views/admin/cost_types/edit.html.erb
+++ b/modules/costs/app/views/admin/cost_types/edit.html.erb
@@ -36,17 +36,12 @@ See COPYRIGHT and LICENSE files for more details.
<% 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,
@@ -67,6 +62,9 @@ See COPYRIGHT and LICENSE files for more details.
<%= f.check_box :default %>
+
+ <%= f.check_box :is_for_all %>
+
<%= t :caption_rate_history %>
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..cc8a2930862c
--- /dev/null
+++ b/modules/costs/app/views/projects/settings/cost_types/index.html.erb
@@ -0,0 +1,77 @@
+<%#-- 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
+ )
+ )
+%>
+
+<% enabled_ids = CostTypesProject.where(project_id: @project.id).pluck(:cost_type_id).to_set %>
+
+<% if @cost_types.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 %>
+
+
+
+ | <%= CostType.model_name.human %> |
+ |
+
+
+
+ <% @cost_types.each do |cost_type| %>
+
+ | <%= h cost_type.name %> |
+
+ <% if cost_type.is_for_all? %>
+ <%= t(:label_for_all) rescue "For all projects" %>
+ <% else %>
+ <%= button_to(
+ enabled_ids.include?(cost_type.id) ? t(:button_disable) : t(:button_enable),
+ toggle_project_settings_cost_type_path(@project, cost_type),
+ method: :put,
+ class: "button",
+ form: { data: { turbo: false } }
+ ) %>
+ <% end %>
+ |
+
+ <% end %>
+
+
+<% end %>
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..f4e8385ed956 100644
--- a/modules/costs/config/locales/en.yml
+++ b/modules/costs/config/locales/en.yml
@@ -47,6 +47,8 @@ en:
unit: "Unit name"
unit_plural: "Pluralized unit name"
default: "Cost type by default"
+ is_for_all: "For all projects"
+ rates: "Rates"
work_package:
costs_by_type: "Spent units"
labor_costs: "Labor costs"
@@ -252,6 +254,24 @@ 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:
+ 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."
+ settings:
+ time_and_costs: "Time & Costs"
+ cost_types:
+ heading: "Cost types"
+ none_active: "No cost types are currently active in this project."
+
costs:
widgets:
actual_costs:
diff --git a/modules/costs/config/routes.rb b/modules/costs/config/routes.rb
index e3eea05aa91f..5fb911cbefdc 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 { put :toggle }
+ end
end
end
@@ -93,6 +96,11 @@
put :set_rate
patch :restore
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
resource :costs,
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..b414babec7f5 100644
--- a/modules/costs/lib/costs/patches/permitted_params_patch.rb
+++ b/modules/costs/lib/costs/patches/permitted_params_patch.rb
@@ -56,6 +56,7 @@ def cost_type
:unit,
:unit_plural,
:default,
+ :is_for_all,
{ 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/costs_context_menu_spec.rb b/modules/costs/spec/features/costs_context_menu_spec.rb
index f075b3d1484e..77780e0faafd 100644
--- a/modules/costs/spec/features/costs_context_menu_spec.rb
+++ b/modules/costs/spec/features/costs_context_menu_spec.rb
@@ -3,6 +3,7 @@
RSpec.describe "Work package table log unit costs", :js do
let(:user) { create(:admin) }
let(:work_package) { create(:work_package) }
+ let!(:cost_type) { create(:cost_type, is_for_all: true) }
let(:wp_table) { Pages::WorkPackagesTable.new }
let(:menu) { Components::WorkPackages::ContextMenu.new }
diff --git a/modules/costs/spec/features/time_entry/activity_spec.rb b/modules/costs/spec/features/time_entry/activity_spec.rb
index 5cbf6029139d..3cd636ed305d 100644
--- a/modules/costs/spec/features/time_entry/activity_spec.rb
+++ b/modules/costs/spec/features/time_entry/activity_spec.rb
@@ -62,7 +62,7 @@
visit project_settings_general_path(project)
- click_on "Time tracking activities"
+ click_on I18n.t("cost_types.settings.time_and_costs")
expect(page).to have_field("Development", checked: true)
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..466a094ed030
--- /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 "PUT #toggle" do
+ context "with a non-global cost type not yet enabled in the project" do
+ it "creates the mapping" do
+ expect do
+ put 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
+ put 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
+ put 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