diff --git a/app/assets/javascripts/custom.js b/app/assets/javascripts/custom.js index 3ab29d091..743c3a344 100644 --- a/app/assets/javascripts/custom.js +++ b/app/assets/javascripts/custom.js @@ -11,6 +11,7 @@ var initialize_modules = function() { // Add calls to your custom code here; this will be called when // loading a page. App.BudgetSets.initialize(); + App.Sectors.initialize(); }; var destroy_non_idempotent_modules = function() { diff --git a/app/assets/javascripts/custom/sectors.js b/app/assets/javascripts/custom/sectors.js new file mode 100644 index 000000000..786825257 --- /dev/null +++ b/app/assets/javascripts/custom/sectors.js @@ -0,0 +1,31 @@ +(function() { + "use strict"; + App.Sectors = { + // Keeps only the sub-sectors of the selected sector in the sub-sector select + filterSubSectors: function(sectorSelect) { + var subSectorSelect = $(sectorSelect.data("sub-sector-select")); + var allOptions = subSectorSelect.data("all-options"); + var sectorId = String(sectorSelect.val()); + var selected = subSectorSelect.val(); + + if (!allOptions) { + allOptions = subSectorSelect.find("option[data-sector-id]").clone(); + subSectorSelect.data("all-options", allOptions); + } + + subSectorSelect.find("optgroup, option[data-sector-id]").remove(); + allOptions.filter(function() { + return String($(this).data("sector-id")) === sectorId; + }).clone().appendTo(subSectorSelect); + + subSectorSelect.val(subSectorSelect.find("option[value='" + selected + "']").length ? selected : ""); + }, + initialize: function() { + $(".js-sector-select").each(function() { + App.Sectors.filterSubSectors($(this)); + }).off("change.sectors").on("change.sectors", function() { + App.Sectors.filterSubSectors($(this)); + }); + } + }; +}).call(this); diff --git a/app/assets/stylesheets/custom/sectors.scss b/app/assets/stylesheets/custom/sectors.scss new file mode 100644 index 000000000..66750720b --- /dev/null +++ b/app/assets/stylesheets/custom/sectors.scss @@ -0,0 +1,46 @@ +.admin-sidebar > ul > li > .sectors-link { + @include has-fa-icon(tags, solid); + + &::before { + @extend %admin-menu-icon; + } +} + +.admin .sectors-table .sub-sector td:first-child { + color: $text-medium; +} + +.admin .project-details-fields { + margin-top: $line-height; +} + +.investment-project-details { + margin: $line-height 0; + + dl { + display: grid; + gap: calc($line-height / 2) $line-height; + grid-template-columns: 1fr; + + @include breakpoint(medium) { + grid-template-columns: repeat(2, 1fr); + } + } + + .project-detail { + border-#{$global-left}: 3px solid $border; + padding-#{$global-left}: calc($line-height / 2); + } + + dt { + color: $text-medium; + font-size: $small-font-size; + font-weight: normal; + margin-bottom: 0; + } + + dd { + font-weight: bold; + margin: 0; + } +} diff --git a/app/components/custom/admin/menu_component.html.erb b/app/components/custom/admin/menu_component.html.erb new file mode 100644 index 000000000..0edc468b4 --- /dev/null +++ b/app/components/custom/admin/menu_component.html.erb @@ -0,0 +1 @@ +<%= link_list(*links, id: "admin_menu") %> diff --git a/app/components/custom/admin/menu_component.rb b/app/components/custom/admin/menu_component.rb new file mode 100644 index 000000000..081d2c494 --- /dev/null +++ b/app/components/custom/admin/menu_component.rb @@ -0,0 +1,25 @@ +class Admin::MenuComponent < ApplicationComponent; end + +load Rails.root.join("app", "components", "admin", "menu_component.rb") + +class Admin::MenuComponent + private + + alias_method :consul_default_links, :default_links + + def default_links + links = consul_default_links + budgets_index = links.index { |link| link.is_a?(Array) && link.last.try(:[], :class) == "budgets-link" } + + budgets_index ? links.insert(budgets_index + 1, sectors_link) : links + end + + def sectors_link + [ + t("admin.menu.sectors"), + admin_sectors_path, + controller_name == "sectors", + class: "sectors-link" + ] + end +end diff --git a/app/components/custom/budgets/investments/project_details_component.html.erb b/app/components/custom/budgets/investments/project_details_component.html.erb new file mode 100644 index 000000000..e45219255 --- /dev/null +++ b/app/components/custom/budgets/investments/project_details_component.html.erb @@ -0,0 +1,12 @@ +
+

<%= t("budgets.investments.project_details.title") %>

+ +
+ <% details.each do |field, value| %> +
+
<%= t("budgets.investments.project_details.#{field}") %>
+
<%= value %>
+
+ <% end %> +
+
diff --git a/app/components/custom/budgets/investments/project_details_component.rb b/app/components/custom/budgets/investments/project_details_component.rb new file mode 100644 index 000000000..693c45b3f --- /dev/null +++ b/app/components/custom/budgets/investments/project_details_component.rb @@ -0,0 +1,52 @@ +class Budgets::Investments::ProjectDetailsComponent < ApplicationComponent + attr_reader :investment + + def initialize(investment) + @investment = investment + end + + def render? + details.any? + end + + private + + def details + @details ||= { + sector: sector, + status: status&.name, + implementation: implementation, + price: (money(investment.price) if show_costs?), + actual_cost: (money(investment.actual_cost) if show_costs? && investment.actual_cost.present?), + start_date: month(investment.start_date), + end_date: month(investment.end_date), + funding_source: investment.funding_source.presence + }.compact + end + + def sector + [investment.sector&.name, investment.sub_sector&.name].compact.join(" › ").presence + end + + def status + investment.milestones.published.with_status.order_by_publication_date.last&.status + end + + def implementation + percentage = investment.primary_progress_bar&.percentage + "#{percentage}%" if percentage + end + + # Same rule Consul uses to show the price of an investment + def show_costs? + investment.should_show_price? + end + + def money(amount) + investment.budget.formatted_amount(amount) + end + + def month(date) + l(date, format: :month_year) if date + end +end diff --git a/app/controllers/custom/admin/budget_investments_controller.rb b/app/controllers/custom/admin/budget_investments_controller.rb new file mode 100644 index 000000000..b308f5e88 --- /dev/null +++ b/app/controllers/custom/admin/budget_investments_controller.rb @@ -0,0 +1,13 @@ +load Rails.root.join("app", "controllers", "admin", "budget_investments_controller.rb") + +class Admin::BudgetInvestmentsController + private + + alias_method :consul_allowed_params, :allowed_params + + def allowed_params + project_attributes = [:sector_id, :sub_sector_id, :funding_source, :actual_cost, :start_date, :end_date] + + [*project_attributes, *consul_allowed_params] + end +end diff --git a/app/controllers/custom/admin/sectors_controller.rb b/app/controllers/custom/admin/sectors_controller.rb new file mode 100644 index 000000000..df0eecef8 --- /dev/null +++ b/app/controllers/custom/admin/sectors_controller.rb @@ -0,0 +1,54 @@ +class Admin::SectorsController < Admin::BaseController + include FeatureFlags + + feature_flag :budgets + + before_action :load_sector, only: [:edit, :update, :destroy] + + def index + @sectors = Sector.main.includes(:sub_sectors) + end + + def new + @sector = Sector.new(parent_id: params[:parent_id]) + end + + def create + @sector = Sector.new(sector_params) + + if @sector.save + redirect_to admin_sectors_path, notice: t("admin.sectors.create.notice") + else + render :new + end + end + + def edit + end + + def update + if @sector.update(sector_params) + redirect_to admin_sectors_path, notice: t("admin.sectors.update.notice") + else + render :edit + end + end + + def destroy + if @sector.destroy + redirect_to admin_sectors_path, notice: t("admin.sectors.destroy.notice") + else + redirect_to admin_sectors_path, alert: t("admin.sectors.destroy.unable_notice") + end + end + + private + + def load_sector + @sector = Sector.find(params[:id]) + end + + def sector_params + params.require(:sector).permit(:name, :parent_id) + end +end diff --git a/app/helpers/sectors_helper.rb b/app/helpers/sectors_helper.rb new file mode 100644 index 000000000..9c1969cba --- /dev/null +++ b/app/helpers/sectors_helper.rb @@ -0,0 +1,19 @@ +module SectorsHelper + def sector_select_options + Sector.main.map { |sector| [sector.name, sector.id] } + end + + # Sub-sectors grouped by sector; each option knows its sector so the list + # can be filtered when a sector is selected + def sub_sector_select_options(selected = nil) + grouped_options = Sector.main.includes(:sub_sectors).map do |sector| + options = sector.sub_sectors.map do |sub_sector| + [sub_sector.name, sub_sector.id, { data: { sector_id: sector.id }}] + end + + [sector.name, options] + end + + grouped_options_for_select(grouped_options, selected) + end +end diff --git a/app/models/custom/budget/investment.rb b/app/models/custom/budget/investment.rb index fdd250200..ed7223246 100644 --- a/app/models/custom/budget/investment.rb +++ b/app/models/custom/budget/investment.rb @@ -2,6 +2,16 @@ load Rails.root.join("app", "models", "budget", "investment.rb") class Budget class Investment + translates :objectives, touch: true + + belongs_to :sector, optional: true + belongs_to :sub_sector, class_name: "Sector", optional: true + + validates :actual_cost, numericality: { greater_than_or_equal_to: 0, allow_nil: true } + validate :sector_is_a_main_sector + validate :sub_sector_belongs_to_sector + validate :end_date_after_start_date + def self.apply_filters_and_search(_budget, params, current_filter = nil) investments = all investments = investments.send(current_filter) if current_filter.present? @@ -11,5 +21,21 @@ class Budget investments = investments.filter_by(params[:advanced_search]) investments end + + private + + def sector_is_a_main_sector + errors.add(:sector_id, :invalid) if sector && !sector.main? + end + + def sub_sector_belongs_to_sector + errors.add(:sub_sector_id, :invalid) if sub_sector && sub_sector.parent_id != sector_id + end + + def end_date_after_start_date + if start_date && end_date && end_date < start_date + errors.add(:end_date, :invalid) + end + end end end diff --git a/app/models/custom/milestone.rb b/app/models/custom/milestone.rb new file mode 100644 index 000000000..c3ac892ae --- /dev/null +++ b/app/models/custom/milestone.rb @@ -0,0 +1,6 @@ +load Rails.root.join("app", "models", "milestone.rb") + +class Milestone + # The milestone status is shown in cached views of the milestoneable + after_commit -> { milestoneable&.touch unless milestoneable&.destroyed? } +end diff --git a/app/models/custom/progress_bar.rb b/app/models/custom/progress_bar.rb new file mode 100644 index 000000000..39db8aea4 --- /dev/null +++ b/app/models/custom/progress_bar.rb @@ -0,0 +1,6 @@ +load Rails.root.join("app", "models", "progress_bar.rb") + +class ProgressBar + # The primary progress bar is shown in cached views of the progressable + after_commit -> { progressable&.touch unless progressable&.destroyed? } +end diff --git a/app/models/custom/sector.rb b/app/models/custom/sector.rb new file mode 100644 index 000000000..3757d36d3 --- /dev/null +++ b/app/models/custom/sector.rb @@ -0,0 +1,27 @@ +class Sector < ApplicationRecord + belongs_to :parent, class_name: "Sector", optional: true, inverse_of: :sub_sectors + has_many :sub_sectors, -> { order(:name) }, class_name: "Sector", foreign_key: :parent_id, + inverse_of: :parent, dependent: :restrict_with_error + has_many :investments, class_name: "Budget::Investment", dependent: :restrict_with_error + has_many :sub_sector_investments, class_name: "Budget::Investment", foreign_key: :sub_sector_id, + inverse_of: :sub_sector, dependent: :restrict_with_error + + validates :name, presence: true, uniqueness: { scope: :parent_id, case_sensitive: false } + validate :parent_is_a_main_sector + + scope :main, -> { where(parent_id: nil).order(:name) } + + def main? + parent_id.nil? + end + + def self.find_or_create_by_name!(name, parent: nil) + where(parent: parent).where("LOWER(name) = ?", name.downcase).first || create!(name: name, parent: parent) + end + + private + + def parent_is_a_main_sector + errors.add(:parent_id, :invalid) if parent && !parent.main? + end +end diff --git a/app/views/custom/admin/budget_investments/edit.html.erb b/app/views/custom/admin/budget_investments/edit.html.erb new file mode 100644 index 000000000..422e0b134 --- /dev/null +++ b/app/views/custom/admin/budget_investments/edit.html.erb @@ -0,0 +1,161 @@ +<%= link_to admin_budget_budget_investment_path(@budget, @investment, Budget::Investment.filter_params(params).to_h), class: "back" do %> + <%= t("shared.back") %> +<% end %> + +<%= render "shared/globalize_locales", resource: @investment %> + +
+ <%= translatable_form_for @investment, + url: admin_budget_budget_investment_path(@budget, @investment) do |f| %> + + <%= render "shared/errors", resource: @investment %> + + <% Budget::Investment.filter_params(params).to_h.each do |filter_name, filter_value| %> + <%= hidden_field_tag filter_name, filter_value %> + <% end %> + +
+ <%= f.translatable_fields do |translations_form| %> +
+ <%= translations_form.text_field :title, + maxlength: Budget::Investment.title_max_length %> +
+ +
+ <%= translations_form.text_area :description, + maxlength: Budget::Investment.description_max_length, + class: "html-area" %> +
+ +
+ <%= translations_form.text_area :objectives, rows: 3 %> +
+ <% end %> + +
+ <%= f.text_field :tag_list, + value: @investment.tag_list.sort.join(","), + label: t("admin.budget_investments.edit.user_tags") %> +
+ +
+
+ <%= f.text_field :external_url %> +
+ +
+ <%= f.select :heading_id, budget_heading_select_options(@budget), include_blank: t("admin.budget_investments.edit.select_heading") %> +
+
+
+ +
+
+

<%= t("admin.budget_investments.edit.project_details") %>

+

+ <%= link_to t("admin.budget_investments.edit.manage_sectors"), admin_sectors_path %> +

+
+ +
+ <%= f.select :sector_id, + sector_select_options, + { include_blank: t("admin.budget_investments.edit.select_sector") }, + class: "js-sector-select", data: { sub_sector_select: "#budget_investment_sub_sector_id" } %> +
+ +
+ <%= f.select :sub_sector_id, + sub_sector_select_options(@investment.sub_sector_id), + { include_blank: t("admin.budget_investments.edit.select_sub_sector") }, + class: "js-sub-sector-select" %> +
+ +
+ <%= f.text_field :funding_source %> +
+ +
+ <%= f.number_field :actual_cost, min: 0, step: 1 %> +
+ +
+ <%= f.date_field :start_date %> +
+ +
+ <%= f.date_field :end_date %> +
+
+ +
+ +
+

<%= t("admin.budget_investments.edit.classification") %>

+ +
+ <%= f.select(:administrator_id, + @admins.map { |a| [a.description_or_name_and_email, a.id] }, + { include_blank: t("admin.budget_investments.edit.undefined") }) %> +
+
+ +
+ <%= f.label :valuation_tag_list %> +
+ <% @tags.each do |tag| %> + <%= tag.name %> + <% end %> +
+ <%= f.text_field :valuation_tag_list, + value: @investment.valuation_tag_list.sort.join(","), + label: false, + placeholder: t("admin.budget_investments.edit.tags_placeholder"), + class: "js-tag-list" %> +
+ +
+ <%= f.label :valuator_ids %> +
    + <%= f.collection_check_boxes :valuator_group_ids, @valuator_groups, :id, :name do |group| %> +
  • <%= group.label(title: group.object.name) { group.check_box + truncate(group.object.name, length: 60) } %>
  • + <% end %> +
+
+ +
+
+ <%= f.label :valuator_ids, t("admin.budget_investments.edit.assigned_valuators") %> +
    + <%= f.collection_check_boxes :valuator_ids, @valuators, :id, :email do |b| %> +
  • <%= b.label(title: valuator_label(b.object)) { b.check_box + truncate(b.object.description_or_email, length: 60) } %>
  • + <% end %> +
+
+
+ +
+ <% if @investment.incompatible? || @investment.winner? %> +
+

<%= t("admin.budget_investments.edit.compatibility") %>

+ <%= f.check_box :incompatible, + title: t("admin.budget_investments.edit.compatibility") %> +
+ <% end %> +
+

<%= t("admin.budget_investments.edit.selection") %>

+ <%= f.check_box :selected, + title: t("admin.budget_investments.edit.selection") %> +
+
+ +
+ <%= f.text_field :milestone_tag_list, + value: @investment.milestone_tag_list.sort.join(", ") %> +
+ +
+ <%= f.submit(class: "button", value: t("admin.budget_investments.edit.submit_button")) %> +
+ <% end %> +
diff --git a/app/views/custom/admin/sectors/_form.html.erb b/app/views/custom/admin/sectors/_form.html.erb new file mode 100644 index 000000000..3b5c9ba00 --- /dev/null +++ b/app/views/custom/admin/sectors/_form.html.erb @@ -0,0 +1,19 @@ +<%= form_for [:admin, @sector] do |f| %> + <%= render "shared/errors", resource: @sector %> + +
+ <%= f.text_field :name %> +
+ +
+ <%= f.select :parent_id, + Sector.main.where.not(id: @sector.id).map { |sector| [sector.name, sector.id] }, + { include_blank: t("admin.sectors.form.no_parent"), + hint: t("admin.sectors.form.parent_hint") }, + disabled: @sector.sub_sectors.any? %> +
+ +
+ <%= f.submit t("admin.sectors.form.submit"), class: "button success" %> +
+<% end %> diff --git a/app/views/custom/admin/sectors/edit.html.erb b/app/views/custom/admin/sectors/edit.html.erb new file mode 100644 index 000000000..6b921d0ec --- /dev/null +++ b/app/views/custom/admin/sectors/edit.html.erb @@ -0,0 +1,5 @@ +<%= back_link_to admin_sectors_path %> + +

<%= t("admin.sectors.edit.title") %>

+ +<%= render "form" %> diff --git a/app/views/custom/admin/sectors/index.html.erb b/app/views/custom/admin/sectors/index.html.erb new file mode 100644 index 000000000..6c77528cd --- /dev/null +++ b/app/views/custom/admin/sectors/index.html.erb @@ -0,0 +1,43 @@ +

<%= t("admin.sectors.index.title") %>

+ +<%= link_to t("admin.sectors.index.new_sector"), new_admin_sector_path, class: "button float-right" %> + +

<%= t("admin.sectors.index.help") %>

+ +<% if @sectors.any? %> + + + + + + + + + + <% @sectors.each do |sector| %> + + + + + + <% sector.sub_sectors.each do |sub_sector| %> + + + + + + <% end %> + <% end %> + +
<%= t("admin.sectors.index.table_name") %><%= t("admin.sectors.index.table_sub_sectors") %><%= t("admin.actions.actions") %>
<%= sector.name %><%= sector.sub_sectors.size %> + <%= render Admin::TableActionsComponent.new(sector) do |actions| %> + <%= actions.action(:new, + text: t("admin.sectors.index.new_sub_sector"), + path: new_admin_sector_path(parent_id: sector.id)) %> + <% end %> +
 ↳ <%= sub_sector.name %><%= render Admin::TableActionsComponent.new(sub_sector) %>
+<% else %> +
+ <%= t("admin.sectors.index.empty") %> +
+<% end %> diff --git a/app/views/custom/admin/sectors/new.html.erb b/app/views/custom/admin/sectors/new.html.erb new file mode 100644 index 000000000..0645dc9d3 --- /dev/null +++ b/app/views/custom/admin/sectors/new.html.erb @@ -0,0 +1,5 @@ +<%= back_link_to admin_sectors_path %> + +

<%= t("admin.sectors.new.title") %>

+ +<%= render "form" %> diff --git a/app/views/custom/budgets/investments/_investment_detail.html.erb b/app/views/custom/budgets/investments/_investment_detail.html.erb new file mode 100644 index 000000000..abd42084c --- /dev/null +++ b/app/views/custom/budgets/investments/_investment_detail.html.erb @@ -0,0 +1,84 @@ +

<%= investment.title %>

+
+ <%= render "/shared/author_info", resource: investment %> + +  •  + <%= l investment.created_at.to_date %> +  •  + <%= render Shared::CommentsCountComponent.new(investment.comments_count, url: "#comments") %> +  •  + <%= investment.heading.name %> +  •  + <% if local_assigns[:preview].nil? %> + + <%= render "shared/flag_actions", flaggable: investment %> + + <% end %> +
+ +
+ +<%= render_image(investment.image, :large, true) if investment.image.present? %> + +

+ <%= sanitize(t("budgets.investments.show.code", code: investment.id)) %> +

+ +<%= auto_link_already_sanitized_html wysiwyg(investment.description) %> + +<% if investment.objectives.present? %> +

<%= t("budgets.investments.project_details.objectives") %>

+ <%= simple_format(investment.objectives) %> +<% end %> + +<%= render Budgets::Investments::ProjectDetailsComponent.new(investment) %> + +<% if feature?(:map) && map_location_available?(@investment.map_location) %> +
+ <%= render_map(investment.map_location) %> +
+<% end %> + +<% if investment.location.present? %> +

+ <%= sanitize(t("budgets.investments.show.location", location: investment.location)) %> +

+<% end %> + +<% if investment.organization_name.present? %> +

+ <%= sanitize(t("budgets.investments.show.organization_name", name: investment.organization_name)) %> +

+<% end %> + +<% if feature?(:allow_attached_documents) %> + <%= render Documents::DocumentsComponent.new(investment.documents) %> +<% end %> + +<%= render "shared/tags", taggable: investment %> + +<% if investment.external_url.present? %> + +<% end %> + +<% if investment.should_show_unfeasibility_explanation? %> +

<%= t("budgets.investments.show.unfeasibility_explanation") %>

+

<%= investment.unfeasibility_explanation %>

+<% end %> + +<% if investment.should_show_price_explanation? %> +

+ <%= t("budgets.investments.show.price_explanation") %> +

+

<%= investment.price_explanation %>

+<% end %> + +<% if local_assigns[:preview].nil? %> + <%= render "relationable/related_content", relationable: investment %> + +
+ <%= render "budgets/investments/actions", investment: investment %> +
+<% end %> diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 8fe6467f7..d087bc1e6 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -140,6 +140,7 @@ ignore_unused: - "activemodel.*" - "attributes.*" - "date.order" + - "date.formats.month_year" - "unauthorized.*" - "admin.officials.level_*" - "admin.hidden_comments.index.filter*" diff --git a/config/locales/custom/en/budgets.yml b/config/locales/custom/en/budgets.yml index 1e3807c01..70c7ecb0d 100644 --- a/config/locales/custom/en/budgets.yml +++ b/config/locales/custom/en/budgets.yml @@ -1,4 +1,7 @@ en: + date: + formats: + month_year: "%B %Y" activerecord: attributes: budget/investment: diff --git a/config/locales/custom/en/projects.yml b/config/locales/custom/en/projects.yml new file mode 100644 index 000000000..f40fe8c28 --- /dev/null +++ b/config/locales/custom/en/projects.yml @@ -0,0 +1,65 @@ +en: + activerecord: + models: + sector: + one: "Sector" + other: "Sectors" + attributes: + budget/investment: + sector_id: "Sector" + sub_sector_id: "Sub-sector" + funding_source: "Source of funds" + actual_cost: "Actual cost" + start_date: "Start date" + end_date: "End date" + budget/investment/translation: + objectives: "Objectives" + sector: + name: "Name" + parent_id: "Parent sector" + admin: + menu: + sectors: "Sectors" + budget_investments: + edit: + project_details: "Project details" + manage_sectors: "Manage sectors and sub-sectors" + select_sector: "Select sector" + select_sub_sector: "Select sub-sector" + sectors: + index: + title: "Sectors" + help: "Sectors and sub-sectors are used to classify investment projects. Each sub-sector belongs to a sector." + new_sector: "Create sector" + new_sub_sector: "Add sub-sector" + table_name: "Name" + table_sub_sectors: "Sub-sectors" + empty: "There are no sectors yet." + new: + title: "New sector or sub-sector" + edit: + title: "Edit sector" + form: + no_parent: "None (this is a main sector)" + parent_hint: "Choose a parent sector to create a sub-sector." + submit: "Save sector" + create: + notice: "Sector created successfully" + update: + notice: "Sector updated successfully" + destroy: + notice: "Sector deleted successfully" + unable_notice: "This sector cannot be deleted because it has sub-sectors or investment projects" + budgets: + investments: + project_details: + title: "Project details" + objectives: "Objectives" + sector: "Sector" + status: "Status" + implementation: "Implementation" + price: "Project cost" + actual_cost: "Actual cost" + start_date: "Start date" + end_date: "End date" + funding_source: "Source of funds" diff --git a/config/locales/custom/es/budgets.yml b/config/locales/custom/es/budgets.yml index 7b7b5a9d1..521c1e0c8 100644 --- a/config/locales/custom/es/budgets.yml +++ b/config/locales/custom/es/budgets.yml @@ -1,4 +1,7 @@ es: + date: + formats: + month_year: "%B de %Y" activerecord: attributes: budget/investment: diff --git a/config/locales/custom/es/projects.yml b/config/locales/custom/es/projects.yml new file mode 100644 index 000000000..0051ad139 --- /dev/null +++ b/config/locales/custom/es/projects.yml @@ -0,0 +1,65 @@ +es: + activerecord: + models: + sector: + one: "Sector" + other: "Sectores" + attributes: + budget/investment: + sector_id: "Sector" + sub_sector_id: "Subsector" + funding_source: "Fuente de financiación" + actual_cost: "Coste real" + start_date: "Fecha de inicio" + end_date: "Fecha de fin" + budget/investment/translation: + objectives: "Objetivos" + sector: + name: "Nombre" + parent_id: "Sector padre" + admin: + menu: + sectors: "Sectores" + budget_investments: + edit: + project_details: "Datos del proyecto" + manage_sectors: "Gestionar sectores y subsectores" + select_sector: "Seleccionar sector" + select_sub_sector: "Seleccionar subsector" + sectors: + index: + title: "Sectores" + help: "Los sectores y subsectores sirven para clasificar los proyectos de gasto. Cada subsector pertenece a un sector." + new_sector: "Crear sector" + new_sub_sector: "Añadir subsector" + table_name: "Nombre" + table_sub_sectors: "Subsectores" + empty: "Todavía no hay sectores." + new: + title: "Nuevo sector o subsector" + edit: + title: "Editar sector" + form: + no_parent: "Ninguno (es un sector principal)" + parent_hint: "Elige un sector padre para crear un subsector." + submit: "Guardar sector" + create: + notice: "Sector creado correctamente" + update: + notice: "Sector actualizado correctamente" + destroy: + notice: "Sector eliminado correctamente" + unable_notice: "No se puede eliminar este sector porque tiene subsectores o proyectos de gasto" + budgets: + investments: + project_details: + title: "Datos del proyecto" + objectives: "Objetivos" + sector: "Sector" + status: "Estado" + implementation: "Ejecución" + price: "Coste del proyecto" + actual_cost: "Coste real" + start_date: "Fecha de inicio" + end_date: "Fecha de fin" + funding_source: "Fuente de financiación" diff --git a/config/locales/custom/sw-KE/budgets.yml b/config/locales/custom/sw-KE/budgets.yml index e3a5c706f..49c642da2 100644 --- a/config/locales/custom/sw-KE/budgets.yml +++ b/config/locales/custom/sw-KE/budgets.yml @@ -1,4 +1,7 @@ sw-KE: + date: + formats: + month_year: "%B %Y" activerecord: attributes: budget/investment: diff --git a/config/locales/custom/sw-KE/projects.yml b/config/locales/custom/sw-KE/projects.yml new file mode 100644 index 000000000..855a99a86 --- /dev/null +++ b/config/locales/custom/sw-KE/projects.yml @@ -0,0 +1,29 @@ +sw-KE: + activerecord: + models: + sector: + one: "Sekta" + other: "Sekta" + attributes: + budget/investment: + sector_id: "Sekta" + sub_sector_id: "Sekta ndogo" + funding_source: "Chanzo cha fedha" + actual_cost: "Gharama halisi" + start_date: "Tarehe ya kuanza" + end_date: "Tarehe ya kumaliza" + budget/investment/translation: + objectives: "Malengo" + budgets: + investments: + project_details: + title: "Maelezo ya mradi" + objectives: "Malengo" + sector: "Sekta" + status: "Hali" + implementation: "Utekelezaji" + price: "Gharama ya mradi" + actual_cost: "Gharama halisi" + start_date: "Tarehe ya kuanza" + end_date: "Tarehe ya kumaliza" + funding_source: "Chanzo cha fedha" diff --git a/config/routes/custom.rb b/config/routes/custom.rb index 6c39d74df..288fb35a8 100644 --- a/config/routes/custom.rb +++ b/config/routes/custom.rb @@ -39,6 +39,8 @@ constraints lambda { |request| !Rails.application.multitenancy_management_mode? resources :budgets, only: [] do resources :sets, except: [:index, :show], controller: "budget_sets" end + + resources :sectors, except: :show end end diff --git a/db/migrate/20261008100000_add_nairobi_project_fields_to_budget_investments.rb b/db/migrate/20261008100000_add_nairobi_project_fields_to_budget_investments.rb new file mode 100644 index 000000000..5c4aee1f8 --- /dev/null +++ b/db/migrate/20261008100000_add_nairobi_project_fields_to_budget_investments.rb @@ -0,0 +1,22 @@ +class AddNairobiProjectFieldsToBudgetInvestments < ActiveRecord::Migration[7.1] + def change + create_table :sectors do |t| + t.string :name, null: false + t.references :parent, foreign_key: { to_table: :sectors }, index: true + + t.timestamps + end + add_index :sectors, [:parent_id, :name], unique: true + + change_table :budget_investments, bulk: true do |t| + t.bigint :actual_cost + t.date :start_date + t.date :end_date + t.string :funding_source + t.references :sector, foreign_key: true, index: true + t.references :sub_sector, foreign_key: { to_table: :sectors }, index: true + end + + add_column :budget_investment_translations, :objectives, :text + end +end diff --git a/db/schema.rb b/db/schema.rb index 711600928..921ce33a9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_10_05_120000) do +ActiveRecord::Schema[7.1].define(version: 2026_10_08_100000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_trgm" enable_extension "plpgsql" @@ -249,6 +249,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_10_05_120000) do t.string "title" t.text "description" t.datetime "hidden_at", precision: nil + t.text "objectives" t.index ["budget_investment_id"], name: "index_budget_investment_translations_on_budget_investment_id" t.index ["hidden_at"], name: "index_budget_investment_translations_on_hidden_at" t.index ["locale"], name: "index_budget_investment_translations_on_locale" @@ -293,6 +294,12 @@ ActiveRecord::Schema[7.1].define(version: 2026_10_05_120000) do t.datetime "ignored_flag_at", precision: nil t.integer "flags_count", default: 0 t.integer "original_heading_id" + t.bigint "actual_cost" + t.date "start_date" + t.date "end_date" + t.string "funding_source" + t.bigint "sector_id" + t.bigint "sub_sector_id" t.index ["administrator_id"], name: "index_budget_investments_on_administrator_id" t.index ["author_id"], name: "index_budget_investments_on_author_id" t.index ["budget_id"], name: "index_budget_investments_on_budget_id" @@ -300,7 +307,9 @@ ActiveRecord::Schema[7.1].define(version: 2026_10_05_120000) do t.index ["group_id"], name: "index_budget_investments_on_group_id" t.index ["heading_id"], name: "index_budget_investments_on_heading_id" t.index ["incompatible"], name: "index_budget_investments_on_incompatible" + t.index ["sector_id"], name: "index_budget_investments_on_sector_id" t.index ["selected"], name: "index_budget_investments_on_selected" + t.index ["sub_sector_id"], name: "index_budget_investments_on_sub_sector_id" t.index ["tsv"], name: "index_budget_investments_on_tsv", using: :gin end @@ -1460,6 +1469,15 @@ ActiveRecord::Schema[7.1].define(version: 2026_10_05_120000) do t.index ["goal_id"], name: "index_sdg_targets_on_goal_id" end + create_table "sectors", force: :cascade do |t| + t.string "name", null: false + t.bigint "parent_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["parent_id", "name"], name: "index_sectors_on_parent_id_and_name", unique: true + t.index ["parent_id"], name: "index_sectors_on_parent_id" + end + create_table "settings", id: :serial, force: :cascade do |t| t.string "key" t.string "value" @@ -1795,6 +1813,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_10_05_120000) do add_foreign_key "budget_groups", "budget_sets", column: "set_id" add_foreign_key "budget_headings", "geozones" add_foreign_key "budget_investments", "communities" + add_foreign_key "budget_investments", "sectors" + add_foreign_key "budget_investments", "sectors", column: "sub_sector_id" add_foreign_key "budget_set_translations", "budget_sets" add_foreign_key "budget_sets", "budgets" add_foreign_key "budget_valuators", "budgets" @@ -1841,6 +1861,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_10_05_120000) do add_foreign_key "related_content_scores", "related_contents" add_foreign_key "related_content_scores", "users" add_foreign_key "sdg_managers", "users" + add_foreign_key "sectors", "sectors", column: "parent_id" add_foreign_key "users", "geozones" add_foreign_key "valuators", "users" end diff --git a/spec/components/custom/budgets/investments/project_details_component_spec.rb b/spec/components/custom/budgets/investments/project_details_component_spec.rb new file mode 100644 index 000000000..81109c7eb --- /dev/null +++ b/spec/components/custom/budgets/investments/project_details_component_spec.rb @@ -0,0 +1,61 @@ +require "rails_helper" + +describe Budgets::Investments::ProjectDetailsComponent do + let(:sector) { create(:sector, name: "Business and Hustler Opportunities") } + let(:investment) do + create(:budget_investment, :winner, + price: 242_592_913, + actual_cost: 194_000_000, + start_date: Date.new(2024, 2, 1), + end_date: Date.new(2025, 2, 1), + funding_source: "NCCG", + sector: sector, + sub_sector: create(:sector, parent: sector, name: "Markets and Trading Services")) + end + + it "shows the project details" do + investment.budget.update!(currency_symbol: "KSh", phase: "finished") + create(:progress_bar, progressable: investment, percentage: 85) + create(:milestone, milestoneable: investment, status: create(:milestone_status, name: "Delayed")) + + render_inline Budgets::Investments::ProjectDetailsComponent.new(investment) + + expect(page).to have_css "h2", exact_text: "Project details" + expect(page).to have_css "dt", exact_text: "Sector" + expect(page).to have_css "dd", + exact_text: "Business and Hustler Opportunities › Markets and Trading Services" + expect(page).to have_css "dd", exact_text: "Delayed" + expect(page).to have_css "dd", exact_text: "85%" + expect(page).to have_css "dd", text: "242,592,913" + expect(page).to have_css "dd", text: "194,000,000" + expect(page).to have_css "dd", exact_text: "February 2024" + expect(page).to have_css "dd", exact_text: "February 2025" + expect(page).to have_css "dd", exact_text: "NCCG" + end + + it "does not show the costs before prices are published" do + investment.budget.update!(phase: "accepting") + + render_inline Budgets::Investments::ProjectDetailsComponent.new(investment) + + expect(page).not_to have_content "Project cost" + expect(page).not_to have_content "Actual cost" + expect(page).to have_content "NCCG" + end + + it "does not show the costs when the budget hides money" do + investment.budget.update!(voting_style: "approval", hide_money: true) + + render_inline Budgets::Investments::ProjectDetailsComponent.new(investment) + + expect(page).not_to have_content "Project cost" + expect(page).not_to have_content "Actual cost" + expect(page).to have_content "NCCG" + end + + it "is not rendered without project details" do + render_inline Budgets::Investments::ProjectDetailsComponent.new(create(:budget_investment, price: nil)) + + expect(page).not_to be_rendered + end +end diff --git a/spec/factories/custom/sectors.rb b/spec/factories/custom/sectors.rb new file mode 100644 index 000000000..9c2dd4054 --- /dev/null +++ b/spec/factories/custom/sectors.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :sector do + sequence(:name) { |n| "Sector #{n}" } + + trait :sub_sector do + parent factory: :sector + end + end +end diff --git a/spec/models/custom/sector_spec.rb b/spec/models/custom/sector_spec.rb new file mode 100644 index 000000000..aeda64b58 --- /dev/null +++ b/spec/models/custom/sector_spec.rb @@ -0,0 +1,69 @@ +require "rails_helper" + +describe Sector do + it "is valid" do + expect(build(:sector)).to be_valid + expect(build(:sector, :sub_sector)).to be_valid + end + + it "requires a unique name within the same parent" do + markets = create(:sector, name: "Markets") + create(:sector, name: "Trading", parent: markets) + + expect(build(:sector, name: "markets")).not_to be_valid + expect(build(:sector, name: "trading", parent: markets)).not_to be_valid + expect(build(:sector, name: "Trading", parent: create(:sector))).to be_valid + end + + it "does not allow sub-sectors of sub-sectors" do + expect(build(:sector, parent: create(:sector, :sub_sector))).not_to be_valid + end + + it "cannot be deleted when it has sub-sectors or investments" do + sector = create(:sector) + create(:sector, parent: sector) + + expect(sector.destroy).to be false + + used = create(:sector) + create(:budget_investment, sector: used) + + expect(used.destroy).to be false + end + + describe ".find_or_create_by_name!" do + it "finds sectors ignoring the case and creates them otherwise" do + sector = create(:sector, name: "Health") + + expect(Sector.find_or_create_by_name!("HEALTH")).to eq sector + expect { Sector.find_or_create_by_name!("Clinics", parent: sector) }.to change { + sector.sub_sectors.count + }.by(1) + end + end +end + +describe Budget::Investment do + describe "project fields" do + let(:sector) { create(:sector) } + + it "requires the sub-sector to belong to the sector" do + sub_sector = create(:sector, parent: sector) + + expect(build(:budget_investment, sector: sector, sub_sector: sub_sector)).to be_valid + expect(build(:budget_investment, sector: create(:sector), sub_sector: sub_sector)).not_to be_valid + expect(build(:budget_investment, sector: sub_sector)).not_to be_valid + end + + it "requires the end date to be after the start date" do + expect(build(:budget_investment, start_date: "2024-02-01", end_date: "2025-02-01")).to be_valid + expect(build(:budget_investment, start_date: "2025-02-01", end_date: "2024-02-01")).not_to be_valid + end + + it "has translatable objectives" do + investment = create(:budget_investment, objectives: "Decent trading spaces") + + expect(investment.reload.objectives).to eq "Decent trading spaces" + end + end +end diff --git a/spec/system/custom/admin/sectors_spec.rb b/spec/system/custom/admin/sectors_spec.rb new file mode 100644 index 000000000..64c64757f --- /dev/null +++ b/spec/system/custom/admin/sectors_spec.rb @@ -0,0 +1,74 @@ +require "rails_helper" + +describe "Admin sectors", :admin do + scenario "Create sectors and sub-sectors" do + visit admin_root_path + within("#admin_menu") { click_link "Sectors" } + + expect(page).to have_content "There are no sectors yet." + + click_link "Create sector" + fill_in "Name", with: "Health" + click_button "Save sector" + + expect(page).to have_content "Sector created successfully" + + within("tr", text: "Health") { click_link "Add sub-sector" } + + expect(page).to have_select "Parent sector", selected: "Health" + + fill_in "Name", with: "Clinics" + click_button "Save sector" + + expect(page).to have_css "tr.sub-sector", text: "Clinics" + end + + scenario "Sectors with investments cannot be deleted" do + sector = create(:sector, name: "Water") + create(:budget_investment, sector: sector) + + visit admin_sectors_path + + accept_confirm { click_button "Delete Water" } + + expect(page).to have_content "This sector cannot be deleted" + expect(page).to have_css "tr", text: "Water" + end + + scenario "Edit the project details of an investment" do + health = create(:sector, name: "Health") + create(:sector, parent: health, name: "Clinics") + markets = create(:sector, name: "Markets") + create(:sector, parent: markets, name: "Trading Services") + investment = create(:budget_investment) + + visit edit_admin_budget_budget_investment_path(investment.budget, investment) + + select "Markets", from: "Sector" + + expect(page).to have_select "Sub-sector", options: ["Select sub-sector", "Trading Services"] + + select "Health", from: "Sector" + + expect(page).to have_select "Sub-sector", options: ["Select sub-sector", "Clinics"] + + select "Clinics", from: "Sub-sector" + fill_in "Objectives", with: "Better health services" + fill_in "Source of funds", with: "NCCG" + fill_in "Actual cost", with: "1500" + fill_in "Start date", with: Date.new(2024, 2, 1) + fill_in "End date", with: Date.new(2025, 2, 1) + click_button "Update" + + expect(page).to have_content "Investment project updated successfully" + + investment.reload + expect(investment.sector).to eq health + expect(investment.sub_sector.name).to eq "Clinics" + expect(investment.objectives).to eq "Better health services" + expect(investment.funding_source).to eq "NCCG" + expect(investment.actual_cost).to eq 1500 + expect(investment.start_date).to eq Date.new(2024, 2, 1) + expect(investment.end_date).to eq Date.new(2025, 2, 1) + end +end diff --git a/spec/system/custom/investment_project_details_spec.rb b/spec/system/custom/investment_project_details_spec.rb new file mode 100644 index 000000000..faff64092 --- /dev/null +++ b/spec/system/custom/investment_project_details_spec.rb @@ -0,0 +1,35 @@ +require "rails_helper" + +describe "Investment project details" do + scenario "are shown on the investment page and updated with its milestones" do + budget = create(:budget, :finished) + investment = create(:budget_investment, :winner, + budget: budget, + objectives: "To provide additional decent trading spaces", + funding_source: "NCCG") + create(:progress_bar, progressable: investment, percentage: 40) + create(:milestone, milestoneable: investment, status: create(:milestone_status, name: "Ongoing")) + + visit budget_investment_path(budget, investment) + + within "#objectives + p" do + expect(page).to have_content "To provide additional decent trading spaces" + end + + within ".investment-project-details" do + expect(page).to have_content "Status\nOngoing" + expect(page).to have_content "Implementation\n40%" + expect(page).to have_content "Source of funds\nNCCG" + end + + investment.primary_progress_bar.update!(percentage: 85) + create(:milestone, milestoneable: investment, status: create(:milestone_status, name: "Delayed")) + + refresh + + within ".investment-project-details" do + expect(page).to have_content "Status\nDelayed" + expect(page).to have_content "Implementation\n85%" + end + end +end