add Nairobi project fields to budget investments

Add objectives, actual cost, start and end dates, source of funds and
sector/sub-sector to investments. Sectors are a catalog managed from
the admin panel, and the sub-sector select only shows the sub-sectors
of the selected sector. The investment page shows these details along
with the current milestone status and implementation percentage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Diego Calvo
2026-10-08 13:23:25 +01:00
parent 7369460a24
commit 2707ab9560
35 changed files with 1099 additions and 1 deletions

View File

@@ -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() {

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -0,0 +1 @@
<%= link_list(*links, id: "admin_menu") %>

View File

@@ -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

View File

@@ -0,0 +1,12 @@
<section class="investment-project-details" aria-labelledby="project_details_header">
<h2 id="project_details_header"><%= t("budgets.investments.project_details.title") %></h2>
<dl>
<% details.each do |field, value| %>
<div class="project-detail project-detail-<%= field.to_s.dasherize %>">
<dt><%= t("budgets.investments.project_details.#{field}") %></dt>
<dd><%= value %></dd>
</div>
<% end %>
</dl>
</section>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,161 @@
<%= link_to admin_budget_budget_investment_path(@budget, @investment, Budget::Investment.filter_params(params).to_h), class: "back" do %>
<span class="icon-angle-left"></span> <%= t("shared.back") %>
<% end %>
<%= render "shared/globalize_locales", resource: @investment %>
<div class="row">
<%= 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 %>
<div class="row expanded">
<%= f.translatable_fields do |translations_form| %>
<div class="small-12 column">
<%= translations_form.text_field :title,
maxlength: Budget::Investment.title_max_length %>
</div>
<div class="small-12 column">
<%= translations_form.text_area :description,
maxlength: Budget::Investment.description_max_length,
class: "html-area" %>
</div>
<div class="small-12 column">
<%= translations_form.text_area :objectives, rows: 3 %>
</div>
<% end %>
<div class="small-12 column">
<%= f.text_field :tag_list,
value: @investment.tag_list.sort.join(","),
label: t("admin.budget_investments.edit.user_tags") %>
</div>
<div class="small-12 column">
<div class="small-12 medium-6">
<%= f.text_field :external_url %>
</div>
<div class="small-12 medium-6">
<%= f.select :heading_id, budget_heading_select_options(@budget), include_blank: t("admin.budget_investments.edit.select_heading") %>
</div>
</div>
</div>
<div class="row expanded project-details-fields">
<div class="small-12 column">
<h2 id="project_details"><%= t("admin.budget_investments.edit.project_details") %></h2>
<p class="help-text">
<%= link_to t("admin.budget_investments.edit.manage_sectors"), admin_sectors_path %>
</p>
</div>
<div class="small-12 medium-6 column">
<%= 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" } %>
</div>
<div class="small-12 medium-6 column">
<%= 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" %>
</div>
<div class="small-12 medium-6 column">
<%= f.text_field :funding_source %>
</div>
<div class="small-12 medium-6 column">
<%= f.number_field :actual_cost, min: 0, step: 1 %>
</div>
<div class="small-12 medium-6 column">
<%= f.date_field :start_date %>
</div>
<div class="small-12 medium-6 column">
<%= f.date_field :end_date %>
</div>
</div>
<div class="row expanded margin-top">
<div class="small-12 column">
<h2 id="classification"><%= t("admin.budget_investments.edit.classification") %></h2>
<div class="small-12 medium-6">
<%= f.select(:administrator_id,
@admins.map { |a| [a.description_or_name_and_email, a.id] },
{ include_blank: t("admin.budget_investments.edit.undefined") }) %>
</div>
</div>
<div class="small-12 column">
<%= f.label :valuation_tag_list %>
<div class="tags">
<% @tags.each do |tag| %>
<a class="js-add-tag-link"><%= tag.name %></a>
<% end %>
</div>
<%= 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" %>
</div>
<div class="small-12 column margin-top">
<%= f.label :valuator_ids %>
<ul>
<%= f.collection_check_boxes :valuator_group_ids, @valuator_groups, :id, :name do |group| %>
<li><%= group.label(title: group.object.name) { group.check_box + truncate(group.object.name, length: 60) } %></li>
<% end %>
</ul>
</div>
<div class="small-12 column">
<hr>
<%= f.label :valuator_ids, t("admin.budget_investments.edit.assigned_valuators") %>
<ul>
<%= f.collection_check_boxes :valuator_ids, @valuators, :id, :email do |b| %>
<li><%= b.label(title: valuator_label(b.object)) { b.check_box + truncate(b.object.description_or_email, length: 60) } %></li>
<% end %>
</ul>
</div>
</div>
<div class="row expanded margin-top">
<% if @investment.incompatible? || @investment.winner? %>
<div class="small-12 medium-3 column">
<h2 id="incompatible"><%= t("admin.budget_investments.edit.compatibility") %></h2>
<%= f.check_box :incompatible,
title: t("admin.budget_investments.edit.compatibility") %>
</div>
<% end %>
<div class="small-12 medium-3 column float-left">
<h2 id="selected"><%= t("admin.budget_investments.edit.selection") %></h2>
<%= f.check_box :selected,
title: t("admin.budget_investments.edit.selection") %>
</div>
</div>
<div class="small-12 column">
<%= f.text_field :milestone_tag_list,
value: @investment.milestone_tag_list.sort.join(", ") %>
</div>
<div class="small-12 column margin-top">
<%= f.submit(class: "button", value: t("admin.budget_investments.edit.submit_button")) %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,19 @@
<%= form_for [:admin, @sector] do |f| %>
<%= render "shared/errors", resource: @sector %>
<div class="small-12 medium-6">
<%= f.text_field :name %>
</div>
<div class="small-12 medium-6">
<%= 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? %>
</div>
<div class="margin-top">
<%= f.submit t("admin.sectors.form.submit"), class: "button success" %>
</div>
<% end %>

View File

@@ -0,0 +1,5 @@
<%= back_link_to admin_sectors_path %>
<h2><%= t("admin.sectors.edit.title") %></h2>
<%= render "form" %>

View File

@@ -0,0 +1,43 @@
<h2 class="inline-block"><%= t("admin.sectors.index.title") %></h2>
<%= link_to t("admin.sectors.index.new_sector"), new_admin_sector_path, class: "button float-right" %>
<p class="help-text clear"><%= t("admin.sectors.index.help") %></p>
<% if @sectors.any? %>
<table class="sectors-table">
<thead>
<tr>
<th><%= t("admin.sectors.index.table_name") %></th>
<th><%= t("admin.sectors.index.table_sub_sectors") %></th>
<th><%= t("admin.actions.actions") %></th>
</tr>
</thead>
<tbody>
<% @sectors.each do |sector| %>
<tr id="<%= dom_id(sector) %>">
<td><strong><%= sector.name %></strong></td>
<td><%= sector.sub_sectors.size %></td>
<td>
<%= 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 %>
</td>
</tr>
<% sector.sub_sectors.each do |sub_sector| %>
<tr id="<%= dom_id(sub_sector) %>" class="sub-sector">
<td>&emsp;↳ <%= sub_sector.name %></td>
<td></td>
<td><%= render Admin::TableActionsComponent.new(sub_sector) %></td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
<% else %>
<div class="callout primary margin-top clear">
<%= t("admin.sectors.index.empty") %>
</div>
<% end %>

View File

@@ -0,0 +1,5 @@
<%= back_link_to admin_sectors_path %>
<h2><%= t("admin.sectors.new.title") %></h2>
<%= render "form" %>

View File

@@ -0,0 +1,84 @@
<h1><%= investment.title %></h1>
<div class="budget-investment-info">
<%= render "/shared/author_info", resource: investment %>
<span class="bullet">&nbsp;&bull;&nbsp;</span>
<%= l investment.created_at.to_date %>
<span class="bullet">&nbsp;&bull;&nbsp;</span>
<%= render Shared::CommentsCountComponent.new(investment.comments_count, url: "#comments") %>
<span class="bullet">&nbsp;&bull;&nbsp;</span>
<%= investment.heading.name %>
<span class="bullet">&nbsp;&bull;&nbsp;</span>
<% if local_assigns[:preview].nil? %>
<span class="js-flag-actions">
<%= render "shared/flag_actions", flaggable: investment %>
</span>
<% end %>
</div>
<br>
<%= render_image(investment.image, :large, true) if investment.image.present? %>
<p id="investment_code">
<%= sanitize(t("budgets.investments.show.code", code: investment.id)) %>
</p>
<%= auto_link_already_sanitized_html wysiwyg(investment.description) %>
<% if investment.objectives.present? %>
<h2 id="objectives"><%= t("budgets.investments.project_details.objectives") %></h2>
<%= simple_format(investment.objectives) %>
<% end %>
<%= render Budgets::Investments::ProjectDetailsComponent.new(investment) %>
<% if feature?(:map) && map_location_available?(@investment.map_location) %>
<div class="margin">
<%= render_map(investment.map_location) %>
</div>
<% end %>
<% if investment.location.present? %>
<p>
<%= sanitize(t("budgets.investments.show.location", location: investment.location)) %>
</p>
<% end %>
<% if investment.organization_name.present? %>
<p>
<%= sanitize(t("budgets.investments.show.organization_name", name: investment.organization_name)) %>
</p>
<% end %>
<% if feature?(:allow_attached_documents) %>
<%= render Documents::DocumentsComponent.new(investment.documents) %>
<% end %>
<%= render "shared/tags", taggable: investment %>
<% if investment.external_url.present? %>
<div class="investment-external-link">
<%= sanitize_and_auto_link investment.external_url %>
</div>
<% end %>
<% if investment.should_show_unfeasibility_explanation? %>
<h2><%= t("budgets.investments.show.unfeasibility_explanation") %></h2>
<p><%= investment.unfeasibility_explanation %></p>
<% end %>
<% if investment.should_show_price_explanation? %>
<h2 id="price_explanation" data-magellan-target="price_explanation">
<%= t("budgets.investments.show.price_explanation") %>
</h2>
<p><%= investment.price_explanation %></p>
<% end %>
<% if local_assigns[:preview].nil? %>
<%= render "relationable/related_content", relationable: investment %>
<div class="js-moderator-investment-actions margin">
<%= render "budgets/investments/actions", investment: investment %>
</div>
<% end %>

View File

@@ -140,6 +140,7 @@ ignore_unused:
- "activemodel.*"
- "attributes.*"
- "date.order"
- "date.formats.month_year"
- "unauthorized.*"
- "admin.officials.level_*"
- "admin.hidden_comments.index.filter*"

View File

@@ -1,4 +1,7 @@
en:
date:
formats:
month_year: "%B %Y"
activerecord:
attributes:
budget/investment:

View File

@@ -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"

View File

@@ -1,4 +1,7 @@
es:
date:
formats:
month_year: "%B de %Y"
activerecord:
attributes:
budget/investment:

View File

@@ -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"

View File

@@ -1,4 +1,7 @@
sw-KE:
date:
formats:
month_year: "%B %Y"
activerecord:
attributes:
budget/investment:

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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