Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ This codebase (Rails 8.1)
| `Story` | Editorial content with facilitators, primary/gallery assets |
| `Resource` | Handouts, toolkits, templates with downloadable assets |
| `Person` | Organization affiliates with contacts, addresses, sectors |
| `Organization` | Groups with affiliations, addresses, logos via ActiveStorage |
| `Organization` | Groups with affiliations, addresses, logos via ActiveStorage; `belongs_to :organization_type` |
| `OrganizationType` | Admin-managed "base data" classification (Publishable, e.g. "501c3/nonprofit"); supplies the organization form and public registration dropdown options |
| `Grant` | Donated funds (polymorphic `donor`: Organization or Person) with eligibility criteria, tasks, deadlines; parent of `Scholarship`. Scholarship totals cannot exceed the grant amount |
| `Scholarship` | Award to a `Person`; optionally drawn from a `Grant`, syncs to event registration `Allocation` |
| `Report` | STI base class for MonthlyReport |
Expand Down
65 changes: 65 additions & 0 deletions app/controllers/organization_types_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
class OrganizationTypesController < ApplicationController
include AhoyTracking
before_action :set_organization_type, only: [ :show, :edit, :update, :destroy ]

def index
authorize!
per_page = params[:number_of_items_per_page].presence || 25
base_scope = authorized_scope(OrganizationType.all)
filtered = base_scope.filter_scope(params)
@organization_types = filtered.ordered.paginate(page: params[:page], per_page: per_page)
@organization_counts = Organization.where(organization_type_id: @organization_types.map(&:id))
.group(:organization_type_id).count

@count_display = filtered.count == base_scope.count ? base_scope.count : "#{filtered.count}/#{base_scope.count}"
end

def show
authorize! @organization_type
end

def new
@organization_type = OrganizationType.new
authorize! @organization_type
end

def edit
authorize! @organization_type
end

def create
@organization_type = OrganizationType.new(organization_type_params)
authorize! @organization_type

if @organization_type.save
redirect_to @organization_type, notice: "Organization type was successfully created."
else
render :new, status: :unprocessable_content
end
end

def update
authorize! @organization_type
if @organization_type.update(organization_type_params)
redirect_to @organization_type, notice: "Organization type was successfully updated.", status: :see_other
else
render :edit, status: :unprocessable_content
end
end

def destroy
authorize! @organization_type
@organization_type.destroy!
redirect_to organization_types_path, notice: "Organization type was successfully destroyed."
end

private

def set_organization_type
@organization_type = OrganizationType.find(params[:id])
end

def organization_type_params
params.require(:organization_type).permit(:name, :published, :description)
end
end
6 changes: 3 additions & 3 deletions app/controllers/organizations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ def index
if turbo_frame_request?
per_page = params[:number_of_items_per_page].presence || 25
base_scope = authorized_scope(Organization.includes(
:windows_type, :organization_status, :sectors, :addresses,
:windows_type, :organization_status, :organization_type, :sectors, :addresses,
{ categorizable_items: { category: :category_type } },
logo_attachment: :blob
))
Expand Down Expand Up @@ -208,7 +208,7 @@ def populations_served

def set_organization
@organization = Organization.includes(
:organization_status, :windows_type, :addresses,
:organization_status, :organization_type, :windows_type, :addresses,
:categorizable_items,
{ comments: [ :created_by, :updated_by ] },
{ sectorable_items: :sector },
Expand All @@ -220,7 +220,7 @@ def set_organization
def organization_params
params.require(:organization).permit(
:name, :description, :start_date, :end_date, :mission_vision_values,
:agency_type, :agency_type_other, :filemaker_code, :logo, :notes, :email, :website_url,
:organization_type_id, :agency_type_other, :filemaker_code, :logo, :notes, :email, :website_url,
:organization_status_id, :location_id, :windows_type_id,
:profile_show_sectors, :profile_show_email, :profile_show_phone,
:profile_show_website, :profile_show_description, :profile_show_workshops,
Expand Down
9 changes: 9 additions & 0 deletions app/decorators/organization_type_decorator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
class OrganizationTypeDecorator < ApplicationDecorator
def title
name
end

def detail(length: nil)
"Organization type: #{name}"
end
end
4 changes: 4 additions & 0 deletions app/helpers/admin_cards_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ def reference_cards
intensity: 100,
params: { published: true }),
custom_card("Organization statuses", organization_statuses_path, icon: "🧮", color: :emerald, intensity: 100),
model_card(:organization_types, icon: "🏢",
intensity: 100,
title: "Organization types",
params: { published: true }),
model_card(:sectors, icon: "🏭",
intensity: 100,
title: "Sectors",
Expand Down
1 change: 1 addition & 0 deletions app/models/organization.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
class Organization < ApplicationRecord
include RemoteSearchable, TagFilterable, Trendable, WindowsTypeFilterable, SectorsTaggable, AgeGroupTaggable # Publishable
belongs_to :organization_status
belongs_to :organization_type, optional: true
belongs_to :organization_obligation, optional: true
belongs_to :location, optional: true # TODO - remove Location if unused
belongs_to :windows_type, optional: true
Expand Down
37 changes: 37 additions & 0 deletions app/models/organization_type.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
class OrganizationType < ApplicationRecord
include NameFilterable, Publishable

# Canonical classifications offered on the organization profile and public
# registration forms. Seeded as published records (see db/seeds.rb) and used
# as the fallback list when no published types exist yet (e.g. a fresh test
# database without seeds), so the forms always have sensible options.
DEFAULT_NAMES = [
"501c3/nonprofit",
"For-profit",
"Government agency",
"Other"
].freeze

has_many :organizations, dependent: :nullify

validates :name, presence: true, uniqueness: { case_sensitive: false }

# Names sort cleanly: digits precede letters, so "501c3/nonprofit" leads and
# "Other" trails — exactly the intended display order.
scope :ordered, -> { order(:name) }
scope :name_contains, ->(term) { term.present? ? where("name LIKE ?", "%#{sanitize_sql_like(term)}%") : all }

# Published names for the forms, falling back to the canonical defaults when
# nothing is seeded yet.
def self.published_names

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: Fallback to DEFAULT_NAMES keeps the org + registration forms populated when no types are published yet — notably fresh test DBs that do not load seeds, so the existing form-builder spec stays green without seeding.

published.ordered.pluck(:name).presence || DEFAULT_NAMES
end

scope :filter_scope, ->(params) do
filtered = all
filtered = filtered.name_contains(params[:name])
filtered = filtered.published if params[:published] == "true"
filtered = filtered.where(published: false) if params[:published] == "false"
filtered
end
end
12 changes: 12 additions & 0 deletions app/policies/organization_type_policy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class OrganizationTypePolicy < ApplicationPolicy
def index? = admin?
def show? = admin?
def create? = admin?
def update? = admin?
def destroy? = record.persisted? && admin?

relation_scope do |relation|
next relation if admin?
relation.none
end
end
5 changes: 1 addition & 4 deletions app/services/form_builder_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -406,10 +406,7 @@ def build_person_contact_info_fields(form, position)
key: "agency_website", group: "person_contact_info", required: false)
position = add_field(form, position, "Organization Type", :single_select_radio,
key: "agency_type", group: "person_contact_info", required: false,
options: [
"501c3/nonprofit", "For-profit", "Government agency",
"Other (please specify below)"
])
options: OrganizationType.published_names)
position = add_field(form, position, "Organization Street Address", :free_form_input_one_line,
key: "agency_street", group: "person_contact_info", required: false)
position = add_field(form, position, "Organization City", :free_form_input_one_line,
Expand Down
45 changes: 45 additions & 0 deletions app/views/organization_types/_form.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<%= simple_form_for(@organization_type) do |f| %>
<div class="space-y-6">

<!-- Errors -->
<%= render "shared/errors", resource: @organization_type if @organization_type.errors.any? %>

<!-- One-line Flex Fields -->
<div class="flex flex-wrap gap-6">

<!-- Name -->
<div class="flex-1 min-w-[220px]">
<%= f.input :name,
label: "Name",
input_html: { class: "form-control" } %>
</div>

<div class="flex items-center min-w-[150px] pt-6">
<%= f.input :published,
as: :boolean,
wrapper_html: { class: "flex items-center gap-2" },
input_html: { class: "form-checkbox" } %>
</div>

</div>

<!-- Description -->
<div>
<%= f.input :description,
as: :text,
label: "Description",
hint: "Optional. Shown alongside this type to clarify its meaning when the name alone isn't enough.",
input_html: { class: "form-control", rows: 2 } %>
</div>

<div class="action-buttons mt-8 flex justify-center gap-3">
<% if allowed_to?(:destroy?, f.object) %>
<%= link_to "Delete", @organization_type, class: "btn btn-danger-outline",
data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete?" } %>
<% end %>
<%= link_to "Cancel", organization_types_path, class: "btn btn-secondary-outline" %>
<%= f.button :submit, class: "btn btn-primary" %>
</div>

</div>
<% end %>
40 changes: 40 additions & 0 deletions app/views/organization_types/_search_boxes.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<!-- Filters -->
<div class="mb-6 p-4 bg-white border border-gray-200 rounded-lg">
<%= form_with url: organization_types_path,
method: :get,
local: true,
class: "grid grid-cols-1 md:grid-cols-5 gap-4 items-end" do |f| %>

<!-- Name Search -->
<div>
<%= f.label :name, "Name contains",
class: "block text-sm font-medium text-gray-700" %>

<%= f.text_field :name,
value: params[:name],
class: "mt-1 block w-full rounded-md border border-gray-300 p-2",
oninput: "this.form.requestSubmit()" %>
</div>

<!-- Published -->
<div>
<%= f.label :published, "Published",
class: "block text-sm font-medium text-gray-700" %>

<%= f.select :published,
options_for_select([["All", ""], ["Published", "true"], ["Unpublished", "false"]], params[:published]),
{},
class: "mt-1 block w-full rounded-md border border-gray-300 p-2",
onchange: "this.form.requestSubmit()" %>
</div>

<!-- Clear -->
<div>
<%= link_to "Clear filters",
organization_types_path,
class: "btn btn-utility-outline whitespace-nowrap",
data: { action: "collection#clearAndSubmit" } %>
</div>

<% end %>
</div>
16 changes: 16 additions & 0 deletions app/views/organization_types/edit.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<% content_for(:page_bg_class, "admin-only bg-blue-100") %>
<div class="<%= DomainTheme.bg_class_for(:organization_types) %> border border-gray-200 rounded-xl shadow p-6">
<div class="flex flex-wrap justify-end gap-2 mb-4">
<%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
<%= link_to "Organization types", organization_types_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
<%= link_to "View", organization_type_path(@organization_type), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
</div>
<h1 class="text-2xl font-semibold text-gray-900 mb-6">Edit organization type</h1>

<div class="space-y-6">
<div class="mt-4">
<%= render "form", organization_type: @organization_type %>
<%= render "shared/audit_info", resource: @organization_type %>
</div>
</div>
</div>
99 changes: 99 additions & 0 deletions app/views/organization_types/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<% content_for(:page_bg_class, "admin-only bg-blue-100") %>
<div class="<%= DomainTheme.bg_class_for(:organization_types) %> border border-gray-200 rounded-xl shadow p-6">
<div class="space-y-6">

<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold text-gray-900">
Organization types (<%= @count_display %>)
</h1>
<div class="flex gap-2">
<%= link_to "New #{OrganizationType.model_name.human.downcase}",
new_organization_type_path,
class: "admin-only bg-blue-100 btn btn-primary-outline" %>
</div>
</div>

<%= render "search_boxes" %>

<!-- Table -->
<div class="rounded-xl bg-white p-6">
<div class="overflow-x-auto">
<% if @organization_types.any? %>
<table class="w-full table-fixed border-collapse border border-gray-200">
<thead class="bg-gray-100">
<tr>
<th class="px-4 py-2 text-left text-sm font-semibold text-gray-700 w-1/3">
Name
</th>

<th class="px-4 py-2 text-center text-sm font-semibold text-gray-700 w-1/6">
Published
</th>

<th class="px-4 py-2 text-center text-sm font-semibold text-gray-700 w-1/6">
Organizations
</th>

<th class="px-4 py-2 text-center text-sm font-semibold text-gray-700 w-[80px]">
Actions
</th>
</tr>
</thead>

<tbody class="divide-y divide-gray-200">
<% @organization_types.each do |organization_type| %>
<tr class=" <%= "bg-gray-200" unless organization_type.published? %>
<%= organization_type.published? ? "hover:bg-gray-100" : "hover:bg-gray-50" %> transition-colors duration-150">

<td class="px-4 py-2 text-md text-gray-800 truncate font-bold">
<%= organization_type.name %>
<% if organization_type.description.present? %>
<i class="fa-solid fa-circle-info ml-1 text-sm font-normal text-gray-400 hover:text-blue-600 cursor-help"
title="<%= organization_type.description %>"></i>
<% end %>
</td>

<td class="px-4 py-2 text-center">
<% if organization_type.published? %>
<span class="inline-block px-2 py-1 text-xs font-semibold text-green-800 bg-green-100 rounded">
Yes
</span>
<% else %>
<span class="inline-block px-2 py-1 text-xs font-semibold text-gray-700 bg-gray-200 rounded">
No
</span>
<% end %>
</td>

<td class="px-4 py-2 text-center text-sm text-gray-700">
<%= @organization_counts[organization_type.id].to_i %>
</td>

<!-- Actions -->
<td class="px-4 py-2 text-center">
<%= link_to "Edit",
edit_organization_type_path(organization_type),
class: "btn btn-secondary-outline whitespace-nowrap" %>
</td>

</tr>
<% end %>
</tbody>
</table>
<% else %>
<!-- Empty state -->
<p class="text-gray-500 text-center py-6">
No <%= OrganizationType.model_name.human.pluralize.downcase %> found.
</p>
<% end %>
</div>
</div>

<!-- Pagination -->
<div class="pagination flex justify-center mt-12">
<%= tailwind_paginate @organization_types %>
</div>

</div>
</div>
Loading