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 @@
+
+
+
+
+ <% 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 %>
+
+ <%= 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? %>
+
+
+
+ | <%= t("admin.sectors.index.table_name") %> |
+ <%= t("admin.sectors.index.table_sub_sectors") %> |
+ <%= t("admin.actions.actions") %> |
+
+
+
+ <% @sectors.each do |sector| %>
+
+ | <%= 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 %>
+ |
+
+ <% sector.sub_sectors.each do |sub_sector| %>
+
+ | ↳ <%= sub_sector.name %> |
+ |
+ <%= render Admin::TableActionsComponent.new(sub_sector) %> |
+
+ <% end %>
+ <% end %>
+
+
+<% 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? %>
+
+ <%= sanitize_and_auto_link investment.external_url %>
+
+<% 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