diff --git a/app/controllers/grants_controller.rb b/app/controllers/grants_controller.rb
index f45627106e..078a34787b 100644
--- a/app/controllers/grants_controller.rb
+++ b/app/controllers/grants_controller.rb
@@ -4,11 +4,16 @@ class GrantsController < ApplicationController
def index
authorize!
- @grants = authorized_scope(Grant.all)
+ # The full page renders only the header, filters, and an empty results frame;
+ # the frame's src request (turbo_frame_request?) loads the filtered rows.
+ return render :index unless turbo_frame_request?
+
+ @grants = filter_grants(authorized_scope(Grant.all))
.includes(:donor, scholarships: { allocation: :allocatable })
.by_deadline
.page(params[:page])
track_index_intent(Grant, @grants, params)
+ render :index_lazy
end
def show
@@ -75,6 +80,38 @@ def set_form_variables
private
+ # Narrow the index by the optional filter inputs. Each filter is a no-op when
+ # its param is blank or unrecognized, so combinations stack cleanly.
+ def filter_grants(scope)
+ scope = scope.where("grants.name LIKE ?", "%#{Grant.sanitize_sql_like(params[:name])}%") if params[:name].present?
+ scope = filter_by_donor_name(scope, params[:donor_name]) if params[:donor_name].present?
+
+ scope = case params[:funds]
+ when "available" then scope.with_funds_remaining
+ when "none" then scope.fully_issued
+ else scope
+ end
+
+ scope = scope.where(donor_type: params[:donor_type]) if Grant::DONOR_TYPES.include?(params[:donor_type])
+
+ case params[:tasks]
+ when "completed" then scope.all_tasks_completed
+ when "outstanding" then scope.tasks_outstanding
+ else scope
+ end
+ end
+
+ # Match grants whose polymorphic donor (Organization or Person) name contains
+ # the query. Resolve matching donor ids per type, then OR the two sides so the
+ # other active filters on `scope` apply to both.
+ def filter_by_donor_name(scope, query)
+ like = "%#{Grant.sanitize_sql_like(query)}%"
+ org_ids = Organization.where("name LIKE ?", like).pluck(:id)
+ person_ids = Person.where("first_name LIKE :q OR last_name LIKE :q OR CONCAT(first_name, ' ', last_name) LIKE :q", q: like).pluck(:id)
+ scope.where(donor_type: "Organization", donor_id: org_ids)
+ .or(scope.where(donor_type: "Person", donor_id: person_ids))
+ end
+
def set_grant
@grant = Grant.find(params[:id])
end
diff --git a/app/frontend/javascript/controllers/table_sort_controller.js b/app/frontend/javascript/controllers/table_sort_controller.js
index d0204f42fc..b69d2ed711 100644
--- a/app/frontend/javascript/controllers/table_sort_controller.js
+++ b/app/frontend/javascript/controllers/table_sort_controller.js
@@ -45,8 +45,12 @@ export default class extends Controller {
compare(rowA, rowB, index, key) {
const a = this.cellValue(rowA, index, key)
const b = this.cellValue(rowB, index, key)
- const numA = parseFloat(a)
- const numB = parseFloat(b)
+ // Strict Number(), not parseFloat(): parseFloat("2026-12-31") is 2026, which
+ // would collapse every date in a year to one value and leave date columns
+ // effectively unsorted. Number() rejects such strings (NaN) so ISO dates fall
+ // through to localeCompare, which orders them correctly since they're padded.
+ const numA = a === "" ? NaN : Number(a)
+ const numB = b === "" ? NaN : Number(b)
if (!isNaN(numA) && !isNaN(numB)) return numA - numB
return a.localeCompare(b)
}
diff --git a/app/models/grant.rb b/app/models/grant.rb
index 330d600f25..8827b9c2e6 100644
--- a/app/models/grant.rb
+++ b/app/models/grant.rb
@@ -13,12 +13,31 @@ class Grant < ApplicationRecord
scope :by_deadline, -> { order(Arel.sql("application_deadline IS NULL, application_deadline ASC")) }
+ # Total scholarship draws against a grant, as a correlated subquery. Used by the
+ # funds scopes so they stay flat WHERE clauses — no GROUP BY/HAVING, which would
+ # break will_paginate's total_entries count on the paginated index.
+ ALLOCATED_CENTS_SUBQUERY =
+ "COALESCE((SELECT SUM(scholarships.amount_cents) FROM scholarships WHERE scholarships.grant_id = grants.id), 0)".freeze
+
# Grants that still have unallocated funds (donation amount exceeds the sum of
# scholarships drawn against them).
- scope :with_funds_remaining, -> {
- left_joins(:scholarships)
- .group(:id)
- .having("grants.amount_cents - COALESCE(SUM(scholarships.amount_cents), 0) > 0")
+ scope :with_funds_remaining, -> { where("grants.amount_cents > #{ALLOCATED_CENTS_SUBQUERY}") }
+
+ # Grants whose full donation has been issued as scholarships (nothing left to
+ # award) — the complement of with_funds_remaining.
+ scope :fully_issued, -> { where("grants.amount_cents <= #{ALLOCATED_CENTS_SUBQUERY}") }
+
+ # Task-completion filters keyed off the grant's scholarships. "Outstanding"
+ # means at least one scholarship still has incomplete tasks; "all completed"
+ # means the grant has scholarships and none are outstanding. The subqueries
+ # exclude grant-less scholarships (grant_id IS NULL) — a stray NULL in the
+ # NOT IN set below would otherwise make all_tasks_completed match nothing.
+ scope :tasks_outstanding, -> {
+ where(id: Scholarship.where(tasks_completed: false).where.not(grant_id: nil).select(:grant_id))
+ }
+ scope :all_tasks_completed, -> {
+ where(id: Scholarship.where.not(grant_id: nil).select(:grant_id))
+ .where.not(id: Scholarship.where(tasks_completed: false).where.not(grant_id: nil).select(:grant_id))
}
# Grants offered in a scholarship's "Funded by grant" picker: every grant with
diff --git a/app/views/events/recipients.html.erb b/app/views/events/recipients.html.erb
index ca2d82b4be..d7075a776d 100644
--- a/app/views/events/recipients.html.erb
+++ b/app/views/events/recipients.html.erb
@@ -6,12 +6,18 @@
<%# Top bar: back link + sub-nav, matching the other event pages %>
-