From b58a18fa4538ea07e02b1b0f1c452fcdcb765974 Mon Sep 17 00:00:00 2001
From: rsmokeUM
Date: Mon, 27 Jul 2026 10:45:30 -0400
Subject: [PATCH 1/4] Replace Active Admin with custom Admin MVC and upgrade to
Rails 8.1 / Ruby 4.0.6.
Cut over admin to Bootstrap Admin:: controllers, add Solid Queue/Cache/Cable on the primary Postgres database, and update specs for the new stack.
Co-authored-by: Cursor
---
.ruby-version | 2 +-
.tool-versions | 2 +-
Gemfile | 12 +-
Gemfile.lock | 218 ++++---
README.md | 21 +-
app/admin/admin_users.rb | 27 -
app/admin/dashboard.rb | 243 --------
app/admin/inputs/action_text_input.rb | 10 -
app/admin/payments.rb | 72 ---
app/admin/program_settings.rb | 19 -
app/admin/static_pages.rb | 21 -
app/admin/users.rb | 41 --
app/assets/builds/active_admin.css | 1 -
app/assets/builds/active_admin.css.map | 1 -
app/assets/builds/admin.css | 1 +
app/assets/config/manifest.js | 2 +-
app/assets/stylesheets/active_admin.scss | 6 -
app/assets/stylesheets/admin.scss | 25 +
.../admin/admin_users_controller.rb | 56 ++
app/controllers/admin/base_controller.rb | 13 +
app/controllers/admin/dashboard_controller.rb | 23 +
app/controllers/admin/payments_controller.rb | 73 +++
.../admin/program_settings_controller.rb | 54 ++
.../admin/static_pages_controller.rb | 31 +
app/controllers/admin/users_controller.rb | 18 +
.../admin_users/passwords_controller.rb | 7 +
.../admin_users/sessions_controller.rb | 15 +
app/helpers/application_helper.rb | 16 +
app/javascript/active_admin.js | 1 -
app/javascript/application.js | 3 +
app/models/admin_user.rb | 8 -
app/queries/admin/dashboard_payments_query.rb | 95 +++
.../shared/_header_utility_nav.html.erb | 12 -
app/views/admin/admin_users/_form.html.erb | 14 +
app/views/admin/admin_users/edit.html.erb | 2 +
app/views/admin/admin_users/index.html.erb | 43 ++
app/views/admin/admin_users/new.html.erb | 2 +
app/views/admin/admin_users/show.html.erb | 12 +
app/views/admin/dashboard/show.html.erb | 107 ++++
app/views/admin/payments/_form.html.erb | 34 ++
app/views/admin/payments/edit.html.erb | 3 +
app/views/admin/payments/index.html.erb | 70 +++
app/views/admin/payments/new.html.erb | 3 +
app/views/admin/payments/show.html.erb | 37 ++
.../admin/program_settings/_form.html.erb | 21 +
.../admin/program_settings/edit.html.erb | 2 +
.../admin/program_settings/index.html.erb | 37 ++
app/views/admin/program_settings/new.html.erb | 2 +
.../admin/program_settings/show.html.erb | 12 +
app/views/admin/shared/_pagination.html.erb | 29 +
app/views/admin/static_pages/edit.html.erb | 15 +
app/views/admin/static_pages/index.html.erb | 22 +
app/views/admin/users/index.html.erb | 59 ++
app/views/admin/users/show.html.erb | 40 ++
app/views/admin_users/passwords/edit.html.erb | 21 +
app/views/admin_users/passwords/new.html.erb | 19 +
app/views/admin_users/sessions/new.html.erb | 29 +
app/views/layouts/_admin_flashes.html.erb | 11 +
app/views/layouts/_admin_nav.html.erb | 26 +
app/views/layouts/_footer.html.erb | 4 +-
app/views/layouts/active_admin.html.erb | 72 ---
app/views/layouts/admin.html.erb | 31 +
bin/jobs | 6 +
config/application.rb | 5 +-
config/boot.rb | 8 +-
config/cable.yml | 15 +-
config/cache.yml | 18 +
config/environments/production.rb | 7 +-
config/environments/staging.rb | 9 +-
config/importmap.rb | 1 -
config/initializers/active_admin.rb | 550 ------------------
config/initializers/pagy.rb | 7 +
config/puma.rb | 3 +
config/queue.yml | 18 +
config/recurring.yml | 15 +
config/routes.rb | 18 +-
...260727140849_drop_active_admin_comments.rb | 18 +
...0_create_solid_cable_cache_queue_tables.rb | 164 ++++++
db/schema.rb | 246 ++++++--
lib/tasks/generate_sample_payments.rake | 2 +-
spec/factories/admin_users.rb | 2 +-
spec/features/admin_payments_spec.rb | 4 +-
spec/features/admin_users_spec.rb | 2 +-
spec/rails_helper.rb | 7 +
spec/requests/admin_payments_request_spec.rb | 42 ++
.../admin_program_settings_request_spec.rb | 39 ++
spec/requests/admin_resources_request_spec.rb | 219 +++++++
87 files changed, 2053 insertions(+), 1300 deletions(-)
delete mode 100644 app/admin/admin_users.rb
delete mode 100644 app/admin/dashboard.rb
delete mode 100644 app/admin/inputs/action_text_input.rb
delete mode 100644 app/admin/payments.rb
delete mode 100644 app/admin/program_settings.rb
delete mode 100644 app/admin/static_pages.rb
delete mode 100644 app/admin/users.rb
delete mode 100644 app/assets/builds/active_admin.css
delete mode 100644 app/assets/builds/active_admin.css.map
create mode 100644 app/assets/builds/admin.css
delete mode 100644 app/assets/stylesheets/active_admin.scss
create mode 100644 app/assets/stylesheets/admin.scss
create mode 100644 app/controllers/admin/admin_users_controller.rb
create mode 100644 app/controllers/admin/base_controller.rb
create mode 100644 app/controllers/admin/dashboard_controller.rb
create mode 100644 app/controllers/admin/payments_controller.rb
create mode 100644 app/controllers/admin/program_settings_controller.rb
create mode 100644 app/controllers/admin/static_pages_controller.rb
create mode 100644 app/controllers/admin/users_controller.rb
create mode 100644 app/controllers/admin_users/passwords_controller.rb
create mode 100644 app/controllers/admin_users/sessions_controller.rb
delete mode 100644 app/javascript/active_admin.js
create mode 100644 app/queries/admin/dashboard_payments_query.rb
delete mode 100644 app/views/active_admin/shared/_header_utility_nav.html.erb
create mode 100644 app/views/admin/admin_users/_form.html.erb
create mode 100644 app/views/admin/admin_users/edit.html.erb
create mode 100644 app/views/admin/admin_users/index.html.erb
create mode 100644 app/views/admin/admin_users/new.html.erb
create mode 100644 app/views/admin/admin_users/show.html.erb
create mode 100644 app/views/admin/dashboard/show.html.erb
create mode 100644 app/views/admin/payments/_form.html.erb
create mode 100644 app/views/admin/payments/edit.html.erb
create mode 100644 app/views/admin/payments/index.html.erb
create mode 100644 app/views/admin/payments/new.html.erb
create mode 100644 app/views/admin/payments/show.html.erb
create mode 100644 app/views/admin/program_settings/_form.html.erb
create mode 100644 app/views/admin/program_settings/edit.html.erb
create mode 100644 app/views/admin/program_settings/index.html.erb
create mode 100644 app/views/admin/program_settings/new.html.erb
create mode 100644 app/views/admin/program_settings/show.html.erb
create mode 100644 app/views/admin/shared/_pagination.html.erb
create mode 100644 app/views/admin/static_pages/edit.html.erb
create mode 100644 app/views/admin/static_pages/index.html.erb
create mode 100644 app/views/admin/users/index.html.erb
create mode 100644 app/views/admin/users/show.html.erb
create mode 100644 app/views/admin_users/passwords/edit.html.erb
create mode 100644 app/views/admin_users/passwords/new.html.erb
create mode 100644 app/views/admin_users/sessions/new.html.erb
create mode 100644 app/views/layouts/_admin_flashes.html.erb
create mode 100644 app/views/layouts/_admin_nav.html.erb
delete mode 100644 app/views/layouts/active_admin.html.erb
create mode 100644 app/views/layouts/admin.html.erb
create mode 100755 bin/jobs
create mode 100644 config/cache.yml
delete mode 100644 config/initializers/active_admin.rb
create mode 100644 config/initializers/pagy.rb
create mode 100644 config/queue.yml
create mode 100644 config/recurring.yml
create mode 100644 db/migrate/20260727140849_drop_active_admin_comments.rb
create mode 100644 db/migrate/20260727141500_create_solid_cable_cache_queue_tables.rb
create mode 100644 spec/requests/admin_payments_request_spec.rb
create mode 100644 spec/requests/admin_program_settings_request_spec.rb
create mode 100644 spec/requests/admin_resources_request_spec.rb
diff --git a/.ruby-version b/.ruby-version
index 7bcbb38..d13e837 100644
--- a/.ruby-version
+++ b/.ruby-version
@@ -1 +1 @@
-3.4.9
+4.0.6
diff --git a/.tool-versions b/.tool-versions
index 53ab124..4747d64 100644
--- a/.tool-versions
+++ b/.tool-versions
@@ -1,3 +1,3 @@
-ruby 3.4.9
+ruby 4.0.6
yarn 1.22.19
diff --git a/Gemfile b/Gemfile
index a525a56..39567f0 100644
--- a/Gemfile
+++ b/Gemfile
@@ -1,10 +1,10 @@
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
-ruby '3.4.9'
-gem 'rails', '~> 7.2.3', '>= 7.2.3'
+ruby '4.0.6'
+gem 'rails', '~> 8.0'
+gem 'cgi'
-gem 'activeadmin'
gem 'base64'
gem 'bootsnap', '>= 1.4.4', require: false
gem 'csv'
@@ -14,12 +14,16 @@ gem 'drb'
gem 'importmap-rails'
gem 'jbuilder', '~> 2.7'
gem 'lsa_tdx_feedback', '~> 1.0', '>= 1.0.4'
+gem 'pagy', '~> 9.3'
gem 'pg'
gem 'puma', '~> 6.0'
gem 'sd_notify'
gem 'sentry-rails'
gem 'sentry-ruby'
gem 'simple_form'
+gem 'solid_cable'
+gem 'solid_cache'
+gem 'solid_queue'
gem 'sprockets-rails', '~> 3.4'
gem 'stimulus-rails'
gem 'turbo-rails'
@@ -27,8 +31,8 @@ gem 'tzinfo-data', platforms: %i[windows jruby]
group :development do
gem 'annotate'
+ gem 'debug', platforms: %i[mri windows]
gem 'listen', '~> 3.3'
- gem 'pry-byebug'
gem 'pry-rails'
gem 'spring', '~> 4.0'
gem 'web-console', '>= 4.1.0'
diff --git a/Gemfile.lock b/Gemfile.lock
index ec994b3..c4e3822 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,107 +1,93 @@
GEM
remote: https://rubygems.org/
specs:
- actioncable (7.2.3)
- actionpack (= 7.2.3)
- activesupport (= 7.2.3)
+ action_text-trix (2.1.19)
+ railties
+ actioncable (8.1.3)
+ actionpack (= 8.1.3)
+ activesupport (= 8.1.3)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
- actionmailbox (7.2.3)
- actionpack (= 7.2.3)
- activejob (= 7.2.3)
- activerecord (= 7.2.3)
- activestorage (= 7.2.3)
- activesupport (= 7.2.3)
+ actionmailbox (8.1.3)
+ actionpack (= 8.1.3)
+ activejob (= 8.1.3)
+ activerecord (= 8.1.3)
+ activestorage (= 8.1.3)
+ activesupport (= 8.1.3)
mail (>= 2.8.0)
- actionmailer (7.2.3)
- actionpack (= 7.2.3)
- actionview (= 7.2.3)
- activejob (= 7.2.3)
- activesupport (= 7.2.3)
+ actionmailer (8.1.3)
+ actionpack (= 8.1.3)
+ actionview (= 8.1.3)
+ activejob (= 8.1.3)
+ activesupport (= 8.1.3)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
- actionpack (7.2.3)
- actionview (= 7.2.3)
- activesupport (= 7.2.3)
- cgi
+ actionpack (8.1.3)
+ actionview (= 8.1.3)
+ activesupport (= 8.1.3)
nokogiri (>= 1.8.5)
- racc
- rack (>= 2.2.4, < 3.3)
+ rack (>= 2.2.4)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
- actiontext (7.2.3)
- actionpack (= 7.2.3)
- activerecord (= 7.2.3)
- activestorage (= 7.2.3)
- activesupport (= 7.2.3)
+ actiontext (8.1.3)
+ action_text-trix (~> 2.1.15)
+ actionpack (= 8.1.3)
+ activerecord (= 8.1.3)
+ activestorage (= 8.1.3)
+ activesupport (= 8.1.3)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
- actionview (7.2.3)
- activesupport (= 7.2.3)
+ actionview (8.1.3)
+ activesupport (= 8.1.3)
builder (~> 3.1)
- cgi
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
- activeadmin (3.3.0)
- arbre (~> 1.2, >= 1.2.1)
- csv
- formtastic (>= 3.1)
- formtastic_i18n (>= 0.4)
- inherited_resources (~> 1.7)
- jquery-rails (>= 4.2)
- kaminari (>= 1.2.1)
- railties (>= 6.1)
- ransack (>= 4.0)
- activejob (7.2.3)
- activesupport (= 7.2.3)
+ activejob (8.1.3)
+ activesupport (= 8.1.3)
globalid (>= 0.3.6)
- activemodel (7.2.3)
- activesupport (= 7.2.3)
- activerecord (7.2.3)
- activemodel (= 7.2.3)
- activesupport (= 7.2.3)
+ activemodel (8.1.3)
+ activesupport (= 8.1.3)
+ activerecord (8.1.3)
+ activemodel (= 8.1.3)
+ activesupport (= 8.1.3)
timeout (>= 0.4.0)
- activestorage (7.2.3)
- actionpack (= 7.2.3)
- activejob (= 7.2.3)
- activerecord (= 7.2.3)
- activesupport (= 7.2.3)
+ activestorage (8.1.3)
+ actionpack (= 8.1.3)
+ activejob (= 8.1.3)
+ activerecord (= 8.1.3)
+ activesupport (= 8.1.3)
marcel (~> 1.0)
- activesupport (7.2.3)
+ activesupport (8.1.3)
base64
- benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
+ json
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
+ uri (>= 0.13.1)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
- annotate (3.2.0)
- activerecord (>= 3.2, < 8.0)
- rake (>= 10.4, < 14.0)
- arbre (1.7.0)
- activesupport (>= 3.0.0)
- ruby2_keywords (>= 0.0.2)
+ annotate (2.6.5)
+ activerecord (>= 2.3.0)
+ rake (>= 0.8.7)
ast (2.4.3)
base64 (0.3.0)
bcrypt (3.1.20)
- benchmark (0.5.0)
bigdecimal (4.1.0)
bindex (0.8.1)
bootsnap (1.18.6)
msgpack (~> 1.2)
builder (3.3.0)
- byebug (12.0.0)
capybara (3.40.0)
addressable
matrix
@@ -130,6 +116,9 @@ GEM
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
date (3.5.1)
+ debug (1.11.1)
+ irb (~> 1.10)
+ reline (>= 0.3.8)
devise (4.9.4)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
@@ -141,6 +130,8 @@ GEM
drb (2.2.3)
erb (6.0.2)
erubi (1.13.1)
+ et-orbi (1.4.0)
+ tzinfo
factory_bot (6.5.4)
activesupport (>= 6.1.0)
factory_bot_rails (6.5.0)
@@ -148,6 +139,7 @@ GEM
railties (>= 6.1.0)
faker (3.5.2)
i18n (>= 1.8.11, < 2)
+ ffi (1.17.2)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
ffi (1.17.2-arm-linux-gnu)
@@ -158,9 +150,9 @@ GEM
ffi (1.17.2-x86_64-linux-musl)
foreman (0.90.0)
thor (~> 1.4)
- formtastic (5.0.0)
- actionpack (>= 6.0.0)
- formtastic_i18n (0.7.0)
+ fugit (1.13.0)
+ et-orbi (~> 1.4)
+ raabro (~> 1.4)
globalid (1.3.0)
activesupport (>= 6.1)
google-protobuf (4.31.1)
@@ -184,9 +176,6 @@ GEM
google-protobuf (4.31.1-x86_64-linux-musl)
bigdecimal
rake (>= 13)
- has_scope (0.8.2)
- actionpack (>= 5.2)
- activesupport (>= 5.2)
hashdiff (1.2.0)
httparty (0.24.2)
csv
@@ -198,11 +187,6 @@ GEM
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
- inherited_resources (1.14.0)
- actionpack (>= 6.0)
- has_scope (>= 0.6)
- railties (>= 6.0)
- responders (>= 2)
io-console (0.8.2)
irb (1.17.0)
pp (>= 0.6.0)
@@ -212,23 +196,7 @@ GEM
jbuilder (2.13.0)
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
- jquery-rails (4.6.0)
- rails-dom-testing (>= 1, < 3)
- railties (>= 4.2.0)
- thor (>= 0.14, < 2.0)
- json (2.13.2)
- kaminari (1.2.2)
- activesupport (>= 4.1.0)
- kaminari-actionview (= 1.2.2)
- kaminari-activerecord (= 1.2.2)
- kaminari-core (= 1.2.2)
- kaminari-actionview (1.2.2)
- actionview
- kaminari-core (= 1.2.2)
- kaminari-activerecord (1.2.2)
- activerecord
- kaminari-core (= 1.2.2)
- kaminari-core (1.2.2)
+ json (2.21.1)
language_server-protocol (3.17.0.5)
launchy (3.1.1)
addressable (~> 2.8)
@@ -296,6 +264,7 @@ GEM
nokogiri (1.19.2-x86_64-linux-musl)
racc (~> 1.4)
orm_adapter (0.5.0)
+ pagy (9.4.0)
parallel (1.27.0)
parser (3.3.9.0)
ast (~> 2.4.1)
@@ -309,9 +278,6 @@ GEM
pry (0.15.2)
coderay (~> 1.1)
method_source (~> 1.0)
- pry-byebug (3.11.0)
- byebug (~> 12.0)
- pry (>= 0.13, < 0.16)
pry-rails (0.3.11)
pry (>= 0.13.0)
psych (5.3.1)
@@ -320,6 +286,7 @@ GEM
public_suffix (6.0.2)
puma (6.6.1)
nio4r (~> 2.0)
+ raabro (1.5.0)
racc (1.8.1)
rack (3.2.5)
rack-session (2.1.1)
@@ -329,20 +296,20 @@ GEM
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
- rails (7.2.3)
- actioncable (= 7.2.3)
- actionmailbox (= 7.2.3)
- actionmailer (= 7.2.3)
- actionpack (= 7.2.3)
- actiontext (= 7.2.3)
- actionview (= 7.2.3)
- activejob (= 7.2.3)
- activemodel (= 7.2.3)
- activerecord (= 7.2.3)
- activestorage (= 7.2.3)
- activesupport (= 7.2.3)
+ rails (8.1.3)
+ actioncable (= 8.1.3)
+ actionmailbox (= 8.1.3)
+ actionmailer (= 8.1.3)
+ actionpack (= 8.1.3)
+ actiontext (= 8.1.3)
+ actionview (= 8.1.3)
+ activejob (= 8.1.3)
+ activemodel (= 8.1.3)
+ activerecord (= 8.1.3)
+ activestorage (= 8.1.3)
+ activesupport (= 8.1.3)
bundler (>= 1.15.0)
- railties (= 7.2.3)
+ railties (= 8.1.3)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
@@ -354,10 +321,9 @@ GEM
rails-html-sanitizer (1.7.0)
loofah (~> 2.25)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
- railties (7.2.3)
- actionpack (= 7.2.3)
- activesupport (= 7.2.3)
- cgi
+ railties (8.1.3)
+ actionpack (= 8.1.3)
+ activesupport (= 8.1.3)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -366,10 +332,6 @@ GEM
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.1)
- ransack (4.3.0)
- activerecord (>= 6.1.5)
- activesupport (>= 6.1.5)
- i18n
rb-fsevent (0.11.2)
rb-inotify (0.11.1)
ffi (~> 1.0)
@@ -446,7 +408,6 @@ GEM
ruby-lsp-rspec (0.1.28)
ruby-lsp (~> 0.26.0)
ruby-progressbar (1.13.0)
- ruby2_keywords (0.0.5)
rubyzip (2.4.1)
sass-embedded (1.89.2-aarch64-linux-gnu)
google-protobuf (~> 4.31)
@@ -489,6 +450,22 @@ GEM
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
+ solid_cable (4.0.2)
+ actioncable (>= 7.2)
+ activejob (>= 7.2)
+ activerecord (>= 7.2)
+ railties (>= 7.2)
+ solid_cache (1.0.10)
+ activejob (>= 7.2)
+ activerecord (>= 7.2)
+ railties (>= 7.2)
+ solid_queue (1.5.0)
+ activejob (>= 7.1)
+ activerecord (>= 7.1)
+ concurrent-ruby (>= 1.3.1)
+ fugit (~> 1.11)
+ railties (>= 7.1)
+ thor (>= 1.3.1)
spring (4.3.0)
sprockets (4.2.2)
concurrent-ruby (~> 1.0)
@@ -513,7 +490,8 @@ GEM
concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
- unicode-emoji (4.1.0)
+ unicode-emoji (4.2.0)
+ uri (1.1.1)
useragent (0.16.11)
warden (1.2.9)
rack (>= 2.0.9)
@@ -548,14 +526,15 @@ PLATFORMS
x86_64-linux-musl
DEPENDENCIES
- activeadmin
annotate
base64
bootsnap (>= 1.4.4)
capybara
+ cgi
csv
dartsass-rails
database_cleaner-active_record
+ debug
devise
drb
factory_bot_rails
@@ -566,11 +545,11 @@ DEPENDENCIES
letter_opener_web
listen (~> 3.3)
lsa_tdx_feedback (~> 1.0, >= 1.0.4)
+ pagy (~> 9.3)
pg
- pry-byebug
pry-rails
puma (~> 6.0)
- rails (~> 7.2.3, >= 7.2.3)
+ rails (~> 8.0)
rails-controller-testing
rspec-rails
rspec-sqlimit
@@ -587,6 +566,9 @@ DEPENDENCIES
shoulda-matchers (~> 5.0)
simple_form
simplecov
+ solid_cable
+ solid_cache
+ solid_queue
spring (~> 4.0)
sprockets-rails (~> 3.4)
stimulus-rails
@@ -597,7 +579,7 @@ DEPENDENCIES
webmock
RUBY VERSION
- ruby 3.4.9
+ ruby 4.0.6
BUNDLED WITH
4.0.8
diff --git a/README.md b/README.md
index 4d60114..f5dc1b5 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ A Rails-based payment management system for the NELP (New England Literature Pro
### Administrative Features
-- **ActiveAdmin Interface** - Comprehensive admin dashboard
+- **Custom Admin Interface** - Bootstrap admin dashboard (replaces ActiveAdmin)
- **User Management** - View and manage user accounts
- **Payment Administration** - Monitor and manage all payments
- **Program Settings** - Configure program years, fees, and schedules
@@ -24,19 +24,20 @@ A Rails-based payment management system for the NELP (New England Literature Pro
## ๐ Technology Stack
-- **Ruby**: 3.4.4
-- **Rails**: 7.2.2.1
-- **Database**: PostgreSQL
+- **Ruby**: 4.0.6
+- **Rails**: 8.1.3
+- **Database**: PostgreSQL (primary DB also hosts Solid Queue / Cache / Cable)
- **Authentication**: Devise
-- **Admin Interface**: ActiveAdmin
+- **Admin Interface**: Custom `Admin::` MVC
- **Payment Gateway**: Nelnet
- **Asset Pipeline**: dartsass-rails (SCSS compilation)
- **Frontend**: Stimulus, Turbo, Importmap
+- **Background jobs / cache / cable**: Solid Queue, Solid Cache, Solid Cable
- **Testing**: RSpec, FactoryBot, Capybara
## ๐ Prerequisites
-- Ruby 3.4.4
+- Ruby 4.0.6
- PostgreSQL
- Node.js (for asset compilation)
- Git
@@ -135,10 +136,12 @@ bundle exec rspec spec/features/
```
app/
-โโโ admin/ # ActiveAdmin configurations
-โโโ controllers/ # Application controllers
+โโโ controllers/
+โ โโโ admin/ # Custom admin MVC controllers
+โโโ queries/ # Query objects (e.g. admin dashboard)
โโโ models/ # ActiveRecord models
-โโโ views/ # ERB templates
+โโโ views/
+โ โโโ admin/ # Admin ERB templates
โโโ assets/ # SCSS stylesheets and images
โโโ javascript/ # Stimulus controllers
diff --git a/app/admin/admin_users.rb b/app/admin/admin_users.rb
deleted file mode 100644
index fed0ec1..0000000
--- a/app/admin/admin_users.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-ActiveAdmin.register AdminUser do
- permit_params :email, :password, :password_confirmation
-
- index do
- selectable_column
- id_column
- column :email
- column :current_sign_in_at
- column :sign_in_count
- column :created_at
- actions
- end
-
- filter :email
- filter :current_sign_in_at
- filter :sign_in_count
- filter :created_at
-
- form do |f|
- f.inputs do
- f.input :email
- f.input :password
- f.input :password_confirmation
- end
- f.actions
- end
-end
diff --git a/app/admin/dashboard.rb b/app/admin/dashboard.rb
deleted file mode 100644
index 80cb993..0000000
--- a/app/admin/dashboard.rb
+++ /dev/null
@@ -1,243 +0,0 @@
-# frozen_string_literal: true
-
-ActiveAdmin.register_page 'Dashboard' do
- menu priority: 1, label: proc { I18n.t('active_admin.dashboard') }
-
- content title: proc { I18n.t('active_admin.dashboard') } do
- if ProgramSetting.active_program.exists?
- active_program = ProgramSetting.active_program.last
-
- # Get sort and pagination parameters
- sort_column = params[:sort_column] || 'total_paid'
- sort_order = params[:sort_order] || 'desc'
- page = (params[:page] || 1).to_i
- per_page = 20
-
- # User Payment Totals Section
- user_totals = Payment.current_program_payments(active_program.program_year)
- .where(transaction_status: '1') # Only successful payments
- .joins(:user)
- .group('users.id', 'users.email')
- .sum('payments.total_amount::float / 100')
-
- # Calculate balance due for each user and prepare data for sorting
- user_data_with_balance = user_totals.map do |user_data, total_paid|
- user_id, user_email = user_data
- user = User.find(user_id)
- balance_due = Payment.current_balance_due_for_user(user, active_program.program_year)
- {
- user_id: user_id,
- user_email: user_email,
- user: user,
- total_paid: total_paid,
- balance_due: balance_due
- }
- end
-
- # Apply sorting
- user_data_with_balance = case sort_column
- when 'user'
- user_data_with_balance.sort_by { |data| data[:user_email] }
- when 'balance_due'
- user_data_with_balance.sort_by { |data| data[:balance_due].to_f }
- else # 'total_paid' or default
- user_data_with_balance.sort_by { |data| data[:total_paid] }
- end
-
- # Reverse if descending
- user_data_with_balance.reverse! if sort_order == 'desc'
-
- # Pagination
- total_users = user_data_with_balance.count
- total_pages = (total_users.to_f / per_page).ceil
- page = [[page, 1].max, total_pages].min if total_pages > 0 # Ensure page is within valid range
- start_index = (page - 1) * per_page
- end_index = start_index + per_page - 1
- paginated_data = user_data_with_balance[start_index..end_index] || []
-
- div class: 'dashboard_section' do
- h2 "User Payment Totals - Program Year #{active_program.program_year}"
-
- if user_data_with_balance.any?
- table class: 'index_table' do
- thead do
- tr do
- th do
- next_order = (sort_column == 'user' && sort_order == 'asc') ? 'desc' : 'asc'
- a href: admin_dashboard_path(sort_column: 'user', sort_order: next_order, page: page), title: 'Click to sort' do
- text_node 'User '
- span class: 'sort_indicator' do
- if sort_column == 'user'
- text_node sort_order == 'asc' ? 'โฒ' : 'โผ'
- else
- text_node 'โ
'
- end
- end
- end
- end
- th 'Total Paid'
- th 'Program Cost'
- th do
- next_order = (sort_column == 'balance_due' && sort_order == 'asc') ? 'desc' : 'asc'
- a href: admin_dashboard_path(sort_column: 'balance_due', sort_order: next_order, page: page), title: 'Click to sort' do
- text_node 'Balance Due '
- span class: 'sort_indicator' do
- if sort_column == 'balance_due'
- text_node sort_order == 'asc' ? 'โฒ' : 'โผ'
- else
- text_node 'โ
'
- end
- end
- end
- end
- th 'Status'
- end
- end
- tbody do
- paginated_data.each do |data|
- status = data[:balance_due].to_i.zero? ? 'Paid in Full' : 'Outstanding Balance'
- status_class = data[:balance_due].to_i.zero? ? 'paid_full' : 'outstanding'
-
- tr class: status_class do
- td do
- link_to data[:user_email], admin_payments_path('q[user_id_eq]' => data[:user_id]),
- title: "View all payments for #{data[:user_email]}"
- end
- td number_to_currency(data[:total_paid])
- td number_to_currency(active_program.total_cost)
- td number_to_currency(data[:balance_due])
- td status
- end
- end
- end
- end
-
- div class: 'pagination_info' do
- paid_in_full = user_data_with_balance.count { |data| data[:balance_due].to_i.zero? }
- showing_start = total_users.zero? ? 0 : start_index + 1
- showing_end = [end_index + 1, total_users].min
- text_node "Displaying users #{showing_start} - #{showing_end} of #{total_users} | "
- text_node "Paid in Full: #{paid_in_full} | Outstanding: #{total_users - paid_in_full}"
- end
-
- # Pagination controls
- if total_pages > 1
- div class: 'pagination', style: 'text-align: center; margin: 20px 0;' do
- # Previous button
- if page > 1
- a href: admin_dashboard_path(sort_column: sort_column, sort_order: sort_order, page: page - 1),
- class: 'pagination_link',
- style: 'padding: 5px 10px; margin: 0 2px; text-decoration: none;' do
- text_node 'ยซ Previous'
- end
- else
- span style: 'padding: 5px 10px; margin: 0 2px; color: #ccc;' do
- text_node 'ยซ Previous'
- end
- end
-
- # Page numbers
- (1..total_pages).each do |p|
- if p == page
- span class: 'current',
- style: 'padding: 5px 10px; margin: 0 2px; background-color: #5E6469; color: white; border-radius: 3px;' do
- text_node p.to_s
- end
- else
- a href: admin_dashboard_path(sort_column: sort_column, sort_order: sort_order, page: p),
- class: 'pagination_link',
- style: 'padding: 5px 10px; margin: 0 2px; text-decoration: none;' do
- text_node p.to_s
- end
- end
- end
-
- # Next button
- if page < total_pages
- a href: admin_dashboard_path(sort_column: sort_column, sort_order: sort_order, page: page + 1),
- class: 'pagination_link',
- style: 'padding: 5px 10px; margin: 0 2px; text-decoration: none;' do
- text_node 'Next ยป'
- end
- else
- span style: 'padding: 5px 10px; margin: 0 2px; color: #ccc;' do
- text_node 'Next ยป'
- end
- end
- end
- end
-
- # Section separator with subtle styling
- div style: 'width: 100%; height: 1px; background-color: #e2e8f0; margin: 3rem 0 2rem 0; border-radius: 1px; box-shadow: 0 1px 3px rgba(0,0,0,0.08);' do
- text_node ''
- end
- else
- div class: 'blank_slate' do
- text_node 'No successful payments found for the active program.'
- end
- end
- end
-
- # Recent Payments Section
- recent_payments = Payment.current_program_payments(active_program.program_year)
- .includes(:user)
- .order(created_at: :desc)
- .limit(20)
-
- div class: 'dashboard_section' do
- h2 "Recent Payments - Program Year #{active_program.program_year}"
-
- if recent_payments.any?
- table class: 'index_table' do
- thead do
- tr do
- th 'User'
- th 'Transaction ID'
- th 'Amount'
- th 'Status'
- th 'Account Type'
- th 'Date'
- th 'Actions'
- end
- end
- tbody do
- recent_payments.each do |payment|
- tr do
- td do
- link_to payment.user.email, admin_payments_path('q[user_id_eq]' => payment.user_id),
- title: "View all payments for #{payment.user.email}"
- end
- td payment.transaction_id
- td number_to_currency(payment.total_amount.to_f / 100)
- td payment.transaction_status == '1' ? 'Success' : 'Failed'
- td payment.account_type
- td payment.created_at.strftime('%Y-%m-%d %H:%M')
- td do
- link_to 'View', admin_payment_path(payment), class: 'member_link'
- end
- end
- end
- end
- end
-
- div class: 'pagination_info' do
- text_node "Showing most recent #{recent_payments.count} payments"
- end
- else
- div class: 'blank_slate' do
- text_node 'No payments found for the active program.'
- end
- end
- end
- else
- div class: 'blank_slate_container', id: 'dashboard_default_message' do
- span class: 'blank_slate' do
- text_node 'No active program found. Please configure a program setting first.'.html_safe
- end
- end
- end
-
- # Static page message
- text_node StaticPage.find_by(location: 'dashboard').message if StaticPage.find_by(location: 'dashboard').present?
- end
-end
diff --git a/app/admin/inputs/action_text_input.rb b/app/admin/inputs/action_text_input.rb
deleted file mode 100644
index f6d2230..0000000
--- a/app/admin/inputs/action_text_input.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-class ActionTextInput < Formtastic::Inputs::StringInput
- def to_html
- input_wrapping do
- template.javascript_include_tag('trix', 'data-turbo-track': 'reload') +
- template.stylesheet_link_tag('trix') +
- label_html +
- builder.rich_text_area(method, input_html_options)
- end
- end
-end
diff --git a/app/admin/payments.rb b/app/admin/payments.rb
deleted file mode 100644
index 470d7bb..0000000
--- a/app/admin/payments.rb
+++ /dev/null
@@ -1,72 +0,0 @@
-ActiveAdmin.register Payment do
- menu priority: 2
-
- # See permitted parameters documentation:
- # https://github.com/activeadmin/activeadmin/blob/master/docs/2-resource-customization.md#setting-up-strong-parameters
- #
- # Uncomment all parameters which should be permitted for assignment
- #
- permit_params :transaction_type, :transaction_status, :transaction_id, :total_amount, :transaction_date,
- :account_type, :result_code, :result_message, :user_account, :payer_identity, :timestamp, :transaction_hash, :user_id, :program_year
- #
- # or
- #
- # permit_params do
- # permitted = [:transaction_type, :transaction_status, :transaction_id, :total_amount, :transaction_date, :account_type, :result_code, :result_message, :user_account, :payer_identity, :timestamp, :transaction_hash, :user_id]
- # permitted << :other if params[:action] == 'create' && current_user.admin?
- # permitted
- # end
- actions :index, :show, :new, :create, :update, :edit
-
- controller do
- def scoped_collection
- super.includes(:user)
- end
- end
-
- filter :user_id, as: :select, collection: -> { User.order(:email).map { |u| [u.email, u.id] } }
- filter :program_year, as: :select
- filter :account_type, as: :select
- filter :created_at
-
- form do |f| # This is a formtastic form builder
- f.semantic_errors # shows errors on :base
- f.inputs do
- f.input :user_id, as: :select, collection: User.all
- f.input :total_amount
- li "Transaction Type #{f.object.transaction_type}" unless f.object.new_record?
- f.input :transaction_type, input_html: { value: '1' } unless f.object.persisted?
- li "Transaction Status #{f.object.transaction_status}" unless f.object.new_record?
- f.input :transaction_status, input_html: { value: '1' } unless f.object.persisted?
- f.input :transaction_id
- f.input :account_type
- f.input :result_message
- f.input :transaction_date, input_html: { value: DateTime.now.strftime('%Y%m%d%H%M').to_s }
- li "Camp Year #{f.object.program_year}" unless f.object.new_record?
- unless f.object.persisted?
- f.input :program_year,
- input_html: { value: current_program&.program_year || Date.current.year }
- end
- end
- f.actions # adds the 'Submit' and 'Cancel' button
- end
-
- index do
- column :user, sortable: 'users.email'
- column 'Type', &:transaction_type
- column 'Status', &:transaction_status
- column :transaction_id
- column 'Total Amount' do |p|
- number_to_currency(p.total_amount.to_f / 100)
- end
- column :transaction_date
- column :account_type
- column :result_code
- column :result_message
- column :user_account
- column :payer_identity
- column :timestamp
- column :program_year
- actions
- end
-end
diff --git a/app/admin/program_settings.rb b/app/admin/program_settings.rb
deleted file mode 100644
index 7a61647..0000000
--- a/app/admin/program_settings.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-ActiveAdmin.register ProgramSetting do
- menu priority: 1
-
- # See permitted parameters documentation:
- # https://github.com/activeadmin/activeadmin/blob/master/docs/2-resource-customization.md#setting-up-strong-parameters
- #
- # Uncomment all parameters which should be permitted for assignment
- #
- permit_params :program_year, :application_fee, :program_fee, :active, :program_open, :program_close,
- :open_instructions, :close_instructions, :payment_instructions, :allow_payments
- #
- # or
- #
- # permit_params do
- # permitted = [:program_year, :application_fee, :program_fee, :active, :program_open, :program_close, :open_instructions, :close_instructions, :payment_instructions, :allow_payments]
- # permitted << :other if params[:action] == 'create' && current_user.admin?
- # permitted
- # end
-end
diff --git a/app/admin/static_pages.rb b/app/admin/static_pages.rb
deleted file mode 100644
index f338113..0000000
--- a/app/admin/static_pages.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-ActiveAdmin.register StaticPage do
- # See permitted parameters documentation:
- # https://github.com/activeadmin/activeadmin/blob/master/docs/2-resource-customization.md#setting-up-strong-parameters
-
- permit_params :location, :message
-
- actions :all, except: %i[destroy show new]
-
- index title: 'Manage messages on static pages' do
- column :location
- actions
- end
-
- form do |f|
- f.inputs do
- f.input :location
- f.input :message, as: :action_text
- end
- f.actions
- end
-end
diff --git a/app/admin/users.rb b/app/admin/users.rb
deleted file mode 100644
index 8353970..0000000
--- a/app/admin/users.rb
+++ /dev/null
@@ -1,41 +0,0 @@
-ActiveAdmin.register User do
- menu priority: 3
-
- # See permitted parameters documentation:
- # https://github.com/activeadmin/activeadmin/blob/master/docs/2-resource-customization.md#setting-up-strong-parameters
- #
- # Uncomment all parameters which should be permitted for assignment
- #
- permit_params :email, :encrypted_password, :reset_password_token, :reset_password_sent_at, :remember_created_at
- #
- # or
- #
- # permit_params do
- # permitted = [:email, :encrypted_password, :reset_password_token, :reset_password_sent_at, :remember_created_at]
- # permitted << :other if params[:action] == 'create' && current_user.admin?
- # permitted
- # end
-
- scope :all
- filter :email, as: :select
-
- # Custom scope for users with zero balance
- scope :zero_balance do |users|
- users.where(id: Payment.users_with_zero_balance.select(:id))
- end
-
- index do
- selectable_column
- id_column
- column :email
- column 'Balance Due' do |u|
- number_to_currency(u.current_balance_due)
- end
- column :current_sign_in_at
- column :sign_in_count
- column :last_sign_in_at
- column :last_sign_in_ip
- column :created_at
- actions
- end
-end
diff --git a/app/assets/builds/active_admin.css b/app/assets/builds/active_admin.css
deleted file mode 100644
index c623752..0000000
--- a/app/assets/builds/active_admin.css
+++ /dev/null
@@ -1 +0,0 @@
-@media screen{/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:rgba(0,0,0,0)}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-0.25em}sup{top:-0.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button}button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{border-style:none;padding:0}button:-moz-focusring,[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}template{display:none}[hidden]{display:none}html{font-size:100.01%}body{font-size:75%;font-family:"Helvetica Neue",Arial,Helvetica,sans-serif}h1,h2,h3,h4,h5,h6{font-weight:normal;color:#5e6469}h1 img,h2 img,h3 img,h4 img,h5 img,h6 img{margin:0}h1{font-size:3em;line-height:1;margin-bottom:.5em}h2{font-size:2em;margin-bottom:.75em}h3{font-size:1.5em;line-height:1;margin-bottom:1em}h4{font-size:1.2em;line-height:1.25;margin-bottom:1.25em}h5{font-size:1em;font-weight:bold;margin-bottom:1.5em}h6{font-size:1em;font-weight:bold}p{margin:0 0 1.5em}p .left{margin:1.5em 1.5em 1.5em 0;padding:0}p .right{margin:1.5em 0 1.5em 1.5em;padding:0}.left{float:left !important}.right{float:right !important}blockquote{margin:1.5em;color:#666;font-style:italic}strong,dfn{font-weight:bold}em,dfn{font-style:italic}sup,sub{line-height:0}abbr,acronym{border-bottom:1px dotted #666}address{margin:0 0 1.5em;font-style:italic}del{color:#666}pre{margin:1.5em 0;white-space:pre}pre,code,tt{font:1em "andale mono","lucida console",monospace;line-height:1.5}li ul,li ol{margin:0}ul,ol{margin:0 1.5em 1.5em 0;padding-left:1.5em}ul{list-style-type:disc}ol{list-style-type:decimal}dl{margin:0 0 1.5em 0}dl dt{font-weight:bold}dd{margin-left:1.5em}table{margin-bottom:1.4em;width:100%}th{font-weight:bold}thead th{background:#c3d9ff}th,td,caption{padding:4px 10px 4px 5px}.small{font-size:.8em;margin-bottom:1.875em;line-height:1.875em}.large{font-size:1.2em;line-height:2.5em;margin-bottom:1.25em}.hide{display:none}.quiet{color:#666}.loud{color:#000}.highlight{background:#ff0}.added{background:#060;color:#fff}.removed{background:#900;color:#fff}.first{margin-left:0;padding-left:0}.last{margin-right:0;padding-right:0}.top{margin-top:0;padding-top:0}.bottom{margin-bottom:0;padding-bottom:0}#header{background-color:hsl(207.2727272727,5.527638191%,44.0196078431%);background-image:linear-gradient(180deg, rgb(106.0452261307, 112.8140703518, 118.4547738693), rgb(77.1366834171, 82.0603015075, 86.1633165829));border-bottom:1px solid rgb(67.5005025126,71.8090452261,75.3994974874);box-shadow:0 1px 2px rgba(0,0,0,.37);text-shadow:#000 0 1px 0;display:table;height:20px;width:100%;overflow:visible;position:inherit;padding:5px 0;z-index:900}#header h1{display:table-cell;vertical-align:middle;white-space:nowrap;color:#cdcdcd;margin-right:20px;margin-bottom:0px;padding:3px 30px 0 30px;font-size:1.3em;font-weight:normal;line-height:1.2}#header h1 a{text-decoration:none}#header h1 a:hover{color:#fff}#header h1 img{position:relative;top:-2px}#header a,#header a:link{color:#cdcdcd}#header .header-item{top:2px;position:relative;height:20px}#header ul.tabs{display:table-cell;vertical-align:middle;height:100%;margin:0;padding:0}#header ul.tabs li:hover>ul{display:block}#header ul.tabs>li{display:inline-block;margin-right:4px;margin-top:5px;margin-bottom:5px;font-size:1em;position:relative}#header ul.tabs>li a{text-decoration:none;padding:6px 10px 4px 10px;position:relative;border-radius:10px}#header ul.tabs>li.current>a{background:hsl(207.2727272727,5.527638191%,51.0196078431%);color:#fff}#header ul.tabs>li.has_nested>a{background:url("data:image/png;base64,R0lGODlhBwAEAKIAAL6+vry8vIiIiJWVlf///3t7ewAAAAAAACH5BAEAAAUALAAAAAAHAAQAAAMLWLol80MoF5mQKgEAOw==") no-repeat calc(100% - 7px) 50%;padding-right:20px}#header ul.tabs>li.has_nested.current>a{background:hsl(207.2727272727,5.527638191%,51.0196078431%) url("data:image/png;base64,R0lGODlhBwAEAKIAAG1tbWxsbElJSVBQUP///0JCQgAAAAAAACH5BAEAAAUALAAAAAAHAAQAAAMLWLol80MoF5mQKgEAOw==") no-repeat calc(100% - 7px) 50%;padding-right:20px}#header ul.tabs>li:hover>a{background:hsl(207.2727272727,5.527638191%,51.0196078431%);color:#fff}#header ul.tabs>li.has_nested:hover>a{border-radius:0;border-top-right-radius:10px;border-top-left-radius:10px;border-bottom:5px solid hsl(207.2727272727,5.527638191%,51.0196078431%);background:hsl(207.2727272727,5.527638191%,51.0196078431%) url("data:image/png;base64,R0lGODlhBwAEAKIAAG1tbWxsbElJSVBQUP///0JCQgAAAAAAACH5BAEAAAUALAAAAAAHAAQAAAMLWLol80MoF5mQKgEAOw==") no-repeat calc(100% - 7px) 50%;z-index:1020}#header ul.tabs>li ul{background:hsl(207.2727272727,5.527638191%,51.0196078431%);border-top-right-radius:10px;border-top-left-radius:0;border-bottom-right-radius:10px;border-bottom-left-radius:10px;box-shadow:0 1px 3px #444;position:absolute;width:120%;min-width:175px;max-width:calc(100% + 20px);margin-top:5px;float:left;display:none;padding:3px 0px 5px 0;list-style:none;z-index:1010}#header ul.tabs>li ul li{position:relative;margin:0px}#header ul.tabs>li ul li a{background:none;display:block}#header ul.tabs>li ul li a:hover{color:#fff;background:none}#header ul.tabs>li ul li.current a{border-radius:0}#header ul.tabs>li ul li.has_nested>a{background:url("data:image/gif;base64,R0lGODlhBAAHAKECAKqqqszMzPkVFfkVFSH+EUNyZWF0ZWQgd2l0aCBHSU1QACH5BAEKAAIALAAAAAAEAAcAAAIJlA0XKbH9nmAFADs=") no-repeat calc(100% - 7px) 55%;padding-right:20px}#header ul.tabs>li ul li.has_nested:hover>a{background:url("data:image/gif;base64,R0lGODlhBAAHAMIEAG1tbWxsbElJSVBQUPkVFfkVFfkVFfkVFSH+EUNyZWF0ZWQgd2l0aCBHSU1QACH5BAEKAAEALAAAAAAEAAcAAAMKGKqy02G8OGeACQA7") no-repeat calc(100% - 7px) 55%;color:#fff}#header ul.tabs>li ul li ul{border-top-right-radius:10px;border-top-left-radius:10px;border-bottom-right-radius:10px;border-bottom-left-radius:10px;margin-top:0;top:-3px;left:100%}#header ul.tabs>li ul li ul:after{content:"";display:block;position:absolute;top:-8px;left:-8px;height:calc(100% + 16px);width:calc(100% + 16px);z-index:-2}#header #tabs{width:100%}#header #utility_nav{color:#aaa;display:table-cell;white-space:nowrap;margin:0;padding:0;padding-right:26px;text-align:right}#header #utility_nav a{text-decoration:none}#header #utility_nav a:hover{color:#fff}#header #utility_nav li{display:inline}form ul,form ol,form li,form fieldset,form legend,form input,form textarea,form select,form p{margin:0;padding:0}form ol,form ul{list-style:none}form fieldset{border:0;padding:10px 0;margin-bottom:20px}form fieldset.inputs{background:#f4f4f4;border-radius:4px;box-shadow:inset 0 1px 4px #ddd}form fieldset legend{width:100%}form fieldset legend span{display:block;background-color:#efefef;background-image:linear-gradient(180deg, #efefef, #dfe1e2);text-shadow:#fff 0 1px 0;border:solid 1px #cdcdcd;border-color:#d4d4d4;border-top-color:#e6e6e6;border-right-color:#d4d4d4;border-bottom-color:#cdcdcd;border-left-color:#d4d4d4;box-shadow:0 1px 3px rgba(0,0,0,.12),0 0 1px #fff inset;font-size:1em;font-weight:bold;line-height:18px;margin-bottom:.5em;color:#5e6469;padding:5px 10px 3px 10px}form fieldset ol>li{padding:10px}form fieldset ol>li label{display:block;width:20%;float:left;font-size:1em;font-weight:bold;color:#5e6469}form fieldset ol>li label abbr{border:none;color:#aaa}form fieldset ol>li.has_many_container{padding:20px 10px}form fieldset ol>li.has_many_container h3{font-size:12px;font-weight:bold}form fieldset ol>li.has_many_container .has_many_fields{margin:10px 0}form fieldset ol>li>li label{line-height:100%;padding-top:0}form fieldset ol>li>li label input{line-height:100%;vertical-align:middle;margin-top:-0.1em}form .has_many_fields{position:relative}form .has_many_container .handle{position:absolute;top:calc(50% - 1.5em);right:2px;padding:0;cursor:move}form .has_many_container.ui-sortable .has_many_container{margin-right:2em}form .ui-sortable input[type=text],form .ui-sortable input[type=password],form .ui-sortable input[type=email],form .ui-sortable input[type=number],form .ui-sortable input[type=url],form .ui-sortable input[type=tel],form .ui-sortable textarea{width:calc(80% - 22px - 2em - 1px)}form fieldset>ol>li fieldset{position:relative;padding:0;margin-bottom:0}form fieldset>ol>li fieldset:not(.inputs) ol{float:left;width:74%;margin:0;padding:0 0 0 20%}form fieldset>ol>li fieldset:not(.inputs) ol li{padding:0;border:0}form fieldset>ol>li fieldset.inputs ol{float:left;width:100%;margin:0}form input[type=text],form input[type=password],form input[type=email],form input[type=number],form input[type=url],form input[type=tel],form input[type=date],form input[type=time],form textarea{width:calc(80% - 22px);border:1px solid #c9d0d6;border-radius:3px;font-size:.95em;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;outline:none;padding:8px 10px 7px}form input[type=text]:focus,form input[type=password]:focus,form input[type=email]:focus,form input[type=number]:focus,form input[type=url]:focus,form input[type=tel]:focus,form input[type=date]:focus,form input[type=time]:focus,form textarea:focus{border:1px solid #99a2aa;box-shadow:0 0 4px #99a2aa}form input[type=date]{width:calc(100% - 22px)}form fieldset>ol>li p.inline-hints{font-size:.95em;font-style:italic;color:#666;margin:.5em 0 0 20%}form fieldset>ol>li.date_select fieldset ol li,form fieldset>ol>li.time_select fieldset ol li,form fieldset>ol>li.datetime_select fieldset ol li{float:left;width:auto;margin:0 .5em 0 0}form fieldset>ol>li.date_select fieldset ol li label,form fieldset>ol>li.time_select fieldset ol li label,form fieldset>ol>li.datetime_select fieldset ol li label{display:none}form fieldset>ol>li.date_select fieldset ol li input,form fieldset>ol>li.time_select fieldset ol li input,form fieldset>ol>li.datetime_select fieldset ol li input{display:inline;margin:0;padding:0}form fieldset>ol>li.check_boxes fieldset ol,form fieldset>ol>li.radio fieldset ol{margin-bottom:-0.6em}form fieldset>ol>li.check_boxes fieldset ol li,form fieldset>ol>li.radio fieldset ol li{margin:.1em 0 .5em 0}form fieldset>ol>li.check_boxes fieldset ol li label,form fieldset>ol>li.radio fieldset ol li label{float:none;width:100%}form fieldset>ol>li.check_boxes fieldset ol li label input,form fieldset>ol>li.radio fieldset ol li label input{margin-right:.2em}form fieldset>ol>li.boolean{min-height:1.1em}form fieldset>ol>li.boolean label{width:100%;padding-left:20%;padding-right:10px;text-transform:none !important;font-weight:normal}form fieldset>ol>li.boolean label input{margin:0 .5em 0 .2em}form fieldset>ol>li.hidden{padding:0}form fieldset>ol>li p.inline-errors{color:#932419;font-weight:bold;margin:.3em 0 0 20%}form fieldset>ol>li ul.errors{color:#932419;margin:.5em 0 0 20%;list-style:square}form fieldset>ol>li ul.errors li{padding:0;border:none;display:list-item}form fieldset>ol>li.error input[type=text],form fieldset>ol>li.error input[type=password],form fieldset>ol>li.error input[type=email],form fieldset>ol>li.error input[type=number],form fieldset>ol>li.error input[type=url],form fieldset>ol>li.error input[type=tel],form fieldset>ol>li.error textarea{border:1px solid #932419}form ul.errors{background:hsl(5.4098360656,70.9302325581%,93.7254901961%);border-radius:4px;color:#932419;font-weight:bold;margin-bottom:10px;padding:10px;list-style:square}form ul.errors li{margin-left:15px;padding:0;border:none;display:list-item}form input[type=submit],form input[type=button],form button{cursor:pointer;background-color:hsl(207.2727272727,5.527638191%,54.0196078431%);background-image:linear-gradient(180deg, rgb(131.2688442211, 138.3391959799, 144.2311557789), rgb(65.0914572864, 69.2462311558, 72.7085427136));text-shadow:#000 0 1px 0;box-shadow:0 1px 1px rgba(0,0,0,.1),0 1px 0 0px hsla(0,0%,100%,.2) inset;border:solid 1px #484e53;border-color:#484e53;border-top-color:#616a71;border-right-color:#484e53;border-bottom-color:#363b3f;border-left-color:#484e53;color:#efefef;border-radius:200px;display:inline-block;font-weight:bold;font-size:1em;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;line-height:12px;margin-right:3px;padding:7px 16px 6px;text-decoration:none}form input[type=submit].disabled,form input[type=button].disabled,form button.disabled{opacity:.5;cursor:default}form input[type=submit]:not(.disabled):hover,form input[type=button]:not(.disabled):hover,form button:not(.disabled):hover{background-color:hsl(207.2727272727,5.527638191%,57.0196078431%);background-image:linear-gradient(180deg, rgb(139.3417085427, 145.9507537688, 151.4582914573), rgb(72.3185929648, 76.9346733668, 80.7814070352))}form input[type=submit]:not(.disabled):active,form input[type=button]:not(.disabled):active,form button:not(.disabled):active{box-shadow:0 1px 3px rgba(0,0,0,.4) inset,0 1px 0 0px #fff;background-color:rgb(113.272361809,120.5025125628,126.527638191);background-image:linear-gradient(180deg, rgb(113.272361809, 120.5025125628, 126.527638191), rgb(53.0462311558, 56.432160804, 59.2537688442))}form .buttons,form .actions{margin-top:15px}form .buttons input[type=submit],form .buttons input[type=button],form .buttons button,form .actions input[type=submit],form .actions input[type=button],form .actions button{margin-right:10px}form .actions .create_another{float:none;margin-bottom:10px}form .actions .create_another label{float:none;display:inline}form fieldset.buttons li,form fieldset.actions li{float:left;padding:0}form fieldset.buttons li.cancel a,form fieldset.actions li.cancel a{background-color:#fff;background-image:linear-gradient(180deg, #FFFFFF, #E7E7E7);box-shadow:0 1px 1px rgba(0,0,0,.1),0 1px 0 0 hsla(0,0%,100%,.8) inset;border:solid 1px #c7c7c7;border-color:#c7c7c7;border-top-color:#d3d3d3;border-right-color:#c7c7c7;border-bottom-color:#c2c2c2;border-left-color:#c7c7c7;text-shadow:#fff 0 1px 0;color:#5e6469;border-radius:200px;display:inline-block;font-weight:bold;font-size:1em;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;line-height:12px;margin-right:3px;padding:7px 16px 6px;text-decoration:none}form fieldset.buttons li.cancel a.disabled,form fieldset.actions li.cancel a.disabled{opacity:.5;cursor:default}form fieldset.buttons li.cancel a:not(.disabled):hover,form fieldset.actions li.cancel a:not(.disabled):hover{background-color:#fff;background-image:linear-gradient(180deg, #FFFFFF, #F1F1F1)}form fieldset.buttons li.cancel a:not(.disabled):active,form fieldset.actions li.cancel a:not(.disabled):active{box-shadow:0 1px 2px rgba(0,0,0,.22) inset,0 1px 0 0px #eee;border-color:#b9b9b9;border-top-color:#c2c2c2;border-right-color:#b9b9b9;border-bottom-color:#b7b7b7;border-left-color:#b9b9b9;background-color:#f3f3f3;background-image:linear-gradient(180deg, #F3F3F3, #D8D8D8)}.sidebar_section label{display:block;text-transform:uppercase;color:#5e6469;font-size:.9em;font-weight:bold}.sidebar_section select{width:240px}.sidebar_section input[type=text],.sidebar_section input[type=password],.sidebar_section input[type=email],.sidebar_section input[type=url],.sidebar_section input[type=tel],.sidebar_section textarea{width:220px}form.filter_form .filter_form_field{margin-bottom:10px;clear:both}form.filter_form .filter_form_field.select_and_search input[type=text]{margin-left:16px;width:88px}form.filter_form .filter_form_field.select_and_search select{width:108px}form.filter_form .filter_form_field.filter_check_boxes label{margin-bottom:3px}form.filter_form .filter_form_field.filter_check_boxes fieldset{margin-bottom:0px;padding-bottom:0px}form.filter_form .filter_form_field.filter_check_boxes .check_boxes_wrapper label{font-weight:normal;margin-bottom:3px;text-transform:none;font-size:1em}form.filter_form .filter_form_field.filter_check_boxes .check_boxes_wrapper label input{vertical-align:baseline}form.filter_form .filter_form_field.filter_date_range input[type=text]{width:114px}form.filter_form .filter_form_field.filter_date_range input[type=text]+input{margin-left:6px}form.filter_form a.clear_filters_btn{background-color:#fff;background-image:linear-gradient(180deg, #FFFFFF, #E7E7E7);box-shadow:0 1px 1px rgba(0,0,0,.1),0 1px 0 0 hsla(0,0%,100%,.8) inset;border:solid 1px #c7c7c7;border-color:#c7c7c7;border-top-color:#d3d3d3;border-right-color:#c7c7c7;border-bottom-color:#c2c2c2;border-left-color:#c7c7c7;text-shadow:#fff 0 1px 0;color:#5e6469;border-radius:200px;display:inline-block;font-weight:bold;font-size:1em;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;line-height:12px;margin-right:3px;padding:7px 16px 6px;text-decoration:none}form.filter_form a.clear_filters_btn.disabled{opacity:.5;cursor:default}form.filter_form a.clear_filters_btn:not(.disabled):hover{background-color:#fff;background-image:linear-gradient(180deg, #FFFFFF, #F1F1F1)}form.filter_form a.clear_filters_btn:not(.disabled):active{box-shadow:0 1px 2px rgba(0,0,0,.22) inset,0 1px 0 0px #eee;border-color:#b9b9b9;border-top-color:#c2c2c2;border-right-color:#b9b9b9;border-bottom-color:#b7b7b7;border-left-color:#b9b9b9;background-color:#f3f3f3;background-image:linear-gradient(180deg, #F3F3F3, #D8D8D8)}.comments .active_admin_comment{margin-top:10px;margin-bottom:20px;max-width:700px}.comments .active_admin_comment:after{visibility:hidden;display:block;content:"";clear:both;height:0}.comments .active_admin_comment .active_admin_comment_meta{width:130px;float:left;overflow:hidden;font-size:.9em;color:hsl(207.2727272727,5.527638191%,49.0196078431%)}.comments .active_admin_comment .active_admin_comment_meta .active_admin_comment_author{font-size:1.2em;font-weight:bold;margin:0;color:#5e6469}.comments .active_admin_comment .active_admin_comment_body{margin-left:150px}.comments form.active_admin_comment{margin:0;padding:0;margin-left:150px}.comments form.active_admin_comment fieldset.inputs{margin:0;padding:0;background:none;box-shadow:none}.comments form.active_admin_comment li{padding:0}.comments form.active_admin_comment fieldset.buttons{padding:0;margin-top:5px}body.logged_in .flash{background-color:#f7f1d3;background-image:linear-gradient(180deg, #f7f1d3, #f5edc5);text-shadow:#fafafa 0 1px 0;border-bottom:1px solid #eee098;color:#cb9810;font-weight:bold;font-size:1.1em;line-height:1em;padding:13px 30px 11px;position:relative}body.logged_in .flash.flash_notice{background-color:#dce9dd;background-image:linear-gradient(180deg, #dce9dd, #ccdfcd);border-bottom:1px solid #adcbaf;color:#416347}body.logged_in .flash.flash_error{background-color:#f5e4e4;background-image:linear-gradient(180deg, #f5e4e4, #f1dcdc);border-bottom:1px solid #e0c2c0;color:#b33c33}body.logged_out .flash{box-shadow:none;text-shadow:#fff 0 1px 0;background:none;color:#666;font-weight:bold;line-height:1em;padding:0;margin-bottom:8px}.ui-datepicker{background:#fff;background-clip:padding-box;color:#fff;display:none;margin-top:2px;padding:0;text-align:center;width:160px}.ui-datepicker a{text-decoration:none}.ui-datepicker a:hover{cursor:pointer}.ui-datepicker .ui-datepicker-header{background-color:hsl(207.2727272727,5.527638191%,44.0196078431%);background-image:linear-gradient(180deg, rgb(106.0452261307, 112.8140703518, 118.4547738693), rgb(77.1366834171, 82.0603015075, 86.1633165829));border-bottom:1px solid rgb(67.5005025126,71.8090452261,75.3994974874);padding:12px 5px 7px 4px;margin:0px 0px 2px 2px;width:156px;border-top-left-radius:7px;border-top-right-radius:7px;position:relative;z-index:2000}.ui-datepicker .ui-datepicker-header:before{content:"";position:absolute;right:45%;top:-6px;width:0px;height:0px;border-left:8.5px solid rgba(0,0,0,0);border-right:8.5px solid rgba(0,0,0,0);border-bottom:10px solid #676e73}.ui-datepicker .ui-datepicker-header .ui-datepicker-title{text-shadow:#000 0 1px 0;color:#fff;display:block;font-size:1.1em;font-weight:bold;line-height:.8em;text-align:center}.ui-datepicker .ui-datepicker-header .ui-datepicker-title .ui-datepicker-month{margin:-4px 0 0 0}.ui-datepicker .ui-datepicker-header .ui-datepicker-title .ui-datepicker-year{margin:-4px 0 0 0}.ui-datepicker .ui-datepicker-header a{color:#fff;display:block;height:19px;margin-top:-4px;width:10px}.ui-datepicker .ui-datepicker-header a.ui-datepicker-prev{float:left;width:0;height:0;margin:0px 0px 0px 4px;border-top:5px solid rgba(0,0,0,0);border-right:5px solid #fff;border-bottom:5px solid rgba(0,0,0,0)}.ui-datepicker .ui-datepicker-header a.ui-datepicker-next{float:right;width:0;height:0;margin:0px 4px 0px 0px;border-top:5px solid rgba(0,0,0,0);border-left:5px solid #fff;border-bottom:5px solid rgba(0,0,0,0)}.ui-datepicker .ui-datepicker-header a span{display:none}.ui-datepicker table.ui-datepicker-calendar{border-radius:0;border-bottom-right-radius:3px;border-bottom-left-radius:3px;box-shadow:0 1px 6px rgba(0,0,0,.26);background-color:#f4f4f4;border:solid 1px #63686e;left:2px;margin-bottom:0px;position:relative;top:-2px;width:156px}.ui-datepicker table.ui-datepicker-calendar td,.ui-datepicker table.ui-datepicker-calendar th{padding:0px;text-align:center}.ui-datepicker table.ui-datepicker-calendar thead th{background-color:#dbdddf;color:#333;font-weight:normal;font-size:.8em;padding-top:1px}.ui-datepicker table.ui-datepicker-calendar tbody{color:#666}.ui-datepicker table.ui-datepicker-calendar tbody td{border:none;height:24px;width:22px}.ui-datepicker table.ui-datepicker-calendar tbody td a{border-radius:3px;color:#666;font-weight:bold;font-size:.85em;padding:4px}.ui-datepicker table.ui-datepicker-calendar tbody td a.ui-state-active{background-color:#5a5f64;color:#fff}.ui-datepicker table.ui-datepicker-calendar tbody td a.ui-state-active.ui-state-hover{background-color:#5a5f64;color:#fff}.ui-datepicker table.ui-datepicker-calendar tbody td a.ui-state-hover{background-color:#eceef0}.ui-datepicker table.ui-datepicker-calendar tbody td a.ui-state-highlight{background-color:#dbdddf}table tr td{vertical-align:top}table tr th{text-align:left}table.index_table{width:100%;margin-bottom:10px;border:0;border-spacing:0}table.index_table th{background-color:#efefef;background-image:linear-gradient(180deg, #efefef, #dfe1e2);text-shadow:#fff 0 1px 0;border:solid 1px #cdcdcd;border-color:#d4d4d4;border-top-color:#e6e6e6;border-right-color:#d4d4d4;border-bottom-color:#cdcdcd;border-left-color:#d4d4d4;box-shadow:0 1px 3px rgba(0,0,0,.12),0 0 1px #fff inset;font-size:1em;font-weight:bold;line-height:18px;margin-bottom:.5em;color:#5e6469;padding:5px 10px 3px 10px;border-right:none;padding-left:12px;padding-right:12px}table.index_table th a,table.index_table th a:link,table.index_table th a:visited{color:#5e6469;text-decoration:none;display:block;white-space:nowrap}table.index_table th.sortable a{background:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAABGCAYAAAAAVo4aAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAH5JREFUeNpi3LhlOwMU1AExGxDXwARYoHQLEFdD2cxAXAliMKFJgEAFEHfBJEHGMKLhMpgkTsAEdch/NNwCk2xCdiEQtML4LEgCf6EubUX3Cgh0oNvJ+P//f7wOGpUclRwYSZb41CyidNbB8giNM+9oXhmVHHm5bJjUSAABBgDKKiwMMUxPwgAAAABJRU5ErkJggg==") no-repeat 0 4px;padding-left:13px}table.index_table th.sorted-asc a{background-position:0 -27px}table.index_table th.sorted-desc a{background-position:0 -56px}table.index_table th.sorted-asc,table.index_table th.sorted-desc{background-color:hsl(0,0%,88.7254901961%);background-image:linear-gradient(180deg, rgb(226.25, 226.25, 226.25), rgb(209.6229508197, 212.4590163934, 213.8770491803))}table.index_table th:last-child{border-right:solid 1px #d4d4d4}table.index_table tr.even td{background:hsl(207.2727272727,5.527638191%,96.0196078431%)}table.index_table tr.selected td{background:#d9e4ec}table.index_table td{padding:10px 12px 8px 12px;border-bottom:1px solid #e8e8e8;vertical-align:top}.panel_contents table{margin-top:5px}.panel_contents table th{padding-top:10px;background:none;color:#5e6469;box-shadow:none;text-shadow:#fff 0 1px 0;text-transform:uppercase;border-bottom:1px solid #ccc}.panel_contents table tr.odd td{background:rgb(236.216080402,237.2894472362,238.183919598)}.panel_contents table tr.even td{background:hsl(207.2727272727,5.527638191%,96.0196078431%)}.attributes_table{overflow:hidden}.attributes_table table col.even{background:hsl(207.2727272727,5.527638191%,96.0196078431%)}.attributes_table table col.odd{background:rgb(236.216080402,237.2894472362,238.183919598)}.attributes_table table th,.attributes_table table td{padding:8px 12px 6px 12px;vertical-align:top;border-bottom:1px solid #e8e8e8}.attributes_table table th{box-shadow:none;background:none;width:150px;font-size:.9em;padding-left:0;text-transform:uppercase;color:#5e6469;text-shadow:#fff 0 1px 0}.attributes_table table td .empty{color:#bbb;font-size:.8em;text-transform:uppercase;letter-spacing:.2em}.sidebar_section .attributes_table th{width:50px}#collection_selection_toggle_panel:after{visibility:hidden;display:block;content:"";clear:both;height:0}#collection_selection_toggle_panel>.resource_selection_toggle_cell{float:left}.ui-widget-overlay{position:fixed;background:rgba(0,0,0,.2);top:0;left:0;right:0;bottom:0;z-index:1001}.ui-dialog{position:fixed;z-index:1002;background:#f4f4f4;border-radius:4px;box-shadow:inset 0 1px 4px #ddd;box-shadow:rgba(0,0,0,.5) 0 0 10px}.ui-dialog .ui-dialog-titlebar{background-color:#efefef;background-image:linear-gradient(180deg, #efefef, #dfe1e2);text-shadow:#fff 0 1px 0;border:solid 1px #cdcdcd;border-color:#d4d4d4;border-top-color:#e6e6e6;border-right-color:#d4d4d4;border-bottom-color:#cdcdcd;border-left-color:#d4d4d4;box-shadow:0 1px 3px rgba(0,0,0,.12),0 0 1px #fff inset;font-size:1em;font-weight:bold;line-height:18px;margin-bottom:.5em;color:#5e6469;padding:5px 10px 3px 10px}.ui-dialog .ui-dialog-titlebar span{font-size:1.1em}.ui-dialog ul{list-style-type:none}.ui-dialog li{margin:10px 0}.ui-dialog label{margin-right:10px}.ui-dialog .ui-dialog-buttonpane,.ui-dialog form{padding:7px 15px 13px}.ui-dialog .ui-dialog-buttonpane button{background-color:hsl(207.2727272727,5.527638191%,54.0196078431%);background-image:linear-gradient(180deg, rgb(131.2688442211, 138.3391959799, 144.2311557789), rgb(65.0914572864, 69.2462311558, 72.7085427136));text-shadow:#000 0 1px 0;box-shadow:0 1px 1px rgba(0,0,0,.1),0 1px 0 0px hsla(0,0%,100%,.2) inset;border:solid 1px #484e53;border-color:#484e53;border-top-color:#616a71;border-right-color:#484e53;border-bottom-color:#363b3f;border-left-color:#484e53;color:#efefef;border-radius:200px;display:inline-block;font-weight:bold;font-size:1em;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;line-height:12px;margin-right:3px;padding:7px 16px 6px;text-decoration:none}.ui-dialog .ui-dialog-buttonpane button.disabled{opacity:.5;cursor:default}.ui-dialog .ui-dialog-buttonpane button:not(.disabled):hover{background-color:hsl(207.2727272727,5.527638191%,57.0196078431%);background-image:linear-gradient(180deg, rgb(139.3417085427, 145.9507537688, 151.4582914573), rgb(72.3185929648, 76.9346733668, 80.7814070352))}.ui-dialog .ui-dialog-buttonpane button:not(.disabled):active{box-shadow:0 1px 3px rgba(0,0,0,.4) inset,0 1px 0 0px #fff;background-color:rgb(113.272361809,120.5025125628,126.527638191);background-image:linear-gradient(180deg, rgb(113.272361809, 120.5025125628, 126.527638191), rgb(53.0462311558, 56.432160804, 59.2537688442))}.ui-dialog .ui-dialog-buttonpane button:last-child{background-color:#fff;background-image:linear-gradient(180deg, #FFFFFF, #E7E7E7);box-shadow:0 1px 1px rgba(0,0,0,.1),0 1px 0 0 hsla(0,0%,100%,.8) inset;border:solid 1px #c7c7c7;border-color:#c7c7c7;border-top-color:#d3d3d3;border-right-color:#c7c7c7;border-bottom-color:#c2c2c2;border-left-color:#c7c7c7;text-shadow:#fff 0 1px 0;color:#5e6469;border-radius:200px;display:inline-block;font-weight:bold;font-size:1em;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;line-height:12px;margin-right:3px;padding:7px 16px 6px;text-decoration:none}.ui-dialog .ui-dialog-buttonpane button:last-child.disabled{opacity:.5;cursor:default}.ui-dialog .ui-dialog-buttonpane button:last-child:not(.disabled):hover{background-color:#fff;background-image:linear-gradient(180deg, #FFFFFF, #F1F1F1)}.ui-dialog .ui-dialog-buttonpane button:last-child:not(.disabled):active{box-shadow:0 1px 2px rgba(0,0,0,.22) inset,0 1px 0 0px #eee;border-color:#b9b9b9;border-top-color:#c2c2c2;border-right-color:#b9b9b9;border-bottom-color:#b7b7b7;border-left-color:#b9b9b9;background-color:#f3f3f3;background-image:linear-gradient(180deg, #F3F3F3, #D8D8D8)}.active_admin_dialog.ui-dialog .ui-dialog-titlebar-close{display:none}.blank_slate_container{clear:both;text-align:center}.blank_slate_container .blank_slate{border-radius:3px;border:1px dashed #dadada;color:#aaa;display:inline-block;font-size:1.2em;font-weight:bold;padding:14px 25px;text-align:center}.blank_slate_container .blank_slate small{display:block;font-size:.9em;font-weight:normal}.admin_dashboard .blank_slate_container .blank_slate{margin-top:40px;margin-bottom:40px}.with_sidebar .blank_slate_container .blank_slate{margin-top:80px}.breadcrumb{display:block;font-size:.9em;font-weight:normal;line-height:1em;margin-bottom:12px;text-transform:uppercase}.breadcrumb a,.breadcrumb a:link,.breadcrumb a:visited,.breadcrumb a:active{color:#8a949e;text-decoration:none}.breadcrumb a:hover{text-decoration:underline}.breadcrumb .breadcrumb_sep{margin:0 2px;color:#aab2ba}.dropdown_menu{display:inline}.dropdown_menu .dropdown_menu_button{background-color:#fff;background-image:linear-gradient(180deg, #FFFFFF, #E7E7E7);box-shadow:0 1px 1px rgba(0,0,0,.1),0 1px 0 0 hsla(0,0%,100%,.8) inset;border:solid 1px #c7c7c7;border-color:#c7c7c7;border-top-color:#d3d3d3;border-right-color:#c7c7c7;border-bottom-color:#c2c2c2;border-left-color:#c7c7c7;text-shadow:#fff 0 1px 0;color:#5e6469;border-radius:200px;display:inline-block;font-weight:bold;font-size:1em;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;line-height:12px;margin-right:3px;padding:7px 16px 6px;text-decoration:none}.dropdown_menu .dropdown_menu_button.disabled{opacity:.5;cursor:default}.dropdown_menu .dropdown_menu_button:not(.disabled):hover{background-color:#fff;background-image:linear-gradient(180deg, #FFFFFF, #F1F1F1)}.dropdown_menu .dropdown_menu_button:not(.disabled):active{box-shadow:0 1px 2px rgba(0,0,0,.22) inset,0 1px 0 0px #eee;border-color:#b9b9b9;border-top-color:#c2c2c2;border-right-color:#b9b9b9;border-bottom-color:#b7b7b7;border-left-color:#b9b9b9;background-color:#f3f3f3;background-image:linear-gradient(180deg, #F3F3F3, #D8D8D8)}.dropdown_menu .dropdown_menu_button{position:relative;padding-right:22px !important;cursor:pointer}.dropdown_menu .dropdown_menu_button:before{content:" ";position:absolute;width:0;height:0;border-width:3px 3px 0;border-style:solid;border-color:#fff rgba(0,0,0,0);right:12px;top:45%}.dropdown_menu .dropdown_menu_button:after{content:" ";position:absolute;width:0;height:0;border-width:3px 3px 0;border-style:solid;border-color:#777 rgba(0,0,0,0);right:12px;top:45%}.dropdown_menu .dropdown_menu_nipple{content:"";position:absolute;top:-6px;display:block;width:0;height:0;border-width:0 6px 6px;border-style:solid;border-color:rgb(84.3638190955,89.7487437186,94.2361809045) rgba(0,0,0,0);z-index:100}.dropdown_menu .dropdown_menu_nipple:before{content:" ";position:absolute;width:0;height:0;border-width:0 5px 5px;border-style:solid;border-color:hsl(207.2727272727,5.527638191%,54.0196078431%) rgba(0,0,0,0);left:-5px;top:1px}.dropdown_menu .dropdown_menu_nipple:after{content:" ";position:absolute;width:0;height:0;border-width:0 5px 5px;border-style:solid;border-color:hsl(207.2727272727,5.527638191%,43.0196078431%) rgba(0,0,0,0);left:-5px;top:2px}.dropdown_menu .dropdown_menu_list_wrapper{display:inline-block;position:absolute;background-color:#fff;padding:2px;box-shadow:rgba(0,0,0,.4) 0 1px 3px,hsl(207.2727272727,5.527638191%,54.0196078431%) 0px 1px 0px 0px inset;background-color:#5e6469;background-color:hsl(207.2727272727,5.527638191%,43.0196078431%);background-image:linear-gradient(180deg, rgb(103.6361809045, 110.2512562814, 115.7638190955), rgb(81.9547738693, 87.1859296482, 91.5452261307));border:solid 1px rgb(69.9095477387,74.3718592965,78.0904522613);border-top-color:rgb(84.3638190955,89.7487437186,94.2361809045);border-bottom-color:rgb(53.0462311558,56.432160804,59.2537688442);border-radius:4px;z-index:2000;display:none}.dropdown_menu .dropdown_menu_list_wrapper .dropdown_menu_list{display:block;background-color:#fff;border:solid 1px rgb(69.9095477387,74.3718592965,78.0904522613);box-shadow:hsl(207.2727272727,5.527638191%,44.0196078431%) 0px 1px 0px 0px;border-radius:3px;margin:0;overflow:hidden;padding:8px;list-style-type:none;padding:0}.dropdown_menu .dropdown_menu_list_wrapper .dropdown_menu_list li{display:block;border-bottom:solid 1px #ebebeb}.dropdown_menu .dropdown_menu_list_wrapper .dropdown_menu_list li a{display:block;box-sizing:padding-box;font-size:.95em;font-weight:bold;padding:7px 16px 5px;text-decoration:none;text-align:center;white-space:nowrap}.dropdown_menu .dropdown_menu_list_wrapper .dropdown_menu_list li a:hover{background-color:#75a1c2;background-image:linear-gradient(180deg, #75a1c2, #608cb4);text-shadow:#5a83aa 0 1px 0;color:#fff}.dropdown_menu .dropdown_menu_list_wrapper .dropdown_menu_list li a:active{background-color:#608cb4;background-image:linear-gradient(180deg, #608cb4, #75a1c2);color:#fff}.dropdown_menu .dropdown_menu_list_wrapper .dropdown_menu_list li:first-child a{border-top-left-radius:2px;border-top-right-radius:2px}.dropdown_menu .dropdown_menu_list_wrapper .dropdown_menu_list li:last-child{border:none}.dropdown_menu .dropdown_menu_list_wrapper .dropdown_menu_list li:last-child a{border-bottom-left-radius:2px;border-bottom-right-radius:2px}a.member_link{margin-right:7px;white-space:nowrap}a.button,a:link.button,a:visited.button,input[type=submit],input[type=button],button{background-color:hsl(207.2727272727,5.527638191%,54.0196078431%);background-image:linear-gradient(180deg, rgb(131.2688442211, 138.3391959799, 144.2311557789), rgb(65.0914572864, 69.2462311558, 72.7085427136));text-shadow:#000 0 1px 0;box-shadow:0 1px 1px rgba(0,0,0,.1),0 1px 0 0px hsla(0,0%,100%,.2) inset;border:solid 1px #484e53;border-color:#484e53;border-top-color:#616a71;border-right-color:#484e53;border-bottom-color:#363b3f;border-left-color:#484e53;color:#efefef;border-radius:200px;display:inline-block;font-weight:bold;font-size:1em;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;line-height:12px;margin-right:3px;padding:7px 16px 6px;text-decoration:none}a.button.disabled,a:link.button.disabled,a:visited.button.disabled,input[type=submit].disabled,input[type=button].disabled,button.disabled{opacity:.5;cursor:default}a.button:not(.disabled):hover,a:link.button:not(.disabled):hover,a:visited.button:not(.disabled):hover,input[type=submit]:not(.disabled):hover,input[type=button]:not(.disabled):hover,button:not(.disabled):hover{background-color:hsl(207.2727272727,5.527638191%,57.0196078431%);background-image:linear-gradient(180deg, rgb(139.3417085427, 145.9507537688, 151.4582914573), rgb(72.3185929648, 76.9346733668, 80.7814070352))}a.button:not(.disabled):active,a:link.button:not(.disabled):active,a:visited.button:not(.disabled):active,input[type=submit]:not(.disabled):active,input[type=button]:not(.disabled):active,button:not(.disabled):active{box-shadow:0 1px 3px rgba(0,0,0,.4) inset,0 1px 0 0px #fff;background-color:rgb(113.272361809,120.5025125628,126.527638191);background-image:linear-gradient(180deg, rgb(113.272361809, 120.5025125628, 126.527638191), rgb(53.0462311558, 56.432160804, 59.2537688442))}table.index_grid td{border:none;background:none;padding:0 20px 20px 0;margin:0}.columns{clear:both;padding:0}.columns .column{float:left}a,a:link,a:visited{color:#38678b;text-decoration:underline}a:hover{text-decoration:none}.paginated_collection_contents{clear:both}.pagination{float:right;font-size:.9em;margin-left:10px}.pagination a{background-color:#fff;background-image:linear-gradient(180deg, #FFFFFF, #E7E7E7);box-shadow:0 1px 1px rgba(0,0,0,.1),0 1px 0 0 hsla(0,0%,100%,.8) inset;border:solid 1px #c7c7c7;border-color:#c7c7c7;border-top-color:#d3d3d3;border-right-color:#c7c7c7;border-bottom-color:#c2c2c2;border-left-color:#c7c7c7;text-shadow:#fff 0 1px 0;color:#5e6469;border-radius:200px;display:inline-block;font-weight:bold;font-size:1em;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;line-height:12px;margin-right:3px;padding:7px 16px 6px;text-decoration:none}.pagination a.disabled{opacity:.5;cursor:default}.pagination a:not(.disabled):hover{background-color:#fff;background-image:linear-gradient(180deg, #FFFFFF, #F1F1F1)}.pagination a:not(.disabled):active{box-shadow:0 1px 2px rgba(0,0,0,.22) inset,0 1px 0 0px #eee;border-color:#b9b9b9;border-top-color:#c2c2c2;border-right-color:#b9b9b9;border-bottom-color:#b7b7b7;border-left-color:#b9b9b9;background-color:#f3f3f3;background-image:linear-gradient(180deg, #F3F3F3, #D8D8D8)}.pagination span.page.current{background-color:hsl(207.2727272727,5.527638191%,54.0196078431%);background-image:linear-gradient(180deg, rgb(131.2688442211, 138.3391959799, 144.2311557789), rgb(65.0914572864, 69.2462311558, 72.7085427136));text-shadow:#000 0 1px 0;box-shadow:0 1px 1px rgba(0,0,0,.1),0 1px 0 0px hsla(0,0%,100%,.2) inset;border:solid 1px #484e53;border-color:#484e53;border-top-color:#616a71;border-right-color:#484e53;border-bottom-color:#363b3f;border-left-color:#484e53;color:#efefef;border-radius:200px;display:inline-block;font-weight:bold;font-size:1em;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;line-height:12px;margin-right:3px;padding:7px 16px 6px;text-decoration:none}.pagination span.page.current.disabled{opacity:.5;cursor:default}.pagination span.page.current:not(.disabled):hover{background-color:hsl(207.2727272727,5.527638191%,57.0196078431%);background-image:linear-gradient(180deg, rgb(139.3417085427, 145.9507537688, 151.4582914573), rgb(72.3185929648, 76.9346733668, 80.7814070352))}.pagination span.page.current:not(.disabled):active{box-shadow:0 1px 3px rgba(0,0,0,.4) inset,0 1px 0 0px #fff;background-color:rgb(113.272361809,120.5025125628,126.527638191);background-image:linear-gradient(180deg, rgb(113.272361809, 120.5025125628, 126.527638191), rgb(53.0462311558, 56.432160804, 59.2537688442))}.pagination a,.pagination span.page.current{border-radius:0px;margin-right:4px;padding:2px 5px}.pagination_information{float:right;margin-bottom:5px;color:#b3bcc1}.pagination_information b{color:#5c6469}.download_links{float:left}.pagination_per_page{float:right;margin-left:4px}.pagination_per_page select{background-color:#fff;background-image:linear-gradient(180deg, #FFFFFF, #E7E7E7);box-shadow:0 1px 1px rgba(0,0,0,.1),0 1px 0 0 hsla(0,0%,100%,.8) inset;border:solid 1px #c7c7c7;border-color:#c7c7c7;border-top-color:#d3d3d3;border-right-color:#c7c7c7;border-bottom-color:#c2c2c2;border-left-color:#c7c7c7;text-shadow:#fff 0 1px 0;color:#5e6469;border-radius:200px;display:inline-block;font-weight:bold;font-size:1em;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;line-height:12px;margin-right:3px;padding:7px 16px 6px;text-decoration:none}.pagination_per_page select.disabled{opacity:.5;cursor:default}.pagination_per_page select:not(.disabled):hover{background-color:#fff;background-image:linear-gradient(180deg, #FFFFFF, #F1F1F1)}.pagination_per_page select:not(.disabled):active{box-shadow:0 1px 2px rgba(0,0,0,.22) inset,0 1px 0 0px #eee;border-color:#b9b9b9;border-top-color:#c2c2c2;border-right-color:#b9b9b9;border-bottom-color:#b7b7b7;border-left-color:#b9b9b9;background-color:#f3f3f3;background-image:linear-gradient(180deg, #F3F3F3, #D8D8D8)}.pagination_per_page select{border-radius:0px;padding:1px 5px}.comments .pagination{float:left;margin-bottom:30px}.comments .pagination_information{float:left;color:#000}.section,.panel{background:#f4f4f4;border-radius:4px;box-shadow:inset 0 1px 4px #ddd;margin-bottom:20px}.section>h3,.panel>h3{background-color:#efefef;background-image:linear-gradient(180deg, #efefef, #dfe1e2);text-shadow:#fff 0 1px 0;border:solid 1px #cdcdcd;border-color:#d4d4d4;border-top-color:#e6e6e6;border-right-color:#d4d4d4;border-bottom-color:#cdcdcd;border-left-color:#d4d4d4;box-shadow:0 1px 3px rgba(0,0,0,.12),0 0 1px #fff inset;font-size:1em;font-weight:bold;line-height:18px;margin-bottom:.5em;color:#5e6469;padding:5px 10px 3px 10px}.section>h3 .header_action,.panel>h3 .header_action{float:right}.section>div,.panel>div{padding:3px 15px 15px 15px}.section hr,.panel hr{border:none;border-bottom:1px solid #e8e8e8}.sidebar_section{background:#f4f4f4;border-radius:4px;box-shadow:inset 0 1px 4px #ddd;margin-bottom:20px}.sidebar_section>h3{background-color:#efefef;background-image:linear-gradient(180deg, #efefef, #dfe1e2);text-shadow:#fff 0 1px 0;border:solid 1px #cdcdcd;border-color:#d4d4d4;border-top-color:#e6e6e6;border-right-color:#d4d4d4;border-bottom-color:#cdcdcd;border-left-color:#d4d4d4;box-shadow:0 1px 3px rgba(0,0,0,.12),0 0 1px #fff inset;font-size:1em;font-weight:bold;line-height:18px;margin-bottom:.5em;color:#5e6469;padding:5px 10px 3px 10px}.sidebar_section>h3 .header_action{float:right}.sidebar_section>div{padding:3px 15px 15px 15px}.sidebar_section hr{border:none;border-bottom:1px solid #e8e8e8}.columns{margin-bottom:10px}.scopes li .count{color:#8e979e;font-weight:normal;font-size:.9em;line-height:10px}.scopes li:first-child a{margin-left:10px}.status_tag{background:hsl(0,0%,79.1176470588%);color:#fff;text-transform:uppercase;letter-spacing:.15em;padding:3px 5px 2px 5px;font-size:.8em}.status_tag.yes{background:#6090db}.status_tag.no{background:gray}.table_tools{margin-bottom:16px}.table_tools:after{visibility:hidden;display:block;content:"";clear:both;height:0}.table_tools .dropdown_menu{float:left}a.table_tools_button,.table_tools .dropdown_menu_button{background-color:#fff;background-image:linear-gradient(180deg, #FFFFFF, #E7E7E7);box-shadow:0 1px 1px rgba(0,0,0,.1),0 1px 0 0 hsla(0,0%,100%,.8) inset;border:solid 1px #c7c7c7;border-color:#c7c7c7;border-top-color:#d3d3d3;border-right-color:#c7c7c7;border-bottom-color:#c2c2c2;border-left-color:#c7c7c7;text-shadow:#fff 0 1px 0;color:#5e6469;border-radius:200px;display:inline-block;font-weight:bold;font-size:1em;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;line-height:12px;margin-right:3px;padding:7px 16px 6px;text-decoration:none}a.table_tools_button.disabled,.table_tools .dropdown_menu_button.disabled{opacity:.5;cursor:default}a.table_tools_button:not(.disabled):hover,.table_tools .dropdown_menu_button:not(.disabled):hover{background-color:#fff;background-image:linear-gradient(180deg, #FFFFFF, #F1F1F1)}a.table_tools_button:not(.disabled):active,.table_tools .dropdown_menu_button:not(.disabled):active{box-shadow:0 1px 2px rgba(0,0,0,.22) inset,0 1px 0 0px #eee;border-color:#b9b9b9;border-top-color:#c2c2c2;border-right-color:#b9b9b9;border-bottom-color:#b7b7b7;border-left-color:#b9b9b9;background-color:#f3f3f3;background-image:linear-gradient(180deg, #F3F3F3, #D8D8D8)}a.table_tools_button,.table_tools .dropdown_menu_button{background-color:#fff;background-image:linear-gradient(180deg, #FFFFFF, #F0F0F0);border-color:#d0d0d0;border-top-color:#d9d9d9;border-right-color:#d0d0d0;border-bottom-color:#c5c5c5;border-left-color:#d0d0d0;font-size:.9em;padding:4px 14px 4px;margin:0}a.table_tools_button:not(.disabled):hover,.table_tools .dropdown_menu_button:not(.disabled):hover{background-color:#fff;background-image:linear-gradient(180deg, #FFFFFF, #F6F6F6)}a.table_tools_button:not(.disabled):active,.table_tools .dropdown_menu_button:not(.disabled):active{border-color:#c8c8c8;border-top-color:#d7d7d7;border-right-color:#c8c8c8;border-bottom-color:#c3c3c3;border-left-color:#c8c8c8;box-shadow:0 1px 1px 0 rgba(0,0,0,.17) inset;background-color:#fff;background-image:linear-gradient(180deg, #FFFFFF, #E8E8E8)}.table_tools_segmented_control{list-style-type:none;padding:0;margin:0}.table_tools_segmented_control li{float:left}.table_tools_segmented_control li a{border-width:1px .5px 1px .5px;border-radius:0}.table_tools_segmented_control li:first-child a{border-left-width:1px;border-top-left-radius:12px;border-bottom-left-radius:12px}.table_tools_segmented_control li:last-child a{border-right-width:1px;border-top-right-radius:12px;border-bottom-right-radius:12px}.table_tools_segmented_control li.selected a{background-color:#f0f0f0;background-image:linear-gradient(180deg, #F0F0F0, #FDFDFD);box-shadow:0 1px 1px 0 rgba(0,0,0,.1) inset;cursor:default}.table_tools_segmented_control li.selected a:hover{background-color:#f0f0f0;background-image:linear-gradient(180deg, #F0F0F0, #FDFDFD)}.indexes{float:right}.indexes li .count{color:#8e979e;font-weight:normal;font-size:.9em;line-height:10px}.unsupported_browser{padding:10px 30px;color:#211e14;background-color:#fae692;background-color:#feefae;background-image:linear-gradient(180deg, #feefae, #fae692);border-bottom:1px solid #b3a569}.unsupported_browser h1{font-size:13px;font-weight:bold}.unsupported_browser p{margin-bottom:.5em}.ui-tabs-nav{list-style:none;display:block;width:auto;margin-bottom:-12px;padding-left:0;overflow:auto;margin-left:15px}.ui-tabs-nav li{display:block;position:relative;margin:0;padding:0;float:left}.ui-tabs-nav li:first-child a{border-left-width:1px;border-top-left-radius:12px;border-bottom-left-radius:12px}.ui-tabs-nav li:last-child a{border-right-width:1px;border-top-right-radius:12px;border-bottom-right-radius:12px}.ui-tabs-nav li a{background-color:#fff;background-image:linear-gradient(180deg, #FFFFFF, #E7E7E7);box-shadow:0 1px 1px rgba(0,0,0,.1),0 1px 0 0 hsla(0,0%,100%,.8) inset;border:solid 1px #c7c7c7;border-color:#c7c7c7;border-top-color:#d3d3d3;border-right-color:#c7c7c7;border-bottom-color:#c2c2c2;border-left-color:#c7c7c7;text-shadow:#fff 0 1px 0;color:#5e6469;border-radius:200px;display:inline-block;font-weight:bold;font-size:1em;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;line-height:12px;margin-right:3px;padding:7px 16px 6px;text-decoration:none}.ui-tabs-nav li a.disabled{opacity:.5;cursor:default}.ui-tabs-nav li a:not(.disabled):hover{background-color:#fff;background-image:linear-gradient(180deg, #FFFFFF, #F1F1F1)}.ui-tabs-nav li a:not(.disabled):active{box-shadow:0 1px 2px rgba(0,0,0,.22) inset,0 1px 0 0px #eee;border-color:#b9b9b9;border-top-color:#c2c2c2;border-right-color:#b9b9b9;border-bottom-color:#b7b7b7;border-left-color:#b9b9b9;background-color:#f3f3f3;background-image:linear-gradient(180deg, #F3F3F3, #D8D8D8)}.ui-tabs-nav li a{background-color:#fff;background-image:linear-gradient(180deg, #FFFFFF, #F0F0F0);border-color:#d0d0d0;border-top-color:#d9d9d9;border-right-color:#d0d0d0;border-bottom-color:#c5c5c5;border-left-color:#d0d0d0;text-decoration:none;border-radius:0;border-width:1px .5px 1px .5px;margin-right:0;padding:4px 14px 4px}.ui-tabs-nav li a:not(.disabled):hover{background-color:#fff;background-image:linear-gradient(180deg, #FFFFFF, #F6F6F6)}.ui-tabs-nav li.ui-tabs-active a{cursor:default;background-color:#f0f0f0;background-image:linear-gradient(180deg, #F0F0F0, #FDFDFD);box-shadow:0 1px 1px 0 rgba(0,0,0,.1) inset}.ui-tabs-nav li.ui-tabs-active a a:hover{background-color:#f0f0f0;background-image:linear-gradient(180deg, #F0F0F0, #FDFDFD)}.tab-content{border:1px solid #d3d3d3;padding:15px;padding-top:30px;text-align:left}body.logged_out{background:#e8e9ea}body.logged_out #content_wrapper{width:500px;margin:70px auto}body.logged_out #content_wrapper #active_admin_content{box-shadow:0 1px 2px rgba(0,0,0,.37);background:#fff;padding:13px 30px}body.logged_out h2{background-color:#efefef;background-image:linear-gradient(180deg, #efefef, #dfe1e2);text-shadow:#fff 0 1px 0;border:solid 1px #cdcdcd;border-color:#d4d4d4;border-top-color:#e6e6e6;border-right-color:#d4d4d4;border-bottom-color:#cdcdcd;border-left-color:#d4d4d4;box-shadow:0 1px 3px rgba(0,0,0,.12),0 0 1px #fff inset;font-size:1em;font-weight:bold;line-height:18px;margin-bottom:.5em;color:#5e6469;padding:5px 10px 3px 10px;background-color:hsl(207.2727272727,5.527638191%,44.0196078431%);background-image:linear-gradient(180deg, rgb(106.0452261307, 112.8140703518, 118.4547738693), rgb(77.1366834171, 82.0603015075, 86.1633165829));border-bottom:1px solid rgb(67.5005025126,71.8090452261,75.3994974874);text-shadow:#000 0 1px 0;box-shadow:0 1px 3px rgba(0,0,0,.3);border:none;color:#fff;margin:-13px -30px 20px -30px}body.logged_out #login form fieldset{box-shadow:none;background:none;padding:0;margin-bottom:0}body.logged_out #login form fieldset li{padding:10px 0}body.logged_out #login form fieldset input[type=text],body.logged_out #login form fieldset input[type=email],body.logged_out #login form fieldset input[type=password]{width:70%}body.logged_out #login form fieldset.buttons{margin-left:20%}body.logged_out #login a{float:right;margin-top:-32px}#footer{padding:30px 30px;font-size:.8em;clear:both}#footer p{padding-top:10px}#index_footer{padding-top:5px;text-align:right;font-size:.85em}#index_footer:after{visibility:hidden;display:block;content:"";clear:both;height:0}.index_content{clear:both}#wrapper{width:100%}.index #wrapper{display:table}#active_admin_content{margin:0;padding:30px}#active_admin_content #main_content_wrapper{float:left;width:100%}#active_admin_content #main_content_wrapper #main_content{margin-right:300px}#active_admin_content.without_sidebar #main_content_wrapper #main_content{margin-right:0}#active_admin_content #sidebar{float:left;width:270px;margin-left:-270px}#title_bar{background-color:#efefef;background-image:linear-gradient(180deg, #efefef, #dfe1e2);text-shadow:#fff 0 1px 0;border:solid 1px #cdcdcd;border-color:#d4d4d4;border-top-color:#e6e6e6;border-right-color:#d4d4d4;border-bottom-color:#cdcdcd;border-left-color:#d4d4d4;box-shadow:0 1px 3px rgba(0,0,0,.12),0 0 1px #fff inset;font-size:1em;font-weight:bold;line-height:18px;margin-bottom:.5em;color:#5e6469;padding:5px 10px 3px 10px;box-shadow:0 1px 2px rgba(0,0,0,.37);display:table;border-bottom-color:#eee;width:100%;position:relative;margin:0;padding:10px 30px;z-index:800}#title_bar:after{visibility:hidden;display:block;content:"";clear:both;height:0}#title_bar #titlebar_left,#title_bar #titlebar_right{height:50px;vertical-align:middle;display:table-cell}#title_bar #titlebar_right{text-align:right}#title_bar h2{margin:0;padding:0;font-size:2.6em;line-height:100%;font-weight:bold}#title_bar .action_items span.action_item>a,#title_bar .action_items span.action_item>.dropdown_menu>a{background-color:#fff;background-image:linear-gradient(180deg, #FFFFFF, #E7E7E7);box-shadow:0 1px 1px rgba(0,0,0,.1),0 1px 0 0 hsla(0,0%,100%,.8) inset;border:solid 1px #c7c7c7;border-color:#c7c7c7;border-top-color:#d3d3d3;border-right-color:#c7c7c7;border-bottom-color:#c2c2c2;border-left-color:#c7c7c7;text-shadow:#fff 0 1px 0;color:#5e6469;border-radius:200px;display:inline-block;font-weight:bold;font-size:1em;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;line-height:12px;margin-right:3px;padding:7px 16px 6px;text-decoration:none}#title_bar .action_items span.action_item>a.disabled,#title_bar .action_items span.action_item>.dropdown_menu>a.disabled{opacity:.5;cursor:default}#title_bar .action_items span.action_item>a:not(.disabled):hover,#title_bar .action_items span.action_item>.dropdown_menu>a:not(.disabled):hover{background-color:#fff;background-image:linear-gradient(180deg, #FFFFFF, #F1F1F1)}#title_bar .action_items span.action_item>a:not(.disabled):active,#title_bar .action_items span.action_item>.dropdown_menu>a:not(.disabled):active{box-shadow:0 1px 2px rgba(0,0,0,.22) inset,0 1px 0 0px #eee;border-color:#b9b9b9;border-top-color:#c2c2c2;border-right-color:#b9b9b9;border-bottom-color:#b7b7b7;border-left-color:#b9b9b9;background-color:#f3f3f3;background-image:linear-gradient(180deg, #F3F3F3, #D8D8D8)}#title_bar .action_items span.action_item>a,#title_bar .action_items span.action_item>.dropdown_menu>a{padding:12px 17px 10px;margin:0px}html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit}body{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;line-height:1.5;font-size:72%;background:#fff;color:#323537}}@media print{/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:rgba(0,0,0,0)}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-0.25em}sup{top:-0.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button}button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{border-style:none;padding:0}button:-moz-focusring,[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}template{display:none}[hidden]{display:none}html{font-size:100.01%}body{font-size:75%;font-family:"Helvetica Neue",Arial,Helvetica,sans-serif}h1,h2,h3,h4,h5,h6{font-weight:normal;color:#000}h1 img,h2 img,h3 img,h4 img,h5 img,h6 img{margin:0}h1{font-size:3em;line-height:1;margin-bottom:.5em}h2{font-size:2em;margin-bottom:.75em}h3{font-size:1.5em;line-height:1;margin-bottom:1em}h4{font-size:1.2em;line-height:1.25;margin-bottom:1.25em}h5{font-size:1em;font-weight:bold;margin-bottom:1.5em}h6{font-size:1em;font-weight:bold}p{margin:0 0 1.5em}p .left{margin:1.5em 1.5em 1.5em 0;padding:0}p .right{margin:1.5em 0 1.5em 1.5em;padding:0}.left{float:left !important}.right{float:right !important}blockquote{margin:1.5em;color:#666;font-style:italic}strong,dfn{font-weight:bold}em,dfn{font-style:italic}sup,sub{line-height:0}abbr,acronym{border-bottom:1px dotted #666}address{margin:0 0 1.5em;font-style:italic}del{color:#666}pre{margin:1.5em 0;white-space:pre}pre,code,tt{font:1em "andale mono","lucida console",monospace;line-height:1.5}li ul,li ol{margin:0}ul,ol{margin:0 1.5em 1.5em 0;padding-left:1.5em}ul{list-style-type:disc}ol{list-style-type:decimal}dl{margin:0 0 1.5em 0}dl dt{font-weight:bold}dd{margin-left:1.5em}table{margin-bottom:1.4em;width:100%}th{font-weight:bold}thead th{background:#c3d9ff}th,td,caption{padding:4px 10px 4px 5px}.small{font-size:.8em;margin-bottom:1.875em;line-height:1.875em}.large{font-size:1.2em;line-height:2.5em;margin-bottom:1.25em}.hide{display:none}.quiet{color:#666}.loud{color:#000}.highlight{background:#ff0}.added{background:#060;color:#fff}.removed{background:#900;color:#fff}.first{margin-left:0;padding-left:0}.last{margin-right:0;padding-right:0}.top{margin-top:0;padding-top:0}.bottom{margin-bottom:0;padding-bottom:0}body{font-family:Helvetica,Arial,sans-serif;line-height:150%;font-size:72%;background:#fff;width:99%;margin:0;padding:.5%;color:#000}a{color:#000;text-decoration:none}h3{font-weight:bold;margin-bottom:.5em}#header{float:left}#header #tabs,#header .tabs,#header #utility_nav{display:none}#header h1{font-weight:bold}.flashes{display:none}#title_bar{float:right}#title_bar h2{line-height:2em;margin:0}#title_bar .breadcrumb,#title_bar #titlebar_right{display:none}#active_admin_content{border-top:thick solid #000;clear:both;margin-top:2em;padding-top:3em}#footer{display:none}.table_tools ul{padding:0;margin:0;list-style-type:none}.table_tools ul li{display:none;padding:0;margin-bottom:1em}.table_tools ul li.scope.selected,.table_tools ul li.index.selected{display:block}.table_tools ul li.scope.selected:before,.table_tools ul li.index.selected:before{content:"Showing "}.table_tools ul li.scope.selected a,.table_tools ul li.index.selected a{font-weight:bold}.table_tools ul li.scope.selected span,.table_tools ul li.index.selected span{display:inline-block;font-weight:normal;font-size:.9em}table{margin-bottom:1.5em;text-align:left;width:100%}table thead{display:table-header-group}table thead th{background:none;border-bottom:medium solid #000;font-weight:bold}table thead th a{text-decoration:none}table th,table td{padding:.5em 1em}table th .member_link,table td .member_link{display:none}table td{border-bottom:thin solid #000}table tr{page-break-inside:avoid}#index_footer,.pagination_information{display:none}.index_grid td{border:none;text-align:center;vertical-align:middle}.index_grid td img{max-width:1in}.panel{border-bottom:thick solid #ccc;margin-bottom:3em;padding-bottom:2em;page-break-inside:avoid}.panel:last-child{border-bottom:none}.comments form{display:none}.comments .active_admin_comment{border-top:thin solid #000;padding-top:1em}.comments .active_admin_comment .active_admin_comment_meta h4{font-size:1em;font-weight:bold;float:left;margin-right:.5em;margin-bottom:0}.comments .active_admin_comment .active_admin_comment_meta span{font-size:.9em;font-style:italic;vertical-align:top}.comments .active_admin_comment .active_admin_comment_body{clear:both;margin-bottom:1em}.attributes_table{border-top:medium solid #000}.attributes_table th{border-bottom:thin solid #000;vertical-align:top}.attributes_table th:after{content:":"}.attributes_table td img{max-height:4in;max-width:6in}#filters_sidebar_section{display:none}form fieldset{border-top:thick solid #ccc;padding-top:2em;margin-bottom:2em}form fieldset:last-child{border-bottom:none}form .buttons,form abbr{display:none}form ol{list-style-type:none;padding:0;margin:0}form ol li{border-top:thin solid #000;margin:0;padding:1em 0;overflow:hidden}form ol li.password,form ol li.hidden{display:none}form ol li label{font-weight:bold;float:left;width:20%}form ol li input,form ol li textarea,form ol li select{background:none;border:0;font:Arial,Helvetica,sans-serif}form ol li input[type=file]{display:none}.unsupported_browser{display:none}}.trix-content{background-color:#fff}.trix-content .attachment-gallery>action-text-attachment,.trix-content .attachment-gallery>.attachment{flex:1 0 33%;padding:0 .5em;max-width:33%}.trix-content .attachment-gallery.attachment-gallery--2>action-text-attachment,.trix-content .attachment-gallery.attachment-gallery--2>.attachment,.trix-content .attachment-gallery.attachment-gallery--4>action-text-attachment,.trix-content .attachment-gallery.attachment-gallery--4>.attachment{flex-basis:50%;max-width:50%}.trix-content action-text-attachment .attachment{padding:0 !important;max-width:100% !important}
diff --git a/app/assets/builds/active_admin.css.map b/app/assets/builds/active_admin.css.map
deleted file mode 100644
index c73b8c8..0000000
--- a/app/assets/builds/active_admin.css.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"sourceRoot":"","sources":["../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/_base.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/_normalize.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/_typography.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/mixins/_variables.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/_header.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/mixins/_gradients.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/mixins/_shadows.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/mixins/_rounded.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/_forms.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/mixins/_sections.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/mixins/_utilities.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/mixins/_typography.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/mixins/_buttons.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/components/_comments.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/components/_flash_messages.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/components/_date_picker.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/components/_tables.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/components/_batch_actions.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/components/_modal_dialog.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/components/_blank_slates.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/components/_breadcrumbs.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/components/_dropdown_menu.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/components/_buttons.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/components/_grid.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/components/_links.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/components/_pagination.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/components/_panels.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/components/_columns.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/components/_scopes.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/components/_status_tags.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/components/_table_tools.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/components/_index_list.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/components/_unsupported_browser.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/components/_tabs.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/pages/_logged_out.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/structure/_footer.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/structure/_main_structure.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/structure/_title_bar.scss","../../../../../.asdf/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activeadmin-3.3.0/app/assets/stylesheets/active_admin/_print.scss","../stylesheets/actiontext.scss"],"names":[],"mappings":"AACA,cCDA,4EAUA,KACE,iBACA,8BAUF,KACE,SAOF,KACE,cAQF,GACE,cACA,eAWF,GACE,uBACA,SACA,iBAQF,IACE,gCACA,cAUF,EACE,+BAQF,YACE,mBACA,0BACA,iCAOF,SAEE,mBAQF,cAGE,gCACA,cAOF,MACE,cAQF,QAEE,cACA,cACA,kBACA,wBAGF,IACE,eAGF,IACE,WAUF,IACE,kBAWF,sCAKE,oBACA,eACA,iBACA,SAQF,aAEE,iBAQF,cAEE,oBAOF,gDAIE,0BAOF,wHAIE,kBACA,UAOF,4GAIE,8BAOF,SACE,2BAUF,OACE,sBACA,cACA,cACA,eACA,UACA,mBAOF,SACE,wBAOF,SACE,cAQF,6BAEE,sBACA,UAOF,kFAEE,YAQF,cACE,6BACA,oBAOF,yCACE,wBAQF,6BACE,0BACA,aAUF,QACE,cAOF,QACE,kBAUF,SACE,aAOF,SACE,aCjUF,uBACA,2EAGA,kBACE,mBACA,MClBc,QDmBd,mDAGF,kDACA,qCACA,mDACA,yDACA,sDACA,kCAGA,EACE,iBAEA,6CACA,8CAGF,4BACA,8BAEA,qDACA,4BACA,yBACA,sBAEA,aACc,8BACd,2CACA,eAEA,mCACA,8EAGA,YACc,SACd,gDAEA,wBACA,2BAEA,sBACA,uBACA,qBAGA,qCACA,oBACA,4BACA,uCAGA,gEACA,8DACA,mBAEA,kBACA,iBACA,2BACA,kCACA,oCAEA,oCACA,qCACA,gCACA,yCElGA,QCGE,iBAKkB,gDAJlB,gJAKA,uECTA,qCAYA,yBFRA,cACA,YACA,WACA,iBACA,iBACA,cACA,YAEA,WACE,mBACA,sBACA,mBACA,MDYqB,QCXrB,kBACA,kBACA,wBACA,gBACA,mBACA,gBAEA,aACE,qBAEA,mBACE,WAIJ,eACE,kBACA,SAIJ,+BDVuB,QCYvB,qBACE,QACA,kBACA,YAGF,gBACE,mBACA,sBACA,YACA,SACA,UAIE,0CAGF,mBACE,qBACA,iBACA,eACA,kBACA,cACA,kBAEA,qBACE,qBACA,0BACA,kBGrEN,cHsEuB,KAGnB,6BACE,WDxDuB,gDCyDvB,WAGF,gCACE,wK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C,QFRzC,kBEQgC,QAChC,cLvBA,oBKCA,qBACA,iBACA,cDHA,wDCKA,iBACA,iBACA,qBACA,qBAEA,iDACE,WACA,eAeA,6DPxBF,iBOyBsB,gDPxBtB,gJO2BE,8DACE,2DP7BJ,iBO8BsB,gDP7BtB,6IasBE,mDbvBF,iBOoCkB,KPnClB,2DOoCA,uEACA,yBF/BA,aEgCgC,QF/BhC,iBE+BuB,QF9BvB,mBE8BgC,QF7BhC,oBE6ByC,QF5BzC,kBE4BgC,QN9BhC,yBMgCA,MT/Bc,QIbd,oBKCA,qBACA,iBACA,cDHA,wDCKA,iBACA,iBACA,qBACA,qBAEA,4DACE,WACA,eAoCA,wEP7CF,iBO8CsB,KP7CtB,2DOgDE,yEACE,4DF3CJ,aE4CoC,QF3CpC,iBE2C2B,QF1C3B,mBE0CoC,QFzCpC,oBEyC6C,QFxC7C,kBEwCoC,QPnDpC,iBOoDsB,QPnDtB,2Da2BA,sEChCF,uBACE,WACA,kBAEA,oCZHA,cADsB,IYMpB,OhBmCiB,mBgBlCjB,MhBiBwB,KgBhBxB,qBACA,gBACA,iBACA,kBACA,kBAEA,0CACE,cACA,eACA,mBAKN,qDACE,gBACA,mBAGF,kDACE,gBC5BF,YACE,cACA,eACA,mBACA,gBACA,mBACA,yBAEA,4EACE,MjBgBgB,QiBfhB,qBAGF,8CAEA,4BACE,aACA,MjBS0B,QkB1B9B,eACE,eAEA,qChBCA,iBOoCkB,KPnClB,2DOoCA,uEACA,yBF/BA,aEgCgC,QF/BhC,iBE+BuB,QF9BvB,mBE8BgC,QF7BhC,oBE6ByC,QF5BzC,kBE4BgC,QN9BhC,yBMgCA,MT/Bc,QIbd,oBKCA,qBACA,iBACA,cDHA,wDCKA,iBACA,iBACA,qBACA,qBAEA,8CACE,WACA,eAoCA,0DP7CF,iBO8CsB,KP7CtB,2DOgDE,2DACE,4DF3CJ,aE4CoC,QF3CpC,iBE2C2B,QF1C3B,mBE0CoC,QFzCpC,oBEyC6C,QFxC7C,kBEwCoC,QPnDpC,iBOoDsB,QPnDtB,2DgBCE,qCACE,kBACA,8BACA,eAGF,4CACE,YACA,kBACA,QACA,SACA,uBACA,mBACA,gCACA,WACA,QAGF,2CACE,YACA,kBACA,QACA,SACA,uBACA,mBACA,gCACA,WACA,QAIJ,qCAGE,WACA,kBACA,SACA,cACA,QACA,SACA,uBACA,mBACA,0EACA,YAIA,4CACE,YACA,kBACA,QACA,SACA,uBACA,mBACA,2EACA,UACA,QAKF,2CACE,YACA,kBACA,QACA,SACA,uBACA,mBACA,2EACA,UACA,QAIJ,2CACE,qBACA,kBACA,sBACA,YACA,0GACA,iBlBxEY,QEVd,iBgBmFoB,gDhBlFpB,gJgBmFE,gEACA,gEACA,kEACA,kBACA,aACA,aAEA,+DACE,cACA,sBACA,gEACA,2EACA,kBACA,SACA,gBACA,YAEA,qBACA,UAEA,kEACE,cACA,gCAEA,oEACE,cACA,uBACA,gBACA,iBACA,qBACA,qBACA,kBACA,mBAEA,0EhBtHR,iBAckB,QAblB,2DCQA,4BegHU,WAGF,2EhB5HR,iBAkBkB,QAjBlB,2DgB6HU,WAMF,gFACE,2BACA,4BAKJ,6EACE,YACA,+EACE,8BACA,+BCnJZ,cACE,iBACA,mBAGF,qFjBDE,iBOekB,gDPdlB,gJCQA,yBMQA,yEACA,yBFXA,aEYgC,QFXhC,iBEWuB,QFVvB,mBEUgC,QFThC,oBESyC,QFRzC,kBEQgC,QAChC,cLvBA,oBKCA,qBACA,iBACA,cDHA,wDCKA,iBACA,iBACA,qBACA,qBAEA,2IACE,WACA,eAeA,mNPxBF,iBOyBsB,gDPxBtB,gJO2BE,yNACE,2DP7BJ,iBO8BsB,gDP7BtB,6IkBJF,+EAGA,SACE,WACA,UACA,4BCPF,mBACE,MrBgBW,QqBfX,0BAEF,6BCJA,+BACE,WAGF,YACE,YACA,eACA,iBAEA,cpBLA,iBOoCkB,KPnClB,2DOoCA,uEACA,yBF/BA,aEgCgC,QF/BhC,iBE+BuB,QF9BvB,mBE8BgC,QF7BhC,oBE6ByC,QF5BzC,kBE4BgC,QN9BhC,yBMgCA,MT/Bc,QIbd,oBKCA,qBACA,iBACA,cDHA,wDCKA,iBACA,iBACA,qBACA,qBAEA,uBACE,WACA,eAoCA,mCP7CF,iBO8CsB,KP7CtB,2DOgDE,oCACE,4DF3CJ,aE4CoC,QF3CpC,iBE2C2B,QF1C3B,mBE0CoC,QFzCpC,oBEyC6C,QFxC7C,kBEwCoC,QPnDpC,iBOoDsB,QPnDtB,2DoBQA,8BpBTA,iBOekB,gDPdlB,gJCQA,yBMQA,yEACA,yBFXA,aEYgC,QFXhC,iBEWuB,QFVvB,mBEUgC,QFThC,oBESyC,QFRzC,kBEQgC,QAChC,cLvBA,oBKCA,qBACA,iBACA,cDHA,wDCKA,iBACA,iBACA,qBACA,qBAEA,uCACE,WACA,eAeA,mDPxBF,iBOyBsB,gDPxBtB,gJO2BE,oDACE,2DP7BJ,iBO8BsB,gDP7BtB,6IoBYA,4ClBhBA,ckBiBmB,IACjB,iBACA,gBAIJ,wBACE,YACA,kBACA,cACA,wCAGF,gBACE,WAGF,qBACE,YACA,gBACA,4BpBlCA,iBOoCkB,KPnClB,2DOoCA,uEACA,yBF/BA,aEgCgC,QF/BhC,iBE+BuB,QF9BvB,mBE8BgC,QF7BhC,oBE6ByC,QF5BzC,kBE4BgC,QN9BhC,yBMgCA,MT/Bc,QIbd,oBKCA,qBACA,iBACA,cDHA,wDCKA,iBACA,iBACA,qBACA,qBAEA,qCACE,WACA,eAoCA,iDP7CF,iBO8CsB,KP7CtB,2DOgDE,kDACE,4DF3CJ,aE4CoC,QF3CpC,iBE2C2B,QF1C3B,mBE0CoC,QFzCpC,oBEyC6C,QFxC7C,kBEwCoC,QPnDpC,iBOoDsB,QPnDtB,2DoBoCE,4BlBxCF,ckByCqB,IACjB,gBAMJ,sBACE,WACA,mBAEF,kCACE,WACA,WCtDJ,gBjBgBE,mBFhBA,cEiBiB,IHTjB,gCGeA,mBAEA,sBJtBA,iBAJyB,QAKzB,2DCQA,yBGVA,yBCQA,aDPgC,QCQhC,iBDRuB,QCSvB,mBDTgC,QCUhC,oBDVyC,QCWzC,kBDXgC,QAChC,wDAEA,cACA,iBACA,iBACA,mBACA,MNGc,QMDd,0BAgBE,oDACE,YAIJ,mDAEA,sBACE,YACA,gCiBjCJ,iBjBYE,mBFhBA,cEiBiB,IHTjB,gCGeA,mBAEA,oBJtBA,iBAJyB,QAKzB,2DCQA,yBGVA,yBCQA,aDPgC,QCQhC,iBDRuB,QCSvB,mBDTgC,QCUhC,oBDVyC,QCWzC,kBDXgC,QAChC,wDAEA,cACA,iBACA,iBACA,mBACA,MNGc,QMDd,0BAgBE,mCACE,YAIJ,gDAEA,oBACE,YACA,gCkBtCJ,SACE,mBCCE,kBACE,cACA,mBACA,eACA,iBAEF,yBACE,iBCTN,YACE,oCACA,WACA,yBACA,qBACA,wBACA,eAEA,mCACA,+BCTF,aACE,sCpBCE,kBACA,cACA,WACA,WACA,SoBDJ,4BACE,WAGF,wDzBLE,iBOoCkB,KPnClB,2DOoCA,uEACA,yBF/BA,aEgCgC,QF/BhC,iBE+BuB,QF9BvB,mBE8BgC,QF7BhC,oBE6ByC,QF5BzC,kBE4BgC,QN9BhC,yBMgCA,MT/Bc,QIbd,oBKCA,qBACA,iBACA,cDHA,wDCKA,iBACA,iBACA,qBACA,qBAEA,0EACE,WACA,eAoCA,kGP7CF,iBO8CsB,KP7CtB,2DOgDE,oGACE,4DF3CJ,aE4CoC,QF3CpC,iBE2C2B,QF1C3B,mBE0CoC,QFzCpC,oBEyC6C,QFxC7C,kBEwCoC,QPnDpC,iBOoDsB,QPnDtB,2DyBOA,wDzBRA,iByBSoB,KzBRpB,2DKMA,aoBGkC,QpBFlC,iBoBEyB,QpBDzB,mBoBCkC,oCpBClC,kBoBDkC,QAChC,eACA,qBACA,SAIA,kGzBjBF,iByBkBsB,KzBjBtB,2DyBoBE,oGpBdF,aoBeoC,QpBdpC,iBoBc2B,QpBb3B,mBoBaoC,QpBZpC,oBoBY6C,QpBX7C,kBoBWoC,QAChC,6CzBvBJ,iByBwBsB,KzBvBtB,2DyB4BF,+BACE,qBACA,UACA,SAEA,kCACE,WAEA,oCACE,+BACA,gBAGF,gDACE,sBACA,4BACA,+BAGF,+CACE,uBACA,6BACA,gCAGF,6CzBtDF,iByBuDsB,QzBtDtB,2DyBuDI,4CACA,eAEA,mDzB3DJ,iByB4DwB,QzB3DxB,2D0BLF,SACE,YAGE,mBACE,cACA,mBACA,eACA,iBCRN,qBACE,kBACA,cACA,yB3BCA,yBACA,2F2BEA,wBACE,eACA,iBAGF,uBACE,mBCbJ,aACE,gBACA,cACA,WACA,oBACA,eACA,cACA,iBAEA,gBACE,cACA,kBACA,SACA,UACA,WAEA,8BACE,sBACA,4BACA,+BAGF,6BACE,uBACA,6BACA,gCAGF,kB5BxBF,iBOoCkB,KPnClB,2DOoCA,uEACA,yBF/BA,aEgCgC,QF/BhC,iBE+BuB,QF9BvB,mBE8BgC,QF7BhC,oBE6ByC,QF5BzC,kBE4BgC,QN9BhC,yBMgCA,MT/Bc,QIbd,oBKCA,qBACA,iBACA,cDHA,wDCKA,iBACA,iBACA,qBACA,qBAEA,2BACE,WACA,eAoCA,uCP7CF,iBO8CsB,KP7CtB,2DOgDE,wCACE,4DF3CJ,aE4CoC,QF3CpC,iBE2C2B,QF1C3B,mBE0CoC,QFzCpC,oBEyC6C,QFxC7C,kBEwCoC,QPnDpC,iBOoDsB,QPnDtB,2D4B0BI,kB5B3BJ,iB4B4BwB,K5B3BxB,2DKMA,auBsBsC,QvBrBtC,iBuBqB6B,QvBpB7B,mBuBoBsC,QvBnBtC,oBuBmB+C,QvBlB/C,kBuBkBsC,QAChC,qBACA,gBACA,+BACA,eACA,qBAIA,uC5BtCN,iB4BuC0B,K5BtC1B,2D4B4CI,iCACE,e5B9CN,iB4B+CwB,Q5B9CxB,2D4B+CM,4CAEA,yC5BlDN,iB4BmD0B,Q5BlD1B,2D4ByDF,aACE,yBACA,aACA,iBACA,gBClEF,gBACE,mBAEA,iCACE,YACA,iBACA,uD5BLF,qC4BOI,gBACA,kBAIJ,mB7BTA,iBAJyB,QAKzB,2DCQA,yBGVA,yBCQA,aDPgC,QCQhC,iBDRuB,QCSvB,mBDTgC,QCUhC,oBDVyC,QCWzC,kBDXgC,QAChC,wDAEA,cACA,iBACA,iBACA,mBACA,MNGc,QMDd,0BJTA,iBAKkB,gDAJlB,gJAKA,uECGA,yB4BIE,oCACA,YACA,WACA,8BAME,qC5BrBJ,gB4BuBM,gBACA,UACA,gBACA,uDAEA,uKACE,UAEF,6DAIJ,sDCxCJ,QACE,kBACA,eACA,WAEA,UACE,iBAKJ,cACE,gBACA,iBACA,gBzBbA,oBACE,kBACA,cACA,WACA,WACA,SyBYJ,0BClBA,SACE,WAGF,gBACE,cAGF,sBACE,SACA,QjCuBuB,KiCrBvB,4CACE,WACA,WAEA,0DACE,mBAIJ,yFAEA,+BACE,WACA,MjCSY,MiCRZ,mBC1BJ,WhCIE,iBAJyB,QAKzB,2DCQA,yBGVA,yBCQA,aDPgC,QCQhC,iBDRuB,QCSvB,mBDTgC,QCUhC,oBDVyC,QCWzC,kBDXgC,QAChC,wDAEA,cACA,iBACA,iBACA,mBACA,MNGc,QMDd,0B4BXA,qCACA,cACA,yBACA,WACA,kBACA,SACA,kBACA,Y3BRA,iBACE,kBACA,cACA,WACA,WACA,S2BMF,qDACE,YACA,sBACA,mBAGF,2BACE,iBAGF,cACE,SACA,UACA,gBACA,iBACA,iBAKE,uGhC5BJ,iBOoCkB,KPnClB,2DOoCA,uEACA,yBF/BA,aEgCgC,QF/BhC,iBE+BuB,QF9BvB,mBE8BgC,QF7BhC,oBE6ByC,QF5BzC,kBE4BgC,QN9BhC,yBMgCA,MT/Bc,QIbd,oBKCA,qBACA,iBACA,cDHA,wDCKA,iBACA,iBACA,qBACA,qBAEA,yHACE,WACA,eAoCA,iJP7CF,iBO8CsB,KP7CtB,2DOgDE,mJACE,4DF3CJ,aE4CoC,QF3CpC,iBE2C2B,QF1C3B,mBE0CoC,QFzCpC,oBEyC6C,QFxC7C,kBEwCoC,QPnDpC,iBOoDsB,QPnDtB,2DgC8BM,uGACE,uBACA,WrCFR,KACE,sBAGF,mBAGE,mBAGF,KW5CA,wDX8CE,gBACA,cACA,WGpCoB,KHqCpB,MGlCS,SHsCb,aCtDA,4EAUA,KACE,iBACA,8BAUF,KACE,SAOF,KACE,cAQF,GACE,cACA,eAWF,GACE,uBACA,SACA,iBAQF,IACE,gCACA,cAUF,EACE,+BAQF,YACE,mBACA,0BACA,iCAOF,SAEE,mBAQF,cAGE,gCACA,cAOF,MACE,cAQF,QAEE,cACA,cACA,kBACA,wBAGF,IACE,eAGF,IACE,WAUF,IACE,kBAWF,sCAKE,oBACA,eACA,iBACA,SAQF,aAEE,iBAQF,cAEE,oBAOF,gDAIE,0BAOF,wHAIE,kBACA,UAOF,4GAIE,8BAOF,SACE,2BAUF,OACE,sBACA,cACA,cACA,eACA,UACA,mBAOF,SACE,wBAOF,SACE,cAQF,6BAEE,sBACA,UAOF,kFAEE,YAQF,cACE,6BACA,oBAOF,yCACE,wBAQF,6BACE,0BACA,aAUF,QACE,cAOF,QACE,kBAUF,SACE,aAOF,SACE,aCjUF,uBACA,2EAGA,kBACE,mBACA,MoC7Bc,KpC8Bd,mDAGF,kDACA,qCACA,mDACA,yDACA,sDACA,kCAGA,EACE,iBAEA,6CACA,8CAGF,4BACA,8BAEA,qDACA,4BACA,yBACA,sBAEA,aACc,8BACd,2CACA,eAEA,mCACA,8EAGA,YACc,SACd,gDAEA,wBACA,2BAEA,sBACA,uBACA,qBAGA,qCACA,oBACA,4BACA,uCAGA,gEACA,8DACA,mBAEA,kBACA,iBACA,2BACA,kCACA,oCAEA,oCACA,qCACA,gCACA,yCoCvFA,KACE,uCACA,iBACA,cACA,gBACA,UACA,SACA,YACA,MAhBW,KAmBb,EACE,MApBW,KAqBX,qBAGF,GACE,iBACA,mBAIF,QACE,WAEA,iDACE,aAGF,WACE,iBAIJ,SACE,aAGF,WACE,YAEA,cACE,gBACA,SAGF,kDACE,aAKJ,sBACE,4BACA,WACA,eACA,gBAIF,QACE,aAKA,gBACE,UACA,SACA,qBAEA,mBACE,aACA,UACA,kBAEA,oEACE,cAEA,kFACE,mBAGF,wEACE,iBAGF,8EACE,qBACA,mBACA,eAQV,MACE,oBACA,gBACA,WAEA,YACE,2BAEA,eACE,gBACA,gCACA,iBAEA,iBACE,qBAKN,kBACE,iBAEA,4CACE,aAIJ,SACE,8BAGF,SACE,wBAKJ,sCACE,aAIA,eACE,YACA,kBACA,sBAEA,mBACE,cAMN,OACE,+BACA,kBACA,mBACA,wBAEA,kBACE,mBAKF,eACE,aAGF,gCACE,2BACA,gBAGE,8DACE,cACA,iBACA,WACA,kBACA,gBAGF,gEACE,eACA,kBACA,mBAIJ,2DACE,WACA,kBAON,kBACE,6BAEA,qBACE,8BACA,mBAEA,2BACE,YAKF,yBACE,eACA,cAMN,yBACE,aAKA,cACE,4BACA,gBACA,kBAEA,yBACE,mBAIJ,wBACE,aAEF,QACE,qBACA,UACA,SAEA,WACE,2BACA,SACA,cACA,gBAEA,sCACE,aAGF,iBACE,iBACA,WACA,UAGF,uDACE,gBACA,SACA,gCAGF,4BACE,aAOR,qBACE,cCnRF,cACE,sBAEE,uGAEE,aACA,eACA,cAKA,sSAEE,eACA,cAMJ,iDACE,qBACA","file":"active_admin.css"}
\ No newline at end of file
diff --git a/app/assets/builds/admin.css b/app/assets/builds/admin.css
new file mode 100644
index 0000000..881e6dc
--- /dev/null
+++ b/app/assets/builds/admin.css
@@ -0,0 +1 @@
+.admin-body{min-height:100vh}.admin-table th a{text-decoration:none;color:inherit}.admin-table tr.paid_full{background-color:#d1e7dd}.admin-table tr.outstanding{background-color:#fff3cd}.admin-scope-nav .nav-link.active{font-weight:600}.pagination{margin-top:1rem}
diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js
index 3a5084e..997c991 100644
--- a/app/assets/config/manifest.js
+++ b/app/assets/config/manifest.js
@@ -1,6 +1,6 @@
//= link_tree ../images
//= link application.css
-//= link active_admin.css
+//= link admin.css
//= link actiontext.css
//= link application.js
//= link_tree ../../javascript/controllers .js
diff --git a/app/assets/stylesheets/active_admin.scss b/app/assets/stylesheets/active_admin.scss
deleted file mode 100644
index c6124fa..0000000
--- a/app/assets/stylesheets/active_admin.scss
+++ /dev/null
@@ -1,6 +0,0 @@
-// Active Admin's got SASS!
-@import "active_admin/mixins";
-@import "active_admin/base";
-@import "actiontext";
-
-// Your custom Active Admin styles go here
diff --git a/app/assets/stylesheets/admin.scss b/app/assets/stylesheets/admin.scss
new file mode 100644
index 0000000..b79651a
--- /dev/null
+++ b/app/assets/stylesheets/admin.scss
@@ -0,0 +1,25 @@
+// Admin shell styles (Bootstrap provides most layout)
+.admin-body {
+ min-height: 100vh;
+}
+
+.admin-table th a {
+ text-decoration: none;
+ color: inherit;
+}
+
+.admin-table tr.paid_full {
+ background-color: #d1e7dd;
+}
+
+.admin-table tr.outstanding {
+ background-color: #fff3cd;
+}
+
+.admin-scope-nav .nav-link.active {
+ font-weight: 600;
+}
+
+.pagination {
+ margin-top: 1rem;
+}
diff --git a/app/controllers/admin/admin_users_controller.rb b/app/controllers/admin/admin_users_controller.rb
new file mode 100644
index 0000000..fff63f7
--- /dev/null
+++ b/app/controllers/admin/admin_users_controller.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module Admin
+ class AdminUsersController < BaseController
+ before_action :set_admin_user, only: %i[show edit update destroy]
+
+ def index
+ scope = AdminUser.order(:email)
+ scope = scope.where('email ILIKE ?', "%#{params[:email]}%") if params[:email].present?
+ @pagy, @admin_users = pagy(scope)
+ end
+
+ def show; end
+
+ def new
+ @admin_user = AdminUser.new
+ end
+
+ def create
+ @admin_user = AdminUser.new(admin_user_params)
+ if @admin_user.save
+ redirect_to admin_admin_user_path(@admin_user), notice: 'Admin user was successfully created.'
+ else
+ render :new, status: :unprocessable_content
+ end
+ end
+
+ def edit; end
+
+ def update
+ attrs = admin_user_params
+ attrs = attrs.except(:password, :password_confirmation) if attrs[:password].blank?
+
+ if @admin_user.update(attrs)
+ redirect_to admin_admin_user_path(@admin_user), notice: 'Admin user was successfully updated.'
+ else
+ render :edit, status: :unprocessable_content
+ end
+ end
+
+ def destroy
+ @admin_user.destroy!
+ redirect_to admin_admin_users_path, notice: 'Admin user was successfully destroyed.'
+ end
+
+ private
+
+ def set_admin_user
+ @admin_user = AdminUser.find(params[:id])
+ end
+
+ def admin_user_params
+ params.require(:admin_user).permit(:email, :password, :password_confirmation)
+ end
+ end
+end
diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb
new file mode 100644
index 0000000..93b886e
--- /dev/null
+++ b/app/controllers/admin/base_controller.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Admin
+ class BaseController < ApplicationController
+ include Pagy::Backend
+
+ layout 'admin'
+
+ before_action :authenticate_admin_user!
+
+ helper_method :current_admin_user
+ end
+end
diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb
new file mode 100644
index 0000000..8e97a13
--- /dev/null
+++ b/app/controllers/admin/dashboard_controller.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Admin
+ class DashboardController < BaseController
+ def show
+ result = Admin::DashboardPaymentsQuery.new(params: params).call
+ @active_program = result.active_program
+ @sort_column = result.sort_column
+ @sort_order = result.sort_order
+ @paid_in_full_count = result.paid_in_full_count
+ @outstanding_count = result.outstanding_count
+ @recent_payments = result.recent_payments
+ @dashboard_message = StaticPage.find_by(location: 'dashboard')&.message
+
+ if @active_program && result.user_rows.any?
+ @pagy, @user_rows = pagy_array(result.user_rows, items: 20)
+ else
+ @pagy = nil
+ @user_rows = result.user_rows
+ end
+ end
+ end
+end
diff --git a/app/controllers/admin/payments_controller.rb b/app/controllers/admin/payments_controller.rb
new file mode 100644
index 0000000..36df74c
--- /dev/null
+++ b/app/controllers/admin/payments_controller.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+module Admin
+ class PaymentsController < BaseController
+ before_action :set_payment, only: %i[show edit update]
+
+ def index
+ scope = Payment.includes(:user).order(created_at: :desc)
+ scope = apply_filters(scope)
+ @pagy, @payments = pagy(scope)
+ @users = User.order(:email)
+ @program_years = Payment.distinct.order(:program_year).pluck(:program_year)
+ @account_types = Payment.distinct.order(:account_type).pluck(:account_type).compact
+ end
+
+ def show; end
+
+ def new
+ @payment = Payment.new(
+ transaction_type: '1',
+ transaction_status: '1',
+ transaction_date: Time.current.strftime('%Y%m%d%H%M'),
+ program_year: current_program&.program_year || Date.current.year
+ )
+ end
+
+ def create
+ @payment = Payment.new(payment_params)
+ if @payment.save
+ redirect_to admin_payment_path(@payment), notice: 'Payment was successfully created.'
+ else
+ render :new, status: :unprocessable_content
+ end
+ end
+
+ def edit; end
+
+ def update
+ if @payment.update(payment_params)
+ redirect_to admin_payment_path(@payment), notice: 'Payment was successfully updated.'
+ else
+ render :edit, status: :unprocessable_content
+ end
+ end
+
+ private
+
+ def set_payment
+ @payment = Payment.includes(:user).find(params[:id])
+ end
+
+ def apply_filters(scope)
+ scope = scope.where(user_id: params[:user_id]) if params[:user_id].present?
+ scope = scope.where(program_year: params[:program_year]) if params[:program_year].present?
+ scope = scope.where(account_type: params[:account_type]) if params[:account_type].present?
+ if params[:created_at_from].present?
+ scope = scope.where(payments: { created_at: Time.zone.parse(params[:created_at_from]).beginning_of_day.. })
+ end
+ if params[:created_at_to].present?
+ scope = scope.where(payments: { created_at: ..Time.zone.parse(params[:created_at_to]).end_of_day })
+ end
+ scope
+ end
+
+ def payment_params
+ params.require(:payment).permit(
+ :transaction_type, :transaction_status, :transaction_id, :total_amount, :transaction_date,
+ :account_type, :result_code, :result_message, :user_account, :payer_identity, :timestamp,
+ :transaction_hash, :user_id, :program_year
+ )
+ end
+ end
+end
diff --git a/app/controllers/admin/program_settings_controller.rb b/app/controllers/admin/program_settings_controller.rb
new file mode 100644
index 0000000..3f91fd3
--- /dev/null
+++ b/app/controllers/admin/program_settings_controller.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module Admin
+ class ProgramSettingsController < BaseController
+ before_action :set_program_setting, only: %i[show edit update destroy]
+
+ def index
+ @pagy, @program_settings = pagy(ProgramSetting.order(program_year: :desc))
+ end
+
+ def show; end
+
+ def new
+ @program_setting = ProgramSetting.new
+ end
+
+ def create
+ @program_setting = ProgramSetting.new(program_setting_params)
+ if @program_setting.save
+ redirect_to admin_program_setting_path(@program_setting), notice: 'Program setting was successfully created.'
+ else
+ render :new, status: :unprocessable_content
+ end
+ end
+
+ def edit; end
+
+ def update
+ if @program_setting.update(program_setting_params)
+ redirect_to admin_program_setting_path(@program_setting), notice: 'Program setting was successfully updated.'
+ else
+ render :edit, status: :unprocessable_content
+ end
+ end
+
+ def destroy
+ @program_setting.destroy!
+ redirect_to admin_program_settings_path, notice: 'Program setting was successfully destroyed.'
+ end
+
+ private
+
+ def set_program_setting
+ @program_setting = ProgramSetting.find(params[:id])
+ end
+
+ def program_setting_params
+ params.require(:program_setting).permit(
+ :program_year, :application_fee, :program_fee, :active, :program_open, :program_close,
+ :open_instructions, :close_instructions, :payment_instructions, :allow_payments
+ )
+ end
+ end
+end
diff --git a/app/controllers/admin/static_pages_controller.rb b/app/controllers/admin/static_pages_controller.rb
new file mode 100644
index 0000000..05d632b
--- /dev/null
+++ b/app/controllers/admin/static_pages_controller.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Admin
+ class StaticPagesController < BaseController
+ before_action :set_static_page, only: %i[edit update]
+
+ def index
+ @pagy, @static_pages = pagy(StaticPage.order(:location))
+ end
+
+ def edit; end
+
+ def update
+ if @static_page.update(static_page_params)
+ redirect_to admin_static_pages_path, notice: 'Static page was successfully updated.'
+ else
+ render :edit, status: :unprocessable_content
+ end
+ end
+
+ private
+
+ def set_static_page
+ @static_page = StaticPage.find(params[:id])
+ end
+
+ def static_page_params
+ params.require(:static_page).permit(:location, :message)
+ end
+ end
+end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
new file mode 100644
index 0000000..e7d4e26
--- /dev/null
+++ b/app/controllers/admin/users_controller.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Admin
+ class UsersController < BaseController
+ def index
+ scope = User.order(:email)
+ scope = scope.where(id: Payment.users_with_zero_balance.select(:id)) if params[:scope] == 'zero_balance'
+ scope = scope.where(email: params[:email]) if params[:email].present?
+ @pagy, @users = pagy(scope)
+ @emails = User.order(:email).pluck(:email)
+ end
+
+ def show
+ @user = User.find(params[:id])
+ @pagy, @payments = pagy(@user.payments.order(created_at: :desc))
+ end
+ end
+end
diff --git a/app/controllers/admin_users/passwords_controller.rb b/app/controllers/admin_users/passwords_controller.rb
new file mode 100644
index 0000000..f2629d5
--- /dev/null
+++ b/app/controllers/admin_users/passwords_controller.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module AdminUsers
+ class PasswordsController < Devise::PasswordsController
+ layout 'admin'
+ end
+end
diff --git a/app/controllers/admin_users/sessions_controller.rb b/app/controllers/admin_users/sessions_controller.rb
new file mode 100644
index 0000000..4ba469f
--- /dev/null
+++ b/app/controllers/admin_users/sessions_controller.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module AdminUsers
+ class SessionsController < Devise::SessionsController
+ layout 'admin'
+
+ def after_sign_in_path_for(_resource)
+ admin_root_path
+ end
+
+ def after_sign_out_path_for(_resource_or_scope)
+ new_admin_user_session_path
+ end
+ end
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 2672847..34266cc 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -1,4 +1,6 @@
module ApplicationHelper
+ include Pagy::Frontend
+
# Returns the full title on a per-page basis.
def full_title(page_title = '')
base_title = 'NELP Payments'
@@ -16,4 +18,18 @@ def sentry_trace_propagation_meta
rescue Encoding::CompatibilityError, ArgumentError
''.html_safe
end
+
+ def admin_sort_link(label, column, current_column:, current_order:, **options)
+ next_order = current_column == column && current_order == 'asc' ? 'desc' : 'asc'
+ indicator = if current_column == column
+ current_order == 'asc' ? ' โฒ' : ' โผ'
+ else
+ ' โ
'
+ end
+
+ link_to "#{label}#{indicator}",
+ admin_root_path(sort_column: column, sort_order: next_order, page: options[:page]),
+ title: 'Click to sort',
+ class: options[:class]
+ end
end
diff --git a/app/javascript/active_admin.js b/app/javascript/active_admin.js
deleted file mode 100644
index c16caf6..0000000
--- a/app/javascript/active_admin.js
+++ /dev/null
@@ -1 +0,0 @@
-import "@activeadmin/activeadmin";
diff --git a/app/javascript/application.js b/app/javascript/application.js
index 0152cdf..b9e10e1 100644
--- a/app/javascript/application.js
+++ b/app/javascript/application.js
@@ -1,6 +1,9 @@
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import { Application } from "@hotwired/stimulus"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
+import "@hotwired/turbo-rails"
+import "trix"
+import "@rails/actiontext"
// Start Stimulus
window.Stimulus = Application.start()
diff --git a/app/models/admin_user.rb b/app/models/admin_user.rb
index 270d67e..948ec93 100644
--- a/app/models/admin_user.rb
+++ b/app/models/admin_user.rb
@@ -14,14 +14,6 @@
class AdminUser < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
- devise :database_authenticatable,
- :recoverable, :rememberable, :validatable
- # Include default devise modules. Others available are:
- # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
- devise :database_authenticatable,
- :recoverable, :rememberable, :validatable
- # Include default devise modules. Others available are:
- # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable,
:recoverable, :rememberable, :validatable
diff --git a/app/queries/admin/dashboard_payments_query.rb b/app/queries/admin/dashboard_payments_query.rb
new file mode 100644
index 0000000..aa0f44a
--- /dev/null
+++ b/app/queries/admin/dashboard_payments_query.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+module Admin
+ class DashboardPaymentsQuery
+ Result = Struct.new(
+ :active_program,
+ :user_rows,
+ :recent_payments,
+ :paid_in_full_count,
+ :outstanding_count,
+ :sort_column,
+ :sort_order,
+ keyword_init: true
+ )
+
+ def initialize(params:)
+ @params = params
+ end
+
+ def call
+ active_program = ProgramSetting.active_program.last
+ return empty_result unless active_program
+
+ sort_column = %w[user total_paid balance_due].include?(@params[:sort_column]) ? @params[:sort_column] : 'total_paid'
+ sort_order = @params[:sort_order] == 'asc' ? 'asc' : 'desc'
+
+ user_data = build_user_data(active_program)
+ user_data = sort_user_data(user_data, sort_column, sort_order)
+
+ paid_in_full_count = user_data.count { |row| row[:balance_due].to_i.zero? }
+ outstanding_count = user_data.size - paid_in_full_count
+
+ recent_payments = Payment.current_program_payments(active_program.program_year)
+ .includes(:user)
+ .order(created_at: :desc)
+ .limit(20)
+
+ Result.new(
+ active_program: active_program,
+ user_rows: user_data,
+ recent_payments: recent_payments,
+ paid_in_full_count: paid_in_full_count,
+ outstanding_count: outstanding_count,
+ sort_column: sort_column,
+ sort_order: sort_order
+ )
+ end
+
+ private
+
+ def empty_result
+ Result.new(
+ active_program: nil,
+ user_rows: [],
+ recent_payments: [],
+ paid_in_full_count: 0,
+ outstanding_count: 0,
+ sort_column: 'total_paid',
+ sort_order: 'desc'
+ )
+ end
+
+ def build_user_data(active_program)
+ user_totals = Payment.current_program_payments(active_program.program_year)
+ .where(transaction_status: '1')
+ .joins(:user)
+ .group('users.id', 'users.email')
+ .sum('payments.total_amount::float / 100')
+
+ user_totals.map do |(user_id, user_email), total_paid|
+ user = User.find(user_id)
+ {
+ user_id: user_id,
+ user_email: user_email,
+ user: user,
+ total_paid: total_paid,
+ balance_due: Payment.current_balance_due_for_user(user, active_program.program_year)
+ }
+ end
+ end
+
+ def sort_user_data(user_data, sort_column, sort_order)
+ sorted = case sort_column
+ when 'user'
+ user_data.sort_by { |data| data[:user_email].to_s.downcase }
+ when 'balance_due'
+ user_data.sort_by { |data| data[:balance_due].to_f }
+ else
+ user_data.sort_by { |data| data[:total_paid].to_f }
+ end
+
+ sort_order == 'desc' ? sorted.reverse : sorted
+ end
+ end
+end
diff --git a/app/views/active_admin/shared/_header_utility_nav.html.erb b/app/views/active_admin/shared/_header_utility_nav.html.erb
deleted file mode 100644
index da6f28f..0000000
--- a/app/views/active_admin/shared/_header_utility_nav.html.erb
+++ /dev/null
@@ -1,12 +0,0 @@
-<% if current_active_admin_user? %>
-
- <%= current_active_admin_user.email %>
-
-
- <%= link_to "Logout", destroy_admin_user_session_path, method: :delete %>
-
-<% else %>
-
- <%= link_to "Login", new_admin_user_session_path %>
-
-<% end %>
diff --git a/app/views/admin/admin_users/_form.html.erb b/app/views/admin/admin_users/_form.html.erb
new file mode 100644
index 0000000..cd887fd
--- /dev/null
+++ b/app/views/admin/admin_users/_form.html.erb
@@ -0,0 +1,14 @@
+<%= simple_form_for [:admin, admin_user] do |f| %>
+ <%= f.error_notification %>
+
+
+ <%= f.input :email %>
+ <%= f.input :password, hint: (admin_user.persisted? ? 'Leave blank to keep the current password' : nil) %>
+ <%= f.input :password_confirmation, label: 'Password confirmation' %>
+
+
+
+ <%= f.button :submit, class: 'btn btn-primary' %>
+ <%= link_to 'Cancel', admin_admin_users_path, class: 'btn btn-link' %>
+
+<% end %>
diff --git a/app/views/admin/admin_users/edit.html.erb b/app/views/admin/admin_users/edit.html.erb
new file mode 100644
index 0000000..2babd31
--- /dev/null
+++ b/app/views/admin/admin_users/edit.html.erb
@@ -0,0 +1,2 @@
+<% content_for :page_title, 'Edit Admin User' %>
+<%= render 'form', admin_user: @admin_user %>
diff --git a/app/views/admin/admin_users/index.html.erb b/app/views/admin/admin_users/index.html.erb
new file mode 100644
index 0000000..88e56ae
--- /dev/null
+++ b/app/views/admin/admin_users/index.html.erb
@@ -0,0 +1,43 @@
+<% content_for :page_title, 'Admin Users' %>
+<% content_for :page_actions do %>
+ <%= link_to 'New Admin User', new_admin_admin_user_path, class: 'btn btn-primary btn-sm' %>
+<% end %>
+
+<%= form_with url: admin_admin_users_path, method: :get, local: true, class: 'row g-2 align-items-end mb-3' do %>
+
+ <%= label_tag :email, 'Email', class: 'form-label' %>
+ <%= text_field_tag :email, params[:email], class: 'form-control' %>
+
+
+ <%= submit_tag 'Filter', class: 'btn btn-secondary' %>
+
+<% end %>
+
+
+
+
+
+ ID
+ Email
+ Created At
+
+
+
+
+ <% @admin_users.each do |admin_user| %>
+
+ <%= admin_user.id %>
+ <%= admin_user.email %>
+ <%= admin_user.created_at %>
+
+ <%= link_to 'View', admin_admin_user_path(admin_user), class: 'me-2' %>
+ <%= link_to 'Edit', edit_admin_admin_user_path(admin_user), class: 'me-2' %>
+ <%= link_to 'Delete', admin_admin_user_path(admin_user), data: { turbo_method: :delete, turbo_confirm: 'Are you sure?' } %>
+
+
+ <% end %>
+
+
+
+
+<%= render 'admin/shared/pagination', pagy: @pagy %>
diff --git a/app/views/admin/admin_users/new.html.erb b/app/views/admin/admin_users/new.html.erb
new file mode 100644
index 0000000..d0ff2aa
--- /dev/null
+++ b/app/views/admin/admin_users/new.html.erb
@@ -0,0 +1,2 @@
+<% content_for :page_title, 'New Admin User' %>
+<%= render 'form', admin_user: @admin_user %>
diff --git a/app/views/admin/admin_users/show.html.erb b/app/views/admin/admin_users/show.html.erb
new file mode 100644
index 0000000..b3e5b43
--- /dev/null
+++ b/app/views/admin/admin_users/show.html.erb
@@ -0,0 +1,12 @@
+<% content_for :page_title, @admin_user.email %>
+<% content_for :page_actions do %>
+ <%= link_to 'Edit', edit_admin_admin_user_path(@admin_user), class: 'btn btn-primary btn-sm' %>
+ <%= link_to 'Back', admin_admin_users_path, class: 'btn btn-outline-secondary btn-sm' %>
+<% end %>
+
+
+ Email
+ <%= @admin_user.email %>
+ Created at
+ <%= @admin_user.created_at %>
+
diff --git a/app/views/admin/dashboard/show.html.erb b/app/views/admin/dashboard/show.html.erb
new file mode 100644
index 0000000..5ddeed4
--- /dev/null
+++ b/app/views/admin/dashboard/show.html.erb
@@ -0,0 +1,107 @@
+<% if content_for?(:title) == false %>
+ <% content_for :page_title, 'Dashboard' %>
+<% end %>
+
+<% if @dashboard_message.present? %>
+
+ <%= @dashboard_message %>
+
+<% end %>
+
+<% if @active_program.nil? %>
+
+ No active program found. Please configure a program setting first.
+
+<% else %>
+
+ User Payment Totals - Program Year <%= @active_program.program_year %>
+
+ <% if @user_rows.any? || (@pagy && @pagy.count.positive?) %>
+
+
+
+
+ <%= admin_sort_link('User', 'user', current_column: @sort_column, current_order: @sort_order, page: @pagy&.page) %>
+ Total Paid
+ Program Cost
+ <%= admin_sort_link('Balance Due', 'balance_due', current_column: @sort_column, current_order: @sort_order, page: @pagy&.page) %>
+ Status
+
+
+
+ <% @user_rows.each do |data| %>
+ <% status = data[:balance_due].to_i.zero? ? 'Paid in Full' : 'Outstanding Balance' %>
+ <% status_class = data[:balance_due].to_i.zero? ? 'paid_full' : 'outstanding' %>
+
+
+ <%= link_to data[:user_email], admin_payments_path(user_id: data[:user_id]),
+ title: "View all payments for #{data[:user_email]}" %>
+
+ <%= number_to_currency(data[:total_paid]) %>
+ <%= number_to_currency(@active_program.total_cost) %>
+ <%= number_to_currency(data[:balance_due]) %>
+ <%= status %>
+
+ <% end %>
+
+
+
+
+
+ <% if @pagy %>
+ Displaying users <%= @pagy.from %> - <%= @pagy.to %> of <%= @pagy.count %> |
+ <% end %>
+ Paid in Full: <%= @paid_in_full_count %> | Outstanding: <%= @outstanding_count %>
+
+
+ <% if @pagy && @pagy.pages > 1 %>
+ <%= render 'admin/shared/pagination',
+ pagy: @pagy,
+ extra_params: { sort_column: @sort_column, sort_order: @sort_order } %>
+ <% end %>
+ <% else %>
+ No successful payments found for the active program.
+ <% end %>
+
+
+
+ Recent Payments - Program Year <%= @active_program.program_year %>
+
+ <% if @recent_payments.any? %>
+
+
+
+
+ User
+ Transaction ID
+ Amount
+ Status
+ Account Type
+ Date
+ Actions
+
+
+
+ <% @recent_payments.each do |payment| %>
+
+
+ <%= link_to payment.user.email, admin_payments_path(user_id: payment.user_id),
+ title: "View all payments for #{payment.user.email}" %>
+
+ <%= payment.transaction_id %>
+ <%= number_to_currency(payment.total_amount.to_f / 100) %>
+ <%= payment.transaction_status == '1' ? 'Success' : 'Failed' %>
+ <%= payment.account_type %>
+ <%= payment.created_at.strftime('%Y-%m-%d %H:%M') %>
+ <%= link_to 'View', admin_payment_path(payment) %>
+
+ <% end %>
+
+
+
+ Showing most recent <%= @recent_payments.count %> payments
+ <% else %>
+ No payments found for the active program.
+ <% end %>
+
+<% end %>
diff --git a/app/views/admin/payments/_form.html.erb b/app/views/admin/payments/_form.html.erb
new file mode 100644
index 0000000..56ebce4
--- /dev/null
+++ b/app/views/admin/payments/_form.html.erb
@@ -0,0 +1,34 @@
+<%= simple_form_for [:admin, payment] do |f| %>
+ <%= f.error_notification %>
+
+
+
+ <%= f.input :user_id, collection: User.order(:email), label_method: :email, value_method: :id, include_blank: false, label: 'User' %>
+ <%= f.input :total_amount, label: 'Total amount' %>
+
+ <% if payment.persisted? %>
+
Transaction Type <%= payment.transaction_type %>
+
Transaction Status <%= payment.transaction_status %>
+
Camp Year <%= payment.program_year %>
+ <%= f.input :transaction_type, as: :hidden %>
+ <%= f.input :transaction_status, as: :hidden %>
+ <%= f.input :program_year, as: :hidden %>
+ <% else %>
+ <%= f.input :transaction_type, label: 'Transaction type', input_html: { value: payment.transaction_type.presence || '1' } %>
+ <%= f.input :transaction_status, label: 'Transaction status', input_html: { value: payment.transaction_status.presence || '1' } %>
+ <%= f.input :program_year, label: 'Program year' %>
+ <% end %>
+
+ <%= f.input :transaction_id, label: 'Transaction ID' %>
+ <%= f.input :account_type, label: 'Account type' %>
+ <%= f.input :result_message, label: 'Result message' %>
+ <%= f.input :transaction_date, label: 'Transaction date',
+ input_html: { value: payment.transaction_date.presence || Time.current.strftime('%Y%m%d%H%M') } %>
+
+
+
+
+ <%= f.button :submit, class: 'btn btn-primary' %>
+ <%= link_to 'Cancel', admin_payments_path, class: 'btn btn-link' %>
+
+<% end %>
diff --git a/app/views/admin/payments/edit.html.erb b/app/views/admin/payments/edit.html.erb
new file mode 100644
index 0000000..2f79b37
--- /dev/null
+++ b/app/views/admin/payments/edit.html.erb
@@ -0,0 +1,3 @@
+<% content_for :page_title, 'Edit Payment' %>
+
+<%= render 'form', payment: @payment %>
diff --git a/app/views/admin/payments/index.html.erb b/app/views/admin/payments/index.html.erb
new file mode 100644
index 0000000..9d75a90
--- /dev/null
+++ b/app/views/admin/payments/index.html.erb
@@ -0,0 +1,70 @@
+<% content_for :page_title, 'Payments' %>
+<% content_for :page_actions do %>
+ <%= link_to 'New Payment', new_admin_payment_path, class: 'btn btn-primary btn-sm' %>
+<% end %>
+
+<%= form_with url: admin_payments_path, method: :get, local: true, class: 'row g-2 align-items-end mb-3' do |f| %>
+
+ <%= label_tag :user_id, 'User', class: 'form-label' %>
+ <%= select_tag :user_id,
+ options_from_collection_for_select(@users, :id, :email, params[:user_id]),
+ include_blank: 'Any',
+ class: 'form-select' %>
+
+
+ <%= label_tag :program_year, 'Program year', class: 'form-label' %>
+ <%= select_tag :program_year,
+ options_for_select(@program_years, params[:program_year]),
+ include_blank: 'Any',
+ class: 'form-select' %>
+
+
+ <%= label_tag :account_type, 'Account type', class: 'form-label' %>
+ <%= select_tag :account_type,
+ options_for_select(@account_types, params[:account_type]),
+ include_blank: 'Any',
+ class: 'form-select' %>
+
+
+ <%= submit_tag 'Filter', class: 'btn btn-secondary' %>
+ <%= link_to 'Clear', admin_payments_path, class: 'btn btn-link' %>
+
+<% end %>
+
+
+
+
+
+ User
+ Type
+ Status
+ Transaction ID
+ Total Amount
+ Transaction Date
+ Account Type
+ Program Year
+
+
+
+
+ <% @payments.each do |payment| %>
+
+ <%= payment.user.email %>
+ <%= payment.transaction_type %>
+ <%= payment.transaction_status %>
+ <%= payment.transaction_id %>
+ <%= number_to_currency(payment.total_amount.to_f / 100) %>
+ <%= payment.transaction_date %>
+ <%= payment.account_type %>
+ <%= payment.program_year %>
+
+ <%= link_to 'View', admin_payment_path(payment), class: 'me-2' %>
+ <%= link_to 'Edit', edit_admin_payment_path(payment) %>
+
+
+ <% end %>
+
+
+
+
+<%= render 'admin/shared/pagination', pagy: @pagy %>
diff --git a/app/views/admin/payments/new.html.erb b/app/views/admin/payments/new.html.erb
new file mode 100644
index 0000000..3564598
--- /dev/null
+++ b/app/views/admin/payments/new.html.erb
@@ -0,0 +1,3 @@
+<% content_for :page_title, 'New Payment' %>
+
+<%= render 'form', payment: @payment %>
diff --git a/app/views/admin/payments/show.html.erb b/app/views/admin/payments/show.html.erb
new file mode 100644
index 0000000..70cc7f9
--- /dev/null
+++ b/app/views/admin/payments/show.html.erb
@@ -0,0 +1,37 @@
+<% content_for :page_title, 'Payment Details' %>
+<% content_for :page_actions do %>
+ <%= link_to 'Edit', edit_admin_payment_path(@payment), class: 'btn btn-primary btn-sm' %>
+ <%= link_to 'Back', admin_payments_path, class: 'btn btn-outline-secondary btn-sm' %>
+<% end %>
+
+
+ User
+ <%= @payment.user.email %>
+
+ Transaction type
+ <%= @payment.transaction_type %>
+
+ Transaction status
+ <%= @payment.transaction_status %>
+
+ Transaction ID
+ <%= @payment.transaction_id %>
+
+ Total amount
+ <%= @payment.total_amount %> (<%= number_to_currency(@payment.total_amount.to_f / 100) %>)
+
+ Transaction date
+ <%= @payment.transaction_date %>
+
+ Account type
+ <%= @payment.account_type %>
+
+ Result code
+ <%= @payment.result_code %>
+
+ Result message
+ <%= @payment.result_message %>
+
+ Program year
+ <%= @payment.program_year %>
+
diff --git a/app/views/admin/program_settings/_form.html.erb b/app/views/admin/program_settings/_form.html.erb
new file mode 100644
index 0000000..c916700
--- /dev/null
+++ b/app/views/admin/program_settings/_form.html.erb
@@ -0,0 +1,21 @@
+<%= simple_form_for [:admin, program_setting] do |f| %>
+ <%= f.error_notification %>
+
+
+ <%= f.input :program_year %>
+ <%= f.input :application_fee %>
+ <%= f.input :program_fee %>
+ <%= f.input :active, as: :boolean %>
+ <%= f.input :allow_payments, as: :boolean %>
+ <%= f.input :program_open, as: :datetime %>
+ <%= f.input :program_close, as: :datetime %>
+ <%= f.input :open_instructions, as: :text %>
+ <%= f.input :close_instructions, as: :text %>
+ <%= f.input :payment_instructions, as: :text %>
+
+
+
+ <%= f.button :submit, class: 'btn btn-primary' %>
+ <%= link_to 'Cancel', admin_program_settings_path, class: 'btn btn-link' %>
+
+<% end %>
diff --git a/app/views/admin/program_settings/edit.html.erb b/app/views/admin/program_settings/edit.html.erb
new file mode 100644
index 0000000..be3e4db
--- /dev/null
+++ b/app/views/admin/program_settings/edit.html.erb
@@ -0,0 +1,2 @@
+<% content_for :page_title, 'Edit Program Setting' %>
+<%= render 'form', program_setting: @program_setting %>
diff --git a/app/views/admin/program_settings/index.html.erb b/app/views/admin/program_settings/index.html.erb
new file mode 100644
index 0000000..aa2988e
--- /dev/null
+++ b/app/views/admin/program_settings/index.html.erb
@@ -0,0 +1,37 @@
+<% content_for :page_title, 'Program Settings' %>
+<% content_for :page_actions do %>
+ <%= link_to 'New Program Setting', new_admin_program_setting_path, class: 'btn btn-primary btn-sm' %>
+<% end %>
+
+
+
+
+
+ Program Year
+ Application Fee
+ Program Fee
+ Active
+ Allow Payments
+
+
+
+
+ <% @program_settings.each do |setting| %>
+
+ <%= setting.program_year %>
+ <%= setting.application_fee %>
+ <%= setting.program_fee %>
+ <%= setting.active? %>
+ <%= setting.allow_payments? %>
+
+ <%= link_to 'View', admin_program_setting_path(setting), class: 'me-2' %>
+ <%= link_to 'Edit', edit_admin_program_setting_path(setting), class: 'me-2' %>
+ <%= link_to 'Delete', admin_program_setting_path(setting), data: { turbo_method: :delete, turbo_confirm: 'Are you sure?' } %>
+
+
+ <% end %>
+
+
+
+
+<%= render 'admin/shared/pagination', pagy: @pagy %>
diff --git a/app/views/admin/program_settings/new.html.erb b/app/views/admin/program_settings/new.html.erb
new file mode 100644
index 0000000..5622ea9
--- /dev/null
+++ b/app/views/admin/program_settings/new.html.erb
@@ -0,0 +1,2 @@
+<% content_for :page_title, 'New Program Setting' %>
+<%= render 'form', program_setting: @program_setting %>
diff --git a/app/views/admin/program_settings/show.html.erb b/app/views/admin/program_settings/show.html.erb
new file mode 100644
index 0000000..d1c160c
--- /dev/null
+++ b/app/views/admin/program_settings/show.html.erb
@@ -0,0 +1,12 @@
+<% content_for :page_title, "Program Setting #{@program_setting.program_year}" %>
+<% content_for :page_actions do %>
+ <%= link_to 'Edit', edit_admin_program_setting_path(@program_setting), class: 'btn btn-primary btn-sm' %>
+ <%= link_to 'Back', admin_program_settings_path, class: 'btn btn-outline-secondary btn-sm' %>
+<% end %>
+
+
+ <% %i[program_year application_fee program_fee active program_open program_close open_instructions close_instructions payment_instructions allow_payments].each do |attr| %>
+ <%= attr.to_s.humanize %>
+ <%= @program_setting.public_send(attr) %>
+ <% end %>
+
diff --git a/app/views/admin/shared/_pagination.html.erb b/app/views/admin/shared/_pagination.html.erb
new file mode 100644
index 0000000..aab0d14
--- /dev/null
+++ b/app/views/admin/shared/_pagination.html.erb
@@ -0,0 +1,29 @@
+<%#
+ locals:
+ pagy: Pagy object
+ extra_params: Hash of params to preserve (optional)
+%>
+<% extra_params ||= {} %>
+<% if pagy.pages > 1 %>
+
+<% end %>
diff --git a/app/views/admin/static_pages/edit.html.erb b/app/views/admin/static_pages/edit.html.erb
new file mode 100644
index 0000000..f61939e
--- /dev/null
+++ b/app/views/admin/static_pages/edit.html.erb
@@ -0,0 +1,15 @@
+<% content_for :page_title, "Edit #{@static_page.location}" %>
+<% content_for :action_text, true %>
+
+<%= simple_form_for [:admin, @static_page] do |f| %>
+ <%= f.error_notification %>
+ <%= f.input :location %>
+
+ <%= f.label :message %>
+ <%= f.rich_text_area :message %>
+
+
+ <%= f.button :submit, class: 'btn btn-primary' %>
+ <%= link_to 'Cancel', admin_static_pages_path, class: 'btn btn-link' %>
+
+<% end %>
diff --git a/app/views/admin/static_pages/index.html.erb b/app/views/admin/static_pages/index.html.erb
new file mode 100644
index 0000000..0fdf453
--- /dev/null
+++ b/app/views/admin/static_pages/index.html.erb
@@ -0,0 +1,22 @@
+<% content_for :page_title, 'Manage messages on static pages' %>
+
+
+
+
+
+ Location
+
+
+
+
+ <% @static_pages.each do |static_page| %>
+
+ <%= static_page.location %>
+ <%= link_to 'Edit', edit_admin_static_page_path(static_page) %>
+
+ <% end %>
+
+
+
+
+<%= render 'admin/shared/pagination', pagy: @pagy %>
diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb
new file mode 100644
index 0000000..46fdd81
--- /dev/null
+++ b/app/views/admin/users/index.html.erb
@@ -0,0 +1,59 @@
+<% content_for :page_title, 'Users' %>
+
+
+
+ <%= link_to 'All', admin_users_path, class: "nav-link #{'active' if params[:scope].blank?}" %>
+
+
+ <%= link_to 'Zero Balance', admin_users_path(scope: 'zero_balance'), class: "nav-link #{'active' if params[:scope] == 'zero_balance'}" %>
+
+
+
+<%= form_with url: admin_users_path, method: :get, local: true, class: 'row g-2 align-items-end mb-3' do |f| %>
+ <%= hidden_field_tag :scope, params[:scope] if params[:scope].present? %>
+
+ <%= label_tag :email, 'Email', class: 'form-label' %>
+ <%= select_tag :email,
+ options_for_select(@emails, params[:email]),
+ include_blank: 'Any',
+ class: 'form-select' %>
+
+
+ <%= submit_tag 'Filter', class: 'btn btn-secondary' %>
+
+<% end %>
+
+
+
+
+
+ ID
+ Email
+ Balance Due
+ Current Sign In At
+ Sign In Count
+ Last Sign In At
+ Last Sign In IP
+ Created At
+
+
+
+
+ <% @users.each do |user| %>
+
+ <%= user.id %>
+ <%= user.email %>
+ <%= number_to_currency(user.current_balance_due) %>
+ <%= user.current_sign_in_at %>
+ <%= user.sign_in_count %>
+ <%= user.last_sign_in_at %>
+ <%= user.last_sign_in_ip %>
+ <%= user.created_at %>
+ <%= link_to 'View', admin_user_path(user) %>
+
+ <% end %>
+
+
+
+
+<%= render 'admin/shared/pagination', pagy: @pagy %>
diff --git a/app/views/admin/users/show.html.erb b/app/views/admin/users/show.html.erb
new file mode 100644
index 0000000..2e676a4
--- /dev/null
+++ b/app/views/admin/users/show.html.erb
@@ -0,0 +1,40 @@
+<% content_for :page_title, @user.email %>
+<% content_for :page_actions do %>
+ <%= link_to 'Back', admin_users_path, class: 'btn btn-outline-secondary btn-sm' %>
+<% end %>
+
+
+ Email
+ <%= @user.email %>
+ Balance Due
+ <%= number_to_currency(@user.current_balance_due) %>
+ Sign In Count
+ <%= @user.sign_in_count %>
+
+
+Payments
+
+
+
+
+ Transaction ID
+ Amount
+ Status
+ Program Year
+
+
+
+
+ <% @payments.each do |payment| %>
+
+ <%= payment.transaction_id %>
+ <%= number_to_currency(payment.total_amount.to_f / 100) %>
+ <%= payment.transaction_status %>
+ <%= payment.program_year %>
+ <%= link_to 'View', admin_payment_path(payment) %>
+
+ <% end %>
+
+
+
+<%= render 'admin/shared/pagination', pagy: @pagy %>
diff --git a/app/views/admin_users/passwords/edit.html.erb b/app/views/admin_users/passwords/edit.html.erb
new file mode 100644
index 0000000..9f9a8d6
--- /dev/null
+++ b/app/views/admin_users/passwords/edit.html.erb
@@ -0,0 +1,21 @@
+
+
+
+
+
Change your password
+ <%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| %>
+ <%= f.hidden_field :reset_password_token %>
+
+ <%= f.label :password, 'New password', class: 'form-label' %>
+ <%= f.password_field :password, autofocus: true, autocomplete: 'new-password', class: 'form-control' %>
+
+
+ <%= f.label :password_confirmation, 'Confirm new password', class: 'form-label' %>
+ <%= f.password_field :password_confirmation, autocomplete: 'new-password', class: 'form-control' %>
+
+ <%= f.submit 'Change my password', class: 'btn btn-primary w-100' %>
+ <% end %>
+
+
+
+
diff --git a/app/views/admin_users/passwords/new.html.erb b/app/views/admin_users/passwords/new.html.erb
new file mode 100644
index 0000000..78d5e5f
--- /dev/null
+++ b/app/views/admin_users/passwords/new.html.erb
@@ -0,0 +1,19 @@
+
+
+
+
+
Forgot your password?
+ <%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post }) do |f| %>
+
+ <%= f.label :email, class: 'form-label' %>
+ <%= f.email_field :email, autofocus: true, autocomplete: 'email', class: 'form-control' %>
+
+ <%= f.submit 'Send me reset password instructions', class: 'btn btn-primary w-100' %>
+ <% end %>
+
+ <%= link_to 'Log in', new_session_path(resource_name) %>
+
+
+
+
+
diff --git a/app/views/admin_users/sessions/new.html.erb b/app/views/admin_users/sessions/new.html.erb
new file mode 100644
index 0000000..2a41ce6
--- /dev/null
+++ b/app/views/admin_users/sessions/new.html.erb
@@ -0,0 +1,29 @@
+
+
+
+
+
Admin Login
+ <%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
+
+ <%= f.label :email, class: 'form-label' %>
+ <%= f.email_field :email, autofocus: true, autocomplete: 'email', class: 'form-control' %>
+
+
+ <%= f.label :password, class: 'form-label' %>
+ <%= f.password_field :password, autocomplete: 'current-password', class: 'form-control' %>
+
+ <% if devise_mapping.rememberable? %>
+
+ <%= f.check_box :remember_me, class: 'form-check-input' %>
+ <%= f.label :remember_me, class: 'form-check-label' %>
+
+ <% end %>
+ <%= f.submit 'Login', class: 'btn btn-primary w-100' %>
+ <% end %>
+
+ <%= link_to 'Forgot your password?', new_password_path(resource_name) %>
+
+
+
+
+
diff --git a/app/views/layouts/_admin_flashes.html.erb b/app/views/layouts/_admin_flashes.html.erb
new file mode 100644
index 0000000..3f24c59
--- /dev/null
+++ b/app/views/layouts/_admin_flashes.html.erb
@@ -0,0 +1,11 @@
+<% flash.each do |type, message| %>
+ <% bootstrap_class = case type.to_s
+ when 'notice' then 'alert-success'
+ when 'alert', 'error' then 'alert-danger'
+ else 'alert-info'
+ end %>
+
+ <%= message %>
+
+
+<% end %>
diff --git a/app/views/layouts/_admin_nav.html.erb b/app/views/layouts/_admin_nav.html.erb
new file mode 100644
index 0000000..e1b5cf1
--- /dev/null
+++ b/app/views/layouts/_admin_nav.html.erb
@@ -0,0 +1,26 @@
+
+
+ <%= link_to 'Nelp Application', admin_root_path, class: 'navbar-brand' %>
+
+
+
+
+
+ <%= link_to 'Dashboard', admin_root_path, class: 'nav-link' %>
+ <%= link_to 'Program Settings', admin_program_settings_path, class: 'nav-link' %>
+ <%= link_to 'Payments', admin_payments_path, class: 'nav-link' %>
+ <%= link_to 'Users', admin_users_path, class: 'nav-link' %>
+ <%= link_to 'Static Pages', admin_static_pages_path, class: 'nav-link' %>
+ <%= link_to 'Admin Users', admin_admin_users_path, class: 'nav-link' %>
+
+
+
+ <%= current_admin_user.email %>
+
+
+ <%= button_to 'Logout', destroy_admin_user_session_path, method: :delete, class: 'btn btn-link nav-link text-white' %>
+
+
+
+
+
diff --git a/app/views/layouts/_footer.html.erb b/app/views/layouts/_footer.html.erb
index afd06c2..ee5c359 100644
--- a/app/views/layouts/_footer.html.erb
+++ b/app/views/layouts/_footer.html.erb
@@ -25,12 +25,12 @@
diff --git a/app/views/layouts/active_admin.html.erb b/app/views/layouts/active_admin.html.erb
deleted file mode 100644
index 25b0499..0000000
--- a/app/views/layouts/active_admin.html.erb
+++ /dev/null
@@ -1,72 +0,0 @@
-
-
-
-
-
- <%= [@page_title, ActiveAdmin.application.site_title(self)].compact.join(" | ") %>
-
- <% ActiveAdmin.application.stylesheets.each do |style, options| %>
- <% if ActiveAdmin.application.use_webpacker %>
- <%= stylesheet_pack_tag style, **options %>
- <% else %>
- <%= stylesheet_link_tag style, **options %>
- <% end %>
- <% end %>
-
-
- <%= stylesheet_link_tag "active_admin", "data-turbo-track": "reload" %>
-
- <% ActiveAdmin.application.javascripts.each do |path, options| %>
- <% if ActiveAdmin.application.use_webpacker %>
- <%= javascript_pack_tag path, **options %>
- <% else %>
- <%= javascript_include_tag path, **options %>
- <% end %>
- <% end %>
-
- <%= favicon_link_tag ActiveAdmin.application.favicon if ActiveAdmin.application.favicon %>
-
- <% ActiveAdmin.application.meta_tags.each do |name, content| %>
- <%= tag(:meta, name: name, content: content) %>
- <% end %>
-
- <%= csrf_meta_tags %>
- <%= csp_meta_tag %>
-
- <%= content_for(:head) %>
-
-
-
-
-
-
-
-
<%= @page_title %>
-
- <%= yield :titlebar_right %>
-
-
-
-
- <% flash_messages.each do |type, message| %>
- <%= content_tag :div, message, class: "flash flash_#{type}" %>
- <% end %>
-
-
-
- <%= yield %>
-
-
-
-
-
-
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb
new file mode 100644
index 0000000..b8e81a1
--- /dev/null
+++ b/app/views/layouts/admin.html.erb
@@ -0,0 +1,31 @@
+
+
+
+ <%= content_for?(:title) ? yield(:title) : 'Nelp Application' %> | Admin
+
+ <%= csrf_meta_tags %>
+ <%= csp_meta_tag %>
+
+
+ <%= stylesheet_link_tag "admin", "data-turbo-track": "reload" %>
+ <%= stylesheet_link_tag "actiontext", "data-turbo-track": "reload" if content_for?(:action_text) || controller_name == 'static_pages' %>
+ <%= javascript_importmap_tags %>
+
+
+
+ <% if admin_user_signed_in? %>
+ <%= render 'layouts/admin_nav' %>
+ <% end %>
+
+
+ <%= render 'layouts/admin_flashes' %>
+ <% if content_for?(:page_title) %>
+
+
<%= yield :page_title %>
+
<%= yield :page_actions %>
+
+ <% end %>
+ <%= yield %>
+
+
+
diff --git a/bin/jobs b/bin/jobs
new file mode 100755
index 0000000..dcf59f3
--- /dev/null
+++ b/bin/jobs
@@ -0,0 +1,6 @@
+#!/usr/bin/env ruby
+
+require_relative "../config/environment"
+require "solid_queue/cli"
+
+SolidQueue::Cli.start(ARGV)
diff --git a/config/application.rb b/config/application.rb
index c4f8aed..883625d 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -22,13 +22,12 @@
module NelpApplication
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
- config.load_defaults 7.2
+ config.load_defaults 8.1
# Configure dartsass-rails to build CSS files
config.dartsass.builds = {
"application.scss" => "application.css",
- "active_admin.scss" => "active_admin.css"
+ "admin.scss" => "admin.css"
}
- config.dartsass.build_options << " --load-path=#{Gem.loaded_specs['activeadmin'].full_gem_path}/app/assets/stylesheets"
config.dartsass.source_dir = 'app/assets/stylesheets'
# Add builds directory to asset paths so Sprockets can find compiled CSS
diff --git a/config/boot.rb b/config/boot.rb
index b9e460c..583c901 100644
--- a/config/boot.rb
+++ b/config/boot.rb
@@ -1,4 +1,10 @@
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
require 'bundler/setup' # Set up gems listed in the Gemfile.
-require 'bootsnap/setup' # Speed up boot time by caching expensive operations.
+
+# Bootsnap's ISeq compile cache conflicts with Ruby coverage (SimpleCov) on Ruby 4+.
+if ENV['DISABLE_BOOTSNAP'] == '1' || defined?(Coverage) || ENV['COVERAGE'] == 'true'
+ # Skip bootsnap when coverage instrumentation is active.
+else
+ require 'bootsnap/setup' # Speed up boot time by caching expensive operations.
+end
diff --git a/config/cable.yml b/config/cable.yml
index a710d9f..af43c22 100644
--- a/config/cable.yml
+++ b/config/cable.yml
@@ -1,10 +1,19 @@
+# Async adapter only works within the same process, so for manually triggering cable updates from a console,
+# and seeing results in the browser, you must do so from the web console (running inside the dev process),
+# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view
+# to make the web console appear.
development:
adapter: async
test:
adapter: test
+staging:
+ adapter: solid_cable
+ polling_interval: 0.1.seconds
+ message_retention: 1.day
+
production:
- adapter: redis
- url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
- channel_prefix: testing_app_production
+ adapter: solid_cable
+ polling_interval: 0.1.seconds
+ message_retention: 1.day
diff --git a/config/cache.yml b/config/cache.yml
new file mode 100644
index 0000000..45e49b2
--- /dev/null
+++ b/config/cache.yml
@@ -0,0 +1,18 @@
+default: &default
+ store_options:
+ # Cap age of oldest cache entry to fulfill retention policies
+ # max_age: <%= 60.days.to_i %>
+ max_size: <%= 256.megabytes %>
+ namespace: <%= Rails.env %>
+
+development:
+ <<: *default
+
+test:
+ <<: *default
+
+staging:
+ <<: *default
+
+production:
+ <<: *default
diff --git a/config/environments/production.rb b/config/environments/production.rb
index 9e1a891..03c2b64 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -68,10 +68,11 @@
config.log_level = ENV.fetch('RAILS_LOG_LEVEL', 'info')
# Use a different cache store in production.
- # config.cache_store = :mem_cache_store
+ config.cache_store = :solid_cache_store
- # Use a real queuing backend for Active Job (and separate queues per environment).
- # config.active_job.queue_adapter = :resque
+ # Use Solid Queue on the primary database (no separate queue DB).
+ config.active_job.queue_adapter = :solid_queue
+ config.solid_queue.silence_polling = true
# config.active_job.queue_name_prefix = "nelp_application_production"
# Disable caching for Action Mailer templates even if Action Controller
diff --git a/config/environments/staging.rb b/config/environments/staging.rb
index 5ef042f..41ad02a 100644
--- a/config/environments/staging.rb
+++ b/config/environments/staging.rb
@@ -67,11 +67,10 @@
# want to log everything, set the level to "debug".
config.log_level = ENV.fetch('RAILS_LOG_LEVEL', 'info')
- # Use a different cache store in staging.
- # config.cache_store = :mem_cache_store
-
- # Use a real queuing backend for Active Job (and separate queues per environment).
- # config.active_job.queue_adapter = :resque
+ # Use Solid Cache / Solid Queue on the primary database.
+ config.cache_store = :solid_cache_store
+ config.active_job.queue_adapter = :solid_queue
+ config.solid_queue.silence_polling = true
# config.active_job.queue_name_prefix = "nelp_application_staging"
# Disable caching for Action Mailer templates even if Action Controller
diff --git a/config/importmap.rb b/config/importmap.rb
index 26a2ede..7a676e7 100644
--- a/config/importmap.rb
+++ b/config/importmap.rb
@@ -7,4 +7,3 @@
pin '@hotwired/turbo-rails', to: 'turbo.min.js'
pin 'trix'
pin '@rails/actiontext', to: 'actiontext.esm.js'
-pin '@activeadmin/activeadmin', to: 'activeadmin.js'
diff --git a/config/initializers/active_admin.rb b/config/initializers/active_admin.rb
deleted file mode 100644
index 70ddc87..0000000
--- a/config/initializers/active_admin.rb
+++ /dev/null
@@ -1,550 +0,0 @@
-ActiveAdmin.setup do |config|
- # == Site Title
- #
- # Set the title that is displayed on the main layout
- # for each of the active admin pages.
- #
- config.site_title = 'Nelp Application'
-
- # Set the link url for the title. For example, to take
- # users to your main site. Defaults to no link.
- #
- config.site_title_link = '/'
-
- # Set an optional image to be displayed for the header
- # instead of a string (overrides :site_title)
- #
- # Note: Aim for an image that's 21px high so it fits in the header.
- #
- # config.site_title_image = "logo.png"
-
- # == Load Paths
- #
- # By default Active Admin files go inside app/admin/.
- # You can change this directory.
- #
- # eg:
- # config.load_paths = [File.join(Rails.root, 'app', 'ui')]
- #
- # Or, you can also load more directories.
- # Useful when setting namespaces with users that are not your main AdminUser entity.
- #
- # eg:
- # config.load_paths = [
- # File.join(Rails.root, 'app', 'admin'),
- # File.join(Rails.root, 'app', 'cashier')
- # ]
-
- # == Default Namespace
- #
- # Set the default namespace each administration resource
- # will be added to.
- #
- # eg:
- # config.default_namespace = :hello_world
- #
- # This will create resources in the HelloWorld module and
- # will namespace routes to /hello_world/*
- #
- # To set no namespace by default, use:
- # config.default_namespace = false
- #
- # Default:
- # config.default_namespace = :admin
- #
- # You can customize the settings for each namespace by using
- # a namespace block. For example, to change the site title
- # within a namespace:
- #
- # config.namespace :admin do |admin|
- # admin.site_title = "Custom Admin Title"
- # end
- #
- # This will ONLY change the title for the admin section. Other
- # namespaces will continue to use the main "site_title" configuration.
-
- # == User Authentication
- #
- # Active Admin will automatically call an authentication
- # method in a before filter of all controller actions to
- # ensure that there is a user logged in.
- #
- config.authentication_method = :authenticate_admin_user!
-
- # == User Authorization
- #
- # Active Admin will automatically call an authorization
- # method in a before filter of all controller actions to
- # ensure that there is a user with proper rights. You can use
- # CanCanAdapter or make your own. Please refer to documentation.
- # config.authorization_adapter = ActiveAdmin::CanCanAdapter
-
- # In case you prefer Pundit over other solutions you can here pass
- # the name of default policy class. This policy will be used in every
- # case when Pundit is unable to find suitable policy.
- # config.pundit_default_policy = "MyDefaultPunditPolicy"
-
- # If you wish to maintain a separate set of Pundit policies for admin
- # resources, you may set a namespace here that Pundit will search
- # within when looking for a resource's policy.
- # config.pundit_policy_namespace = :admin
-
- # You can customize your CanCan Ability class name here.
- # config.cancan_ability_class = "Ability"
-
- # You can specify a method to be called on unauthorized access.
- # This is necessary in order to prevent a redirect loop which happens
- # because, by default, user gets redirected to Dashboard. If user
- # doesn't have access to Dashboard, he'll end up in a redirect loop.
- # Method provided here should be defined in application_controller.rb.
- # config.on_unauthorized_access = :access_denied
-
- # == Current User
- #
- # Active Admin will associate actions with the current
- # user performing them.
- #
- # This setting changes the method which Active Admin calls
- # (within the application controller) to return the currently logged in user.
- config.current_user_method = :current_admin_user
-
- # == Logging out
- #
- # Active Admin needs to know how to log users out.
- #
- config.logout_link_path = :destroy_admin_user_session_path
-
- # == Batch Actions
- #
- # Enable and disable Batch Actions
- #
- config.batch_actions = true
-
- # == Controller Filters
- #
- # You can add before, after and around filters to all of your
- # resources controller.
- #
- # config.before_action :do_something_awesome
-
- # == Attribute Filters
- #
- # You can exclude possibly sensitive model attributes from being displayed,
- # added to forms, or exported by default by ActiveAdmin
- #
- config.filter_attributes = %i[encrypted_password password password_confirmation]
-
- # == Localize Date/Time Format
- #
- # Set the localize format to display dates and times.
- # To understand how to localize your app with I18n, read more at
- # https://guides.rubyonrails.org/i18n.html
- #
- # You can run `bin/rails runner 'puts I18n.t("date.formats")'` to see the
- # available formats in your application.
- #
- config.localize_format = :long
-
- # == Setting a Favicon
- #
- # config.favicon = 'favicon.ico'
-
- # == Meta Tags
- #
- # Add additional meta tags to the head element of active admin pages.
- #
- # Add tags to all pages logged in users see:
- # config.meta_tags = { author: 'My Company' }
-
- # By default, sign up/sign in/recover password pages are excluded
- # from showing up in search engine results by adding a robots meta
- # tag. You can reset the hash of meta tags included in logged out
- # pages:
- # config.meta_tags_for_logged_out_pages = {}
-
- # == Removing Breadcrumbs
- #
- # Breadcrumbs are enabled by default. You can customize them for individual
- # resources or you can disable them globally from here.
- #
- # config.breadcrumb = false
-
- # == Create Another Checkbox
- #
- # Create another checkbox is disabled by default. You can customize it for individual
- # resources or you can enable them globally from here.
- #
- # config.create_another = true
-
- # == Register Stylesheets & Javascripts
- #
- # We recommend using the built in Active Admin layout and loading
- # up your own stylesheets / javascripts to customize the look
- # and feel.
- #
- # To load a stylesheet:
- # config.register_stylesheet 'my_stylesheet.css'
- #
- # You can provide an options hash for more control, which is passed along to stylesheet_link_tag():
- # config.register_stylesheet 'my_print_stylesheet.css', media: :print
- #
- # To load a javascript file:
- # config.register_javascript 'my_javascript.js'
-
- # == CSV options
- #
- # Set the CSV builder separator
- # config.csv_options = { col_sep: ';' }
- #
- # Force the use of quotes
- # config.csv_options = { force_quotes: true }
-
- # == Menu System
- #
- # You can add a navigation menu to be used in your application, or configure a provided menu
- #
- # To change the default utility navigation to show a link to your website & a logout btn
- #
- # config.namespace :admin do |admin|
- # admin.build_menu :utility_navigation do |menu|
- # menu.add label: "My Great Website", url: "http://www.mygreatwebsite.com", html_options: { target: :blank }
- # admin.add_logout_button_to_menu menu
- # end
- # end
- #
- # If you wanted to add a static menu item to the default menu provided:
- #
- # config.namespace :admin do |admin|
- # admin.build_menu :default do |menu|
- # menu.add label: "My Great Website", url: "http://www.mygreatwebsite.com", html_options: { target: "_blank" }
- # end
- # end
-
- # == Download Links
- #
- # You can disable download links on resource listing pages,
- # or customize the formats shown per namespace/globally
- #
- # To disable/customize for the :admin namespace:
- #
- # config.namespace :admin do |admin|
- #
- # # Disable the links entirely
- # admin.download_links = false
- #
- # # Only show XML & PDF options
- # admin.download_links = [:xml, :pdf]
- #
- # # Enable/disable the links based on block
- # # (for example, with cancan)
- # admin.download_links = proc { can?(:view_download_links) }
- #
- # end
-
- # == Pagination
- #
- # Pagination is enabled by default for all resources.
- # You can control the default per page count for all resources here.
- #
- # config.default_per_page = 30
- #
- # You can control the max per page count too.
- #
- # config.max_per_page = 10_000
-
- # == Filters
- #
- # By default the index screen includes a "Filters" sidebar on the right
- # hand side with a filter for each attribute of the registered model.
- # You can enable or disable them for all resources here.
- #
- # config.filters = true
- #
- # By default the filters include associations in a select, which means
- # that every record will be loaded for each association (up
- # to the value of config.maximum_association_filter_arity).
- # You can enabled or disable the inclusion
- # of those filters by default here.
- #
- # config.include_default_association_filters = true
-
- # config.maximum_association_filter_arity = 256 # default value of :unlimited will change to 256 in a future version
- # config.filter_columns_for_large_association = [
- # :display_name,
- # :full_name,
- # :name,
- # :username,
- # :login,
- # :title,
- # :email,
- # ]
- # config.filter_method_for_large_association = '_start'
-
- # == Head
- #
- # You can add your own content to the site head like analytics. Make sure
- # you only pass content you trust.
- #
- # config.head = ''.html_safe
- config.head = proc { ApplicationController.helpers.javascript_importmap_tags('active_admin') }
-
- # == Footer
- #
- # By default, the footer shows the current Active Admin version. You can
- # override the content of the footer here.
- #
- # config.footer = 'my custom footer text'
-
- # == Sorting
- #
- # By default ActiveAdmin::OrderClause is used for sorting logic
- # You can inherit it with own class and inject it for all resources
- #
- # config.order_clause = MyOrderClause
-
- # == Webpacker
- #
- # By default, Active Admin uses Sprocket's asset pipeline.
- # You can switch to using Webpacker here.
- #
- # config.use_webpacker = true
- # Active Admin can be protected by basic authentication in staging environments.
- #
- # config.authentication_method = :authenticate_admin_user!
-
- #
- # ## Current user
- #
- # Active Admin will associate actions with the current user performing them.
- #
- # You can configure what named method it calls to find the current user here.
- #
- # config.current_user_method = :current_admin_user
-
- #
- # ## Root an namespace actions
- #
- # Action items will be displayed in the header of all root namespace pages.
- #
- # The default action items are:
- #
- # config.namespace_actions :admin, { 'New Post' => 'new_admin_post_path' }
- #
- # You can add your own action items for a particular namespace:
- #
- # config.namespace_actions :admin, { 'Report' => 'new_admin_report_path' }
- #
- # You can remove the an action item for a particular namespace:
- #
- # config.namespace_actions.delete_if do |namespace, action|
- # namespace == :admin && action.name == 'New Post'
- # end
-
- #
- # ## Viewport meta tag
- #
- # Active Admin saw a change in its default viewport meta tag in 3.0.
- # To keep the old behavior, you can set the `viewport` option to `true`.
- #
- # config.viewport = true
-
- #
- # ## Custom stylesheets
- #
- # You can customize the look of Active Admin by adding your own stylesheets.
- #
- # To "push" styles onto Active Admin's included assets, use the `register_stylesheet`
- # method. It accepts a path to a file and optional media and screen defined in the options hash.
- #
- # config.register_stylesheet 'my_stylesheet.css', media: :screen
- # Custom Active Admin stylesheet is handled by dartsass and asset pipeline
- # The stylesheet is included in the custom layout at app/views/active_admin/base.html.erb
- # config.register_stylesheet 'active_admin.css'
-
- #
- # ## Custom javascripts
- #
- # You can customize the behavior of Active Admin by adding your own javascripts.
- #
- # To "push" javascripts onto Active Admin's included assets, use the `register_javascript`
- # method. It accepts a path to a file and optional options defined in a hash.
- #
- # config.register_javascript 'my_javascript.js'
- # config.register_javascript 'active_admin.js', type: "module"
-
- #
- # ## CSV options
- #
- # Set the CSV builder separator
- #
- # config.csv_builder = {
- # col_sep: ';',
- # byte_order_mark: "\xEF\xBB\xBF",
- # force_quotes: true
- # }
-
- #
- # ## Localize Date/Time Format
- #
- # You can localize the date/time format used in Active Admin.
- #
- # config.localize_format = :long
-
- #
- # ## Comments
- #
- # You can disable the comments in Active Admin.
- #
- # config.comments = false
- #
- # You can change the name under which comments are registered:
- #
- # config.comments_registration_name = 'AdminComment'
- #
- # You can change the order for the comments and you can change the column
- # to be used for ordering.
- #
- # config.comments_order = 'created_at ASC'
- #
- # You can disable the menu item for the comments index page.
- #
- # config.comments_menu = false
- #
- # You can customize the comment menu:
- #
- # config.comments_menu = { parent: 'Admin', priority: 1 }
-
- #
- # ## Batch Actions
- #
- # You can disable batch actions here:
- #
- # config.batch_actions = false
-
- #
- # ## Controller to render authorization failures
- #
- # You can change the controller and method name that renders authorization failures
- #
- # config.authorization_failure_controller = 'sessions'
- # config.authorization_failure_action = 'new'
-
- #
- # ## Breadcrumbs
- #
- # You can change the breadcrumb separator
- #
- # config.breadcrumb_separator = ' / '
-
- #
- # ## Create Another Checkbox
- #
- # You can show a "Create another" checkbox on a form page so that the user is
- # redirected to the new page after a successful save.
- #
- # config.create_another = true
-
- #
- # ## Pagination
- #
- # You can change the default per page values for models.
- #
- # config.default_per_page = 30
- #
- # You can change the max per page value.
- #
- # config.max_per_page = 10_000
-
- #
- # ## Footer
- #
- # You can customize the footer of Active Admin.
- #
- # To override the default footer text, you can change the `footer` config.
- #
- # config.footer = 'my custom footer text'
- #
- # To provide a logo for the footer, you can change the `footer_logo` config.
- #
- # config.footer_logo = 'logo.png'
-
- #
- # ## Table Builder Class
- #
- # You can change the table builder class used to render tables
- #
- # config.table_builder = 'ActiveAdmin::Views::TableFor'
-
- #
- # ## Index default actions
- #
- # You can disable default actions in index pages on a per-model basis.
- # The "View", "Edit" and "Delete" actions are enabled by default..
- #
- # config.remove_action_item(:destroy, for: User)
- # config.remove_action_item(:edit, for: User)
- # config.remove_action_item(:show, for: User)
- # config.remove_action_item(:new, for: User)
-
- #
- # ## Default Scopes
- #
- # You can remove the "All" scope from the index page
- #
- # config.remove_scope 'all'
-
- #
- # ## Preserve Filters
- #
- # You can preserve filters on a per-model basis. The options available are:
- #
- # `true` (default) - Preserves filters locally (in browser's `localStorage`)
- # `false` - Does not preserve filters
- # `:session` - Preserves filters in the session
- #
- # config.preserve_filters = true
-
- #
- # ## Inflections
- #
- # Active Admin deeply relies on Active Support's inflector to pluralize and
- # singularize resource names. If you have any special cases, you can add them
- # to the inflector's rules.
- #
- # ActiveSupport::Inflector.inflections(:en) do |inflect|
- # inflect.irregular 'person', 'people'
- # end
-
- #
- # ## Includes
- #
- # You can specify relationships to be included in an index page query.
- #
- # config.includes = [:author]
-
- #
- # ## Number formatting
- #
- # You can customize the formatting of numbers.
- #
- # config.number_format = ->(number) { service.number_to_currency(number) }
-
- #
- # ## Meta tags
- #
- # You can set meta tags for the Active Admin pages.
- #
- # config.meta_tags = {
- # viewport: 'width=device-width, initial-scale=1'
- # }
-
- #
- # ## Favicon
- #
- # You can set a favicon for the Active Admin pages.
- #
- # config.favicon = '/favicon.ico'
-
- # By default, Active Admin uses its own stylesheets and JavaScripts.
- # To use a custom setup, disable the default assets and register your own.
-end
diff --git a/config/initializers/pagy.rb b/config/initializers/pagy.rb
new file mode 100644
index 0000000..f1e3d76
--- /dev/null
+++ b/config/initializers/pagy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'pagy/extras/array'
+require 'pagy/extras/overflow'
+
+Pagy::DEFAULT[:items] = 20
+Pagy::DEFAULT[:overflow] = :last_page
diff --git a/config/puma.rb b/config/puma.rb
index 6641449..664dfb5 100644
--- a/config/puma.rb
+++ b/config/puma.rb
@@ -32,6 +32,9 @@
# Allow puma to be restarted by `bin/rails restart` command.
plugin :tmp_restart
+# Run the Solid Queue supervisor inside Puma in production/staging.
+plugin :solid_queue if ENV['SOLID_QUEUE_IN_PUMA'] == 'true'
+
# Workers configuration for production
workers ENV.fetch('WEB_CONCURRENCY', 2) if ENV.fetch('RACK_ENV', 'development') == 'production'
diff --git a/config/queue.yml b/config/queue.yml
new file mode 100644
index 0000000..6b14360
--- /dev/null
+++ b/config/queue.yml
@@ -0,0 +1,18 @@
+default: &default
+ dispatchers:
+ - polling_interval: 1
+ batch_size: 500
+ workers:
+ - queues: "*"
+ threads: 3
+ processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
+ polling_interval: 1
+
+development:
+ <<: *default
+
+test:
+ <<: *default
+
+production:
+ <<: *default
diff --git a/config/recurring.yml b/config/recurring.yml
new file mode 100644
index 0000000..b4207f9
--- /dev/null
+++ b/config/recurring.yml
@@ -0,0 +1,15 @@
+# examples:
+# periodic_cleanup:
+# class: CleanSoftDeletedRecordsJob
+# queue: background
+# args: [ 1000, { batch_size: 500 } ]
+# schedule: every hour
+# periodic_cleanup_with_command:
+# command: "SoftDeletedRecord.due.delete_all"
+# priority: 2
+# schedule: at 5am every day
+
+production:
+ clear_solid_queue_finished_jobs:
+ command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
+ schedule: every hour at minute 12
diff --git a/config/routes.rb b/config/routes.rb
index a618eb0..020b082 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -3,13 +3,25 @@
# Devise routes
devise_for :users, controllers: { sessions: 'users/sessions' }
- devise_for :admin_users, ActiveAdmin::Devise.config
+ devise_for :admin_users,
+ path: 'admin',
+ path_names: { sign_in: 'login', sign_out: 'logout' },
+ controllers: {
+ sessions: 'admin_users/sessions',
+ passwords: 'admin_users/passwords'
+ }
# LSA TDX Feedback routes
mount LsaTdxFeedback::Engine => '/lsa_tdx_feedback', as: 'lsa_tdx_feedback'
- # ActiveAdmin routes
- ActiveAdmin.routes(self)
+ namespace :admin do
+ root to: 'dashboard#show'
+ resources :payments, except: :destroy
+ resources :users, only: %i[index show]
+ resources :program_settings
+ resources :static_pages, only: %i[index edit update]
+ resources :admin_users
+ end
# Static pages
get '/about', to: 'static_pages#about'
diff --git a/db/migrate/20260727140849_drop_active_admin_comments.rb b/db/migrate/20260727140849_drop_active_admin_comments.rb
new file mode 100644
index 0000000..2e44b0d
--- /dev/null
+++ b/db/migrate/20260727140849_drop_active_admin_comments.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class DropActiveAdminComments < ActiveRecord::Migration[7.2]
+ def up
+ drop_table :active_admin_comments, if_exists: true
+ end
+
+ def down
+ create_table :active_admin_comments do |t|
+ t.string :namespace
+ t.text :body
+ t.references :resource, polymorphic: true
+ t.references :author, polymorphic: true
+ t.timestamps
+ end
+ add_index :active_admin_comments, [:namespace]
+ end
+end
diff --git a/db/migrate/20260727141500_create_solid_cable_cache_queue_tables.rb b/db/migrate/20260727141500_create_solid_cable_cache_queue_tables.rb
new file mode 100644
index 0000000..3c4b1d7
--- /dev/null
+++ b/db/migrate/20260727141500_create_solid_cable_cache_queue_tables.rb
@@ -0,0 +1,164 @@
+# frozen_string_literal: true
+
+class CreateSolidCableCacheQueueTables < ActiveRecord::Migration[8.1]
+ def change
+ create_table :solid_cache_entries do |t|
+ t.binary :key, limit: 1024, null: false
+ t.binary :value, limit: 536_870_912, null: false
+ t.datetime :created_at, null: false
+ t.integer :key_hash, limit: 8, null: false
+ t.integer :byte_size, limit: 4, null: false
+
+ t.index :byte_size, name: 'index_solid_cache_entries_on_byte_size'
+ t.index %i[key_hash byte_size], name: 'index_solid_cache_entries_on_key_hash_and_byte_size'
+ t.index :key_hash, name: 'index_solid_cache_entries_on_key_hash', unique: true
+ end
+
+ create_table :solid_cable_messages do |t|
+ t.binary :channel, limit: 1024, null: false
+ t.binary :payload, limit: 536_870_912, null: false
+ t.datetime :created_at, null: false
+ t.integer :channel_hash, limit: 8, null: false
+
+ t.index :channel, name: 'index_solid_cable_messages_on_channel'
+ t.index :channel_hash, name: 'index_solid_cable_messages_on_channel_hash'
+ t.index :created_at, name: 'index_solid_cable_messages_on_created_at'
+ end
+
+ create_table :solid_queue_jobs do |t|
+ t.string :queue_name, null: false
+ t.string :class_name, null: false
+ t.text :arguments
+ t.integer :priority, default: 0, null: false
+ t.string :active_job_id
+ t.datetime :scheduled_at
+ t.datetime :finished_at
+ t.string :concurrency_key
+ t.timestamps
+
+ t.index :active_job_id, name: 'index_solid_queue_jobs_on_active_job_id'
+ t.index :class_name, name: 'index_solid_queue_jobs_on_class_name'
+ t.index :finished_at, name: 'index_solid_queue_jobs_on_finished_at'
+ t.index %i[queue_name finished_at], name: 'index_solid_queue_jobs_for_filtering'
+ t.index %i[scheduled_at finished_at], name: 'index_solid_queue_jobs_for_alerting'
+ end
+
+ create_table :solid_queue_blocked_executions do |t|
+ t.bigint :job_id, null: false
+ t.string :queue_name, null: false
+ t.integer :priority, default: 0, null: false
+ t.string :concurrency_key, null: false
+ t.datetime :expires_at, null: false
+ t.datetime :created_at, null: false
+
+ t.index %i[concurrency_key priority job_id], name: 'index_solid_queue_blocked_executions_for_release'
+ t.index %i[expires_at concurrency_key], name: 'index_solid_queue_blocked_executions_for_maintenance'
+ t.index :job_id, name: 'index_solid_queue_blocked_executions_on_job_id', unique: true
+ end
+
+ create_table :solid_queue_claimed_executions do |t|
+ t.bigint :job_id, null: false
+ t.bigint :process_id
+ t.datetime :created_at, null: false
+
+ t.index :job_id, name: 'index_solid_queue_claimed_executions_on_job_id', unique: true
+ t.index %i[process_id job_id], name: 'index_solid_queue_claimed_executions_on_process_id_and_job_id'
+ end
+
+ create_table :solid_queue_failed_executions do |t|
+ t.bigint :job_id, null: false
+ t.text :error
+ t.datetime :created_at, null: false
+
+ t.index :job_id, name: 'index_solid_queue_failed_executions_on_job_id', unique: true
+ end
+
+ create_table :solid_queue_pauses do |t|
+ t.string :queue_name, null: false
+ t.datetime :created_at, null: false
+
+ t.index :queue_name, name: 'index_solid_queue_pauses_on_queue_name', unique: true
+ end
+
+ create_table :solid_queue_processes do |t|
+ t.string :kind, null: false
+ t.datetime :last_heartbeat_at, null: false
+ t.bigint :supervisor_id
+ t.integer :pid, null: false
+ t.string :hostname
+ t.text :metadata
+ t.datetime :created_at, null: false
+ t.string :name, null: false
+
+ t.index :last_heartbeat_at, name: 'index_solid_queue_processes_on_last_heartbeat_at'
+ t.index %i[name supervisor_id], name: 'index_solid_queue_processes_on_name_and_supervisor_id', unique: true
+ t.index :supervisor_id, name: 'index_solid_queue_processes_on_supervisor_id'
+ end
+
+ create_table :solid_queue_ready_executions do |t|
+ t.bigint :job_id, null: false
+ t.string :queue_name, null: false
+ t.integer :priority, default: 0, null: false
+ t.datetime :created_at, null: false
+
+ t.index :job_id, name: 'index_solid_queue_ready_executions_on_job_id', unique: true
+ t.index %i[priority job_id], name: 'index_solid_queue_poll_all'
+ t.index %i[queue_name priority job_id], name: 'index_solid_queue_poll_by_queue'
+ end
+
+ create_table :solid_queue_recurring_executions do |t|
+ t.bigint :job_id, null: false
+ t.string :task_key, null: false
+ t.datetime :run_at, null: false
+ t.datetime :created_at, null: false
+
+ t.index :job_id, name: 'index_solid_queue_recurring_executions_on_job_id', unique: true
+ t.index %i[task_key run_at], name: 'index_solid_queue_recurring_executions_on_task_key_and_run_at', unique: true
+ end
+
+ create_table :solid_queue_recurring_tasks do |t|
+ t.string :key, null: false
+ t.string :schedule, null: false
+ t.string :command, limit: 2048
+ t.string :class_name
+ t.text :arguments
+ t.string :queue_name
+ t.integer :priority, default: 0
+ t.boolean :static, default: true, null: false
+ t.text :description
+ t.timestamps
+
+ t.index :key, name: 'index_solid_queue_recurring_tasks_on_key', unique: true
+ t.index :static, name: 'index_solid_queue_recurring_tasks_on_static'
+ end
+
+ create_table :solid_queue_scheduled_executions do |t|
+ t.bigint :job_id, null: false
+ t.string :queue_name, null: false
+ t.integer :priority, default: 0, null: false
+ t.datetime :scheduled_at, null: false
+ t.datetime :created_at, null: false
+
+ t.index :job_id, name: 'index_solid_queue_scheduled_executions_on_job_id', unique: true
+ t.index %i[scheduled_at priority job_id], name: 'index_solid_queue_dispatch_all'
+ end
+
+ create_table :solid_queue_semaphores do |t|
+ t.string :key, null: false
+ t.integer :value, default: 1, null: false
+ t.datetime :expires_at, null: false
+ t.timestamps
+
+ t.index :expires_at, name: 'index_solid_queue_semaphores_on_expires_at'
+ t.index %i[key value], name: 'index_solid_queue_semaphores_on_key_and_value'
+ t.index :key, name: 'index_solid_queue_semaphores_on_key', unique: true
+ end
+
+ add_foreign_key :solid_queue_blocked_executions, :solid_queue_jobs, column: :job_id, on_delete: :cascade
+ add_foreign_key :solid_queue_claimed_executions, :solid_queue_jobs, column: :job_id, on_delete: :cascade
+ add_foreign_key :solid_queue_failed_executions, :solid_queue_jobs, column: :job_id, on_delete: :cascade
+ add_foreign_key :solid_queue_ready_executions, :solid_queue_jobs, column: :job_id, on_delete: :cascade
+ add_foreign_key :solid_queue_recurring_executions, :solid_queue_jobs, column: :job_id, on_delete: :cascade
+ add_foreign_key :solid_queue_scheduled_executions, :solid_queue_jobs, column: :job_id, on_delete: :cascade
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 3d6ea00..7abf5de 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,53 +10,39 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2025_07_15_162336) do
+ActiveRecord::Schema[8.1].define(version: 2026_07_27_141500) do
# These are extensions that must be enabled in order to support this database
- enable_extension "plpgsql"
+ enable_extension "pg_catalog.plpgsql"
create_table "action_text_rich_texts", force: :cascade do |t|
- t.string "name", null: false
t.text "body"
- t.string "record_type", null: false
- t.bigint "record_id", null: false
t.datetime "created_at", null: false
+ t.string "name", null: false
+ t.bigint "record_id", null: false
+ t.string "record_type", null: false
t.datetime "updated_at", null: false
t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true
end
- create_table "active_admin_comments", force: :cascade do |t|
- t.string "namespace"
- t.text "body"
- t.string "resource_type"
- t.bigint "resource_id"
- t.string "author_type"
- t.bigint "author_id"
- t.datetime "created_at", null: false
- t.datetime "updated_at", null: false
- t.index ["author_type", "author_id"], name: "index_active_admin_comments_on_author"
- t.index ["namespace"], name: "index_active_admin_comments_on_namespace"
- t.index ["resource_type", "resource_id"], name: "index_active_admin_comments_on_resource"
- end
-
create_table "active_storage_attachments", force: :cascade do |t|
- t.string "name", null: false
- t.string "record_type", null: false
- t.bigint "record_id", null: false
t.bigint "blob_id", null: false
t.datetime "created_at", precision: nil, null: false
+ t.string "name", null: false
+ t.bigint "record_id", null: false
+ t.string "record_type", null: false
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
end
create_table "active_storage_blobs", force: :cascade do |t|
- t.string "key", null: false
- t.string "filename", null: false
- t.string "content_type"
- t.text "metadata"
- t.string "service_name", null: false
t.bigint "byte_size", null: false
t.string "checksum"
+ t.string "content_type"
t.datetime "created_at", precision: nil, null: false
+ t.string "filename", null: false
+ t.string "key", null: false
+ t.text "metadata"
+ t.string "service_name", null: false
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
end
@@ -67,71 +53,213 @@
end
create_table "admin_users", force: :cascade do |t|
+ t.datetime "created_at", null: false
t.string "email", default: "", null: false
t.string "encrypted_password", default: "", null: false
- t.string "reset_password_token"
- t.datetime "reset_password_sent_at", precision: nil
t.datetime "remember_created_at", precision: nil
- t.datetime "created_at", null: false
+ t.datetime "reset_password_sent_at", precision: nil
+ t.string "reset_password_token"
t.datetime "updated_at", null: false
t.index ["email"], name: "index_admin_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_admin_users_on_reset_password_token", unique: true
end
create_table "payments", force: :cascade do |t|
- t.string "transaction_type"
- t.string "transaction_status"
- t.string "transaction_id"
- t.string "total_amount"
- t.string "transaction_date"
t.string "account_type"
+ t.datetime "created_at", null: false
+ t.string "payer_identity"
+ t.integer "program_year", null: false
t.string "result_code"
t.string "result_message"
- t.string "user_account"
- t.string "payer_identity"
t.string "timestamp"
+ t.string "total_amount"
+ t.string "transaction_date"
t.string "transaction_hash"
- t.bigint "user_id", null: false
- t.datetime "created_at", null: false
+ t.string "transaction_id"
+ t.string "transaction_status"
+ t.string "transaction_type"
t.datetime "updated_at", null: false
- t.integer "program_year", null: false
+ t.string "user_account"
+ t.bigint "user_id", null: false
t.index ["user_id"], name: "index_payments_on_user_id"
end
create_table "program_settings", force: :cascade do |t|
- t.integer "program_year"
- t.integer "application_fee", default: 0, null: false
- t.integer "program_fee", default: 0, null: false
t.boolean "active", default: false
- t.datetime "program_open", precision: nil
- t.datetime "program_close", precision: nil
- t.text "open_instructions"
+ t.boolean "allow_payments", default: false
+ t.integer "application_fee", default: 0, null: false
t.text "close_instructions"
+ t.datetime "created_at", null: false
+ t.text "open_instructions"
t.text "payment_instructions"
- t.boolean "allow_payments", default: false
+ t.datetime "program_close", precision: nil
+ t.integer "program_fee", default: 0, null: false
+ t.datetime "program_open", precision: nil
+ t.integer "program_year"
+ t.datetime "updated_at", null: false
+ end
+
+ create_table "solid_cable_messages", force: :cascade do |t|
+ t.binary "channel", null: false
+ t.bigint "channel_hash", null: false
+ t.datetime "created_at", null: false
+ t.binary "payload", null: false
+ t.index ["channel"], name: "index_solid_cable_messages_on_channel"
+ t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash"
+ t.index ["created_at"], name: "index_solid_cable_messages_on_created_at"
+ end
+
+ create_table "solid_cache_entries", force: :cascade do |t|
+ t.integer "byte_size", null: false
+ t.datetime "created_at", null: false
+ t.binary "key", null: false
+ t.bigint "key_hash", null: false
+ t.binary "value", null: false
+ t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size"
+ t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size"
+ t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true
+ end
+
+ create_table "solid_queue_blocked_executions", force: :cascade do |t|
+ t.string "concurrency_key", null: false
+ t.datetime "created_at", null: false
+ t.datetime "expires_at", null: false
+ t.bigint "job_id", null: false
+ t.integer "priority", default: 0, null: false
+ t.string "queue_name", null: false
+ t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release"
+ t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance"
+ t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true
+ end
+
+ create_table "solid_queue_claimed_executions", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.bigint "job_id", null: false
+ t.bigint "process_id"
+ t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true
+ t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
+ end
+
+ create_table "solid_queue_failed_executions", force: :cascade do |t|
t.datetime "created_at", null: false
+ t.text "error"
+ t.bigint "job_id", null: false
+ t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true
+ end
+
+ create_table "solid_queue_jobs", force: :cascade do |t|
+ t.string "active_job_id"
+ t.text "arguments"
+ t.string "class_name", null: false
+ t.string "concurrency_key"
+ t.datetime "created_at", null: false
+ t.datetime "finished_at"
+ t.integer "priority", default: 0, null: false
+ t.string "queue_name", null: false
+ t.datetime "scheduled_at"
t.datetime "updated_at", null: false
+ t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id"
+ t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name"
+ t.index ["finished_at"], name: "index_solid_queue_jobs_on_finished_at"
+ t.index ["queue_name", "finished_at"], name: "index_solid_queue_jobs_for_filtering"
+ t.index ["scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting"
+ end
+
+ create_table "solid_queue_pauses", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "queue_name", null: false
+ t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true
+ end
+
+ create_table "solid_queue_processes", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "hostname"
+ t.string "kind", null: false
+ t.datetime "last_heartbeat_at", null: false
+ t.text "metadata"
+ t.string "name", null: false
+ t.integer "pid", null: false
+ t.bigint "supervisor_id"
+ t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at"
+ t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true
+ t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id"
+ end
+
+ create_table "solid_queue_ready_executions", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.bigint "job_id", null: false
+ t.integer "priority", default: 0, null: false
+ t.string "queue_name", null: false
+ t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true
+ t.index ["priority", "job_id"], name: "index_solid_queue_poll_all"
+ t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue"
+ end
+
+ create_table "solid_queue_recurring_executions", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.bigint "job_id", null: false
+ t.datetime "run_at", null: false
+ t.string "task_key", null: false
+ t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
+ t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
+ end
+
+ create_table "solid_queue_recurring_tasks", force: :cascade do |t|
+ t.text "arguments"
+ t.string "class_name"
+ t.string "command", limit: 2048
+ t.datetime "created_at", null: false
+ t.text "description"
+ t.string "key", null: false
+ t.integer "priority", default: 0
+ t.string "queue_name"
+ t.string "schedule", null: false
+ t.boolean "static", default: true, null: false
+ t.datetime "updated_at", null: false
+ t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true
+ t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static"
+ end
+
+ create_table "solid_queue_scheduled_executions", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.bigint "job_id", null: false
+ t.integer "priority", default: 0, null: false
+ t.string "queue_name", null: false
+ t.datetime "scheduled_at", null: false
+ t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true
+ t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all"
+ end
+
+ create_table "solid_queue_semaphores", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.datetime "expires_at", null: false
+ t.string "key", null: false
+ t.datetime "updated_at", null: false
+ t.integer "value", default: 1, null: false
+ t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at"
+ t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value"
+ t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true
end
create_table "static_pages", force: :cascade do |t|
- t.string "location"
t.datetime "created_at", null: false
+ t.string "location"
t.datetime "updated_at", null: false
end
create_table "users", force: :cascade do |t|
- t.string "email", default: "", null: false
- t.string "encrypted_password", default: "", null: false
- t.string "reset_password_token"
- t.datetime "reset_password_sent_at", precision: nil
- t.datetime "remember_created_at", precision: nil
t.datetime "created_at", null: false
- t.datetime "updated_at", null: false
- t.integer "sign_in_count", default: 0, null: false
t.datetime "current_sign_in_at", precision: nil
- t.datetime "last_sign_in_at", precision: nil
t.string "current_sign_in_ip"
+ t.string "email", default: "", null: false
+ t.string "encrypted_password", default: "", null: false
+ t.datetime "last_sign_in_at", precision: nil
t.string "last_sign_in_ip"
+ t.datetime "remember_created_at", precision: nil
+ t.datetime "reset_password_sent_at", precision: nil
+ t.string "reset_password_token"
+ t.integer "sign_in_count", default: 0, null: false
+ t.datetime "updated_at", null: false
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end
@@ -139,4 +267,10 @@
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "payments", "users"
+ add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+ add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+ add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+ add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+ add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+ add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
end
diff --git a/lib/tasks/generate_sample_payments.rake b/lib/tasks/generate_sample_payments.rake
index 442d626..5436323 100644
--- a/lib/tasks/generate_sample_payments.rake
+++ b/lib/tasks/generate_sample_payments.rake
@@ -110,7 +110,7 @@ namespace :sample_data do
puts "โ Successfully created #{target_count} users"
puts "โ Successfully created #{created_count} payments"
puts "โ All records are associated with program year 2024"
- puts "\nYou can now view them in ActiveAdmin at /admin/payments"
+ puts "\nYou can now view them in the admin at /admin/payments"
end
desc "Clean up sample payment data"
diff --git a/spec/factories/admin_users.rb b/spec/factories/admin_users.rb
index 41ab297..7f9c4ec 100644
--- a/spec/factories/admin_users.rb
+++ b/spec/factories/admin_users.rb
@@ -13,7 +13,7 @@
#
FactoryBot.define do
factory :admin_user do
- email { 'admin@example.com' }
+ email { Faker::Internet.unique.email }
password { 'password' }
password_confirmation { 'password' }
end
diff --git a/spec/features/admin_payments_spec.rb b/spec/features/admin_payments_spec.rb
index 16621db..93f74ea 100644
--- a/spec/features/admin_payments_spec.rb
+++ b/spec/features/admin_payments_spec.rb
@@ -52,7 +52,7 @@
expect(page).to have_field('Transaction date', with: DateTime.now.strftime('%Y%m%d%H%M'))
fill_in 'Total amount', with: 5000
select user.email, from: 'User'
- fill_in 'Transaction', with: 'TX123'
+ fill_in 'Transaction ID', with: 'TX123'
fill_in 'Account type', with: 'Checking'
fill_in 'Result message', with: 'Success'
fill_in 'Program year', with: Date.current.year
@@ -75,7 +75,7 @@
describe 'authorization' do
it 'redirects non-admin users' do
- click_link 'Logout'
+ click_button 'Logout'
visit admin_payments_path
expect(page).to have_content('You need to sign in or sign up before continuing')
end
diff --git a/spec/features/admin_users_spec.rb b/spec/features/admin_users_spec.rb
index 075c4d0..8b0d44f 100644
--- a/spec/features/admin_users_spec.rb
+++ b/spec/features/admin_users_spec.rb
@@ -50,7 +50,7 @@
end
it 'redirects non-admin users from admin users page' do
- click_link 'Logout'
+ click_button 'Logout'
visit admin_users_path
expect(page).to have_content('You need to sign in or sign up before continuing')
end
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index 9b7aaa1..85fb794 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -14,6 +14,7 @@
add_filter '/channels/'
add_filter '/jobs/'
add_filter '/mailers/'
+ add_filter 'sentry_test_helper'
add_group 'Models', 'app/models'
add_group 'Controllers', 'app/controllers'
add_group 'Helpers', 'app/helpers'
@@ -23,6 +24,9 @@
minimum_coverage 85
end
+# Bootsnap ISeq compile cache is incompatible with coverage on Ruby 4+.
+ENV['DISABLE_BOOTSNAP'] = '1'
+
require 'spec_helper'
ENV['RAILS_ENV'] ||= 'test'
require_relative '../config/environment'
@@ -57,6 +61,9 @@
abort e.to_s.strip
end
+# Rails 8 lazy-loads routes; Devise mappings are empty until routes are drawn.
+Rails.application.reload_routes_unless_loaded
+
RSpec.configure do |config|
# Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
# config.fixture_path = "#{::Rails.root}/spec/fixtures" # This method is deprecated
diff --git a/spec/requests/admin_payments_request_spec.rb b/spec/requests/admin_payments_request_spec.rb
new file mode 100644
index 0000000..d310e85
--- /dev/null
+++ b/spec/requests/admin_payments_request_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Admin::Payments', type: :request do
+ let(:admin_user) { create(:admin_user) }
+ let(:user) { create(:user) }
+
+ before { sign_in admin_user }
+
+ it 'filters payments by user_id' do
+ matching = create(:payment, user: user, program_year: Date.current.year)
+ other_user = create(:user)
+ create(:payment, user: other_user, program_year: Date.current.year)
+
+ get admin_payments_path, params: { user_id: user.id }
+
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include(matching.transaction_id)
+ expect(response.body).to include(user.email)
+ end
+
+ it 'creates a payment with defaults' do
+ expect do
+ post admin_payments_path, params: {
+ payment: {
+ user_id: user.id,
+ total_amount: '5000',
+ transaction_type: '1',
+ transaction_status: '1',
+ transaction_id: "TX-#{SecureRandom.hex(4)}",
+ transaction_date: Time.current.strftime('%Y%m%d%H%M'),
+ account_type: 'Checking',
+ result_message: 'Success',
+ program_year: Date.current.year
+ }
+ }
+ end.to change(Payment, :count).by(1)
+
+ expect(response).to redirect_to(admin_payment_path(Payment.last))
+ end
+end
diff --git a/spec/requests/admin_program_settings_request_spec.rb b/spec/requests/admin_program_settings_request_spec.rb
new file mode 100644
index 0000000..d13133b
--- /dev/null
+++ b/spec/requests/admin_program_settings_request_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Admin::ProgramSettings', type: :request do
+ let(:admin_user) { create(:admin_user) }
+
+ before { sign_in admin_user }
+
+ it 'requires authentication' do
+ sign_out admin_user
+ get admin_program_settings_path
+ expect(response).to redirect_to(new_admin_user_session_path)
+ end
+
+ it 'lists and creates program settings' do
+ get admin_program_settings_path
+ expect(response).to have_http_status(:success)
+
+ expect do
+ post admin_program_settings_path, params: {
+ program_setting: {
+ program_year: 2099,
+ application_fee: 100,
+ program_fee: 400,
+ active: false,
+ allow_payments: true,
+ program_open: 1.day.ago,
+ program_close: 1.month.from_now,
+ open_instructions: 'Open',
+ close_instructions: 'Closed',
+ payment_instructions: 'Pay'
+ }
+ }
+ end.to change(ProgramSetting, :count).by(1)
+
+ expect(response).to redirect_to(admin_program_setting_path(ProgramSetting.last))
+ end
+end
diff --git a/spec/requests/admin_resources_request_spec.rb b/spec/requests/admin_resources_request_spec.rb
new file mode 100644
index 0000000..be6b904
--- /dev/null
+++ b/spec/requests/admin_resources_request_spec.rb
@@ -0,0 +1,219 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Admin resources', type: :request do
+ let(:admin_user) { create(:admin_user, email: 'coverage-admin@example.com') }
+
+ before { sign_in admin_user }
+
+ describe 'Admin::Users' do
+ let!(:user) { create(:user) }
+
+ it 'lists and shows users' do
+ get admin_users_path
+ expect(response).to have_http_status(:success)
+
+ get admin_users_path, params: { scope: 'zero_balance' }
+ expect(response).to have_http_status(:success)
+
+ get admin_user_path(user)
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include(user.email)
+ end
+ end
+
+ describe 'Admin::AdminUsers' do
+ it 'supports CRUD' do
+ get admin_admin_users_path
+ expect(response).to have_http_status(:success)
+
+ get new_admin_admin_user_path
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include('Password confirmation')
+
+ expect do
+ post admin_admin_users_path, params: {
+ admin_user: {
+ email: 'new-admin@example.com',
+ password: 'password',
+ password_confirmation: 'password'
+ }
+ }
+ end.to change(AdminUser, :count).by(1)
+
+ created = AdminUser.find_by!(email: 'new-admin@example.com')
+ get admin_admin_user_path(created)
+ expect(response).to have_http_status(:success)
+
+ get edit_admin_admin_user_path(created)
+ expect(response).to have_http_status(:success)
+
+ patch admin_admin_user_path(created), params: {
+ admin_user: { email: 'updated-admin@example.com', password: '', password_confirmation: '' }
+ }
+ expect(response).to redirect_to(admin_admin_user_path(created))
+ expect(created.reload.email).to eq('updated-admin@example.com')
+
+ expect do
+ delete admin_admin_user_path(created)
+ end.to change(AdminUser, :count).by(-1)
+ end
+ end
+
+ describe 'Admin::ProgramSettings' do
+ let!(:setting) do
+ create(:program_setting, program_year: 2088, active: false, application_fee: 50, program_fee: 150)
+ end
+
+ it 'supports show edit update destroy' do
+ get admin_program_setting_path(setting)
+ expect(response).to have_http_status(:success)
+
+ get edit_admin_program_setting_path(setting)
+ expect(response).to have_http_status(:success)
+
+ patch admin_program_setting_path(setting), params: {
+ program_setting: { application_fee: 75 }
+ }
+ expect(response).to redirect_to(admin_program_setting_path(setting))
+ expect(setting.reload.application_fee).to eq(75)
+
+ expect do
+ delete admin_program_setting_path(setting)
+ end.to change(ProgramSetting, :count).by(-1)
+ end
+ end
+
+ describe 'Admin::StaticPages' do
+ let!(:static_page) { create(:static_page, location: 'about') }
+
+ it 'updates a static page' do
+ patch admin_static_page_path(static_page), params: {
+ static_page: { location: 'about', message: 'Updated
' }
+ }
+ expect(response).to redirect_to(admin_static_pages_path)
+ end
+ end
+
+ describe 'Admin::Payments' do
+ let(:user) { create(:user) }
+ let!(:payment) { create(:payment, user: user, program_year: Date.current.year) }
+
+ it 'renders edit and updates' do
+ get edit_admin_payment_path(payment)
+ expect(response).to have_http_status(:success)
+
+ patch admin_payment_path(payment), params: {
+ payment: { total_amount: '9999' }
+ }
+ expect(response).to redirect_to(admin_payment_path(payment))
+ expect(payment.reload.total_amount).to eq('9999')
+ end
+
+ it 'renders new form' do
+ get new_admin_payment_path
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include('Transaction type')
+ end
+
+ it 're-renders new on validation failure' do
+ post admin_payments_path, params: {
+ payment: {
+ user_id: user.id,
+ total_amount: '',
+ transaction_id: '',
+ program_year: Date.current.year
+ }
+ }
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+
+ it 're-renders edit on validation failure' do
+ patch admin_payment_path(payment), params: {
+ payment: { total_amount: '' }
+ }
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+
+ it 'filters by created_at range' do
+ get admin_payments_path, params: {
+ created_at_from: 1.day.ago.to_date.to_s,
+ created_at_to: Date.current.to_s
+ }
+ expect(response).to have_http_status(:success)
+ end
+ end
+
+ describe 'Admin::ProgramSettings invalid' do
+ it 're-renders new and edit on validation failure' do
+ post admin_program_settings_path, params: {
+ program_setting: { program_year: nil, application_fee: 1, program_fee: 1 }
+ }
+ expect(response).to have_http_status(:unprocessable_content)
+
+ setting = create(:program_setting, program_year: 2077, active: false)
+ patch admin_program_setting_path(setting), params: {
+ program_setting: { program_year: nil }
+ }
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+
+ it 'renders new form' do
+ get new_admin_program_setting_path
+ expect(response).to have_http_status(:success)
+ end
+ end
+
+ describe 'Admin::AdminUsers invalid' do
+ it 're-renders new and edit on validation failure' do
+ post admin_admin_users_path, params: {
+ admin_user: { email: '', password: 'password', password_confirmation: 'password' }
+ }
+ expect(response).to have_http_status(:unprocessable_content)
+
+ other = create(:admin_user, email: 'other-admin@example.com')
+ patch admin_admin_user_path(other), params: {
+ admin_user: { email: '', password: 'password', password_confirmation: 'password' }
+ }
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+ end
+
+ describe 'Admin::StaticPages invalid' do
+ it 're-renders edit on validation failure' do
+ static_page = create(:static_page, location: 'home')
+ patch admin_static_page_path(static_page), params: {
+ static_page: { location: '' }
+ }
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+ end
+
+ describe 'AdminUsers::Passwords' do
+ it 'renders forgot password form' do
+ sign_out admin_user
+ get new_admin_user_password_path
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include('Forgot your password')
+ end
+ end
+
+ describe 'Admin::DashboardPaymentsQuery' do
+ it 'sorts by user and balance_due' do
+ program = create(:program_setting, :active, program_year: 2090, program_fee: 100, application_fee: 50)
+ users = create_list(:user, 2)
+ users.each_with_index do |user, idx|
+ create(:payment, user: user, program_year: program.program_year, total_amount: (10_000 * (idx + 1)).to_s,
+ transaction_status: '1')
+ end
+
+ result = Admin::DashboardPaymentsQuery.new(params: { sort_column: 'user', sort_order: 'asc' }).call
+ expect(result.user_rows.map { |r| r[:user_email] }).to eq(result.user_rows.map { |r| r[:user_email] }.sort)
+
+ result = Admin::DashboardPaymentsQuery.new(params: { sort_column: 'balance_due', sort_order: 'desc' }).call
+ balances = result.user_rows.map { |r| r[:balance_due].to_f }
+ expect(balances).to eq(balances.sort.reverse)
+ end
+ end
+end
From 0978bd610d7927381d3795009b34dfc67dad8823 Mon Sep 17 00:00:00 2001
From: rsmokeUM
Date: Mon, 27 Jul 2026 11:00:31 -0400
Subject: [PATCH 2/4] Upgrade Devise to 5.0 to clear Rails 8.1 route keyword
deprecations.
Co-authored-by: Cursor
---
Gemfile | 4 +--
Gemfile.lock | 95 ++++++++++++++++++++++++++--------------------------
2 files changed, 49 insertions(+), 50 deletions(-)
diff --git a/Gemfile b/Gemfile
index 39567f0..055b61e 100644
--- a/Gemfile
+++ b/Gemfile
@@ -9,11 +9,11 @@ gem 'base64'
gem 'bootsnap', '>= 1.4.4', require: false
gem 'csv'
gem 'dartsass-rails'
-gem 'devise'
+gem 'devise', '~> 5.0'
gem 'drb'
gem 'importmap-rails'
gem 'jbuilder', '~> 2.7'
-gem 'lsa_tdx_feedback', '~> 1.0', '>= 1.0.4'
+gem 'lsa_tdx_feedback', '~> 2.0.1'
gem 'pagy', '~> 9.3'
gem 'pg'
gem 'puma', '~> 6.0'
diff --git a/Gemfile.lock b/Gemfile.lock
index c4e3822..5b56f8c 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -82,8 +82,8 @@ GEM
rake (>= 0.8.7)
ast (2.4.3)
base64 (0.3.0)
- bcrypt (3.1.20)
- bigdecimal (4.1.0)
+ bcrypt (3.1.22)
+ bigdecimal (4.1.2)
bindex (0.8.1)
bootsnap (1.18.6)
msgpack (~> 1.2)
@@ -101,13 +101,13 @@ GEM
childprocess (5.1.0)
logger (~> 1.5)
coderay (1.1.3)
- concurrent-ruby (1.3.6)
+ concurrent-ruby (1.3.8)
connection_pool (3.0.2)
crack (1.0.0)
bigdecimal
rexml
- crass (1.0.6)
- csv (3.3.5)
+ crass (1.0.7)
+ csv (3.3.6)
dartsass-rails (0.5.1)
railties (>= 6.0.0)
sass-embedded (~> 1.63)
@@ -119,16 +119,16 @@ GEM
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
- devise (4.9.4)
+ devise (5.0.4)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
- railties (>= 4.1.0)
+ railties (>= 7.0)
responders
warden (~> 1.2.3)
diff-lcs (1.6.2)
docile (1.4.1)
drb (2.2.3)
- erb (6.0.2)
+ erb (6.0.6)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
@@ -153,7 +153,7 @@ GEM
fugit (1.13.0)
et-orbi (~> 1.4)
raabro (~> 1.4)
- globalid (1.3.0)
+ globalid (1.4.0)
activesupport (>= 6.1)
google-protobuf (4.31.1)
bigdecimal
@@ -181,14 +181,14 @@ GEM
csv
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
- i18n (1.14.8)
+ i18n (1.15.2)
concurrent-ruby (~> 1.0)
importmap-rails (2.2.0)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.8.2)
- irb (1.17.0)
+ irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
@@ -214,30 +214,30 @@ GEM
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
logger (1.7.0)
- loofah (2.25.1)
+ loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
- lsa_tdx_feedback (1.0.4)
+ lsa_tdx_feedback (2.0.1)
httparty (~> 0.24.0)
rails (>= 6.0)
redis (>= 4.0)
- mail (2.9.0)
+ mail (2.9.1)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
- marcel (1.1.0)
+ marcel (1.2.1)
matrix (0.4.3)
method_source (1.1.0)
mini_mime (1.1.5)
- minitest (6.0.2)
+ minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
msgpack (1.8.0)
- multi_xml (0.8.1)
+ multi_xml (0.9.1)
bigdecimal (>= 3.1, < 5)
- net-imap (0.6.3)
+ net-imap (0.6.6)
date
net-protocol
net-pop (0.1.2)
@@ -247,21 +247,21 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
- nokogiri (1.19.2-aarch64-linux-gnu)
+ nokogiri (1.19.4-aarch64-linux-gnu)
racc (~> 1.4)
- nokogiri (1.19.2-aarch64-linux-musl)
+ nokogiri (1.19.4-aarch64-linux-musl)
racc (~> 1.4)
- nokogiri (1.19.2-arm-linux-gnu)
+ nokogiri (1.19.4-arm-linux-gnu)
racc (~> 1.4)
- nokogiri (1.19.2-arm-linux-musl)
+ nokogiri (1.19.4-arm-linux-musl)
racc (~> 1.4)
- nokogiri (1.19.2-arm64-darwin)
+ nokogiri (1.19.4-arm64-darwin)
racc (~> 1.4)
- nokogiri (1.19.2-x86_64-darwin)
+ nokogiri (1.19.4-x86_64-darwin)
racc (~> 1.4)
- nokogiri (1.19.2-x86_64-linux-gnu)
+ nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4)
- nokogiri (1.19.2-x86_64-linux-musl)
+ nokogiri (1.19.4-x86_64-linux-musl)
racc (~> 1.4)
orm_adapter (0.5.0)
pagy (9.4.0)
@@ -270,26 +270,23 @@ GEM
ast (~> 2.4.1)
racc
pg (1.5.9)
- pp (0.6.3)
+ pp (0.6.4)
prettyprint
prettier_print (1.2.1)
prettyprint (0.2.0)
- prism (1.5.1)
+ prism (1.9.0)
pry (0.15.2)
coderay (~> 1.1)
method_source (~> 1.0)
pry-rails (0.3.11)
pry (>= 0.13.0)
- psych (5.3.1)
- date
- stringio
public_suffix (6.0.2)
puma (6.6.1)
nio4r (~> 2.0)
raabro (1.5.0)
racc (1.8.1)
- rack (3.2.5)
- rack-session (2.1.1)
+ rack (3.2.6)
+ rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
@@ -318,8 +315,8 @@ GEM
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
- rails-html-sanitizer (1.7.0)
- loofah (~> 2.25)
+ rails-html-sanitizer (1.7.1)
+ loofah (~> 2.25, >= 2.25.2)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.1.3)
actionpack (= 8.1.3)
@@ -331,26 +328,29 @@ GEM
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
- rake (13.3.1)
+ rake (13.4.2)
rb-fsevent (0.11.2)
rb-inotify (0.11.1)
ffi (~> 1.0)
- rbs (3.9.5)
+ rbs (4.1.0)
logger
- rdoc (7.2.0)
+ prism (>= 1.6.0)
+ tsort
+ rdoc (8.0.0)
erb
- psych (>= 4.0.0)
+ prism (>= 1.6.0)
+ rbs (>= 4.0.0)
tsort
redis (5.4.1)
redis-client (>= 0.22.0)
- redis-client (0.26.0)
+ redis-client (0.30.1)
connection_pool
regexp_parser (2.10.0)
reline (0.6.3)
io-console (~> 0.5)
- responders (3.1.1)
- actionpack (>= 5.2)
- railties (>= 5.2)
+ responders (3.2.0)
+ actionpack (>= 7.0)
+ railties (>= 7.0)
rexml (3.4.1)
rspec (3.13.1)
rspec-core (~> 3.13.0)
@@ -477,7 +477,6 @@ GEM
sprockets (>= 3.0.0)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
- stringio (3.2.0)
syntax_tree (6.3.0)
prettier_print (>= 1.2.0)
thor (1.5.0)
@@ -505,13 +504,13 @@ GEM
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket (1.2.11)
- websocket-driver (0.8.0)
+ websocket-driver (0.8.2)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
- zeitwerk (2.7.5)
+ zeitwerk (2.8.2)
PLATFORMS
aarch64-linux-gnu
@@ -535,7 +534,7 @@ DEPENDENCIES
dartsass-rails
database_cleaner-active_record
debug
- devise
+ devise (~> 5.0)
drb
factory_bot_rails
faker
@@ -544,7 +543,7 @@ DEPENDENCIES
jbuilder (~> 2.7)
letter_opener_web
listen (~> 3.3)
- lsa_tdx_feedback (~> 1.0, >= 1.0.4)
+ lsa_tdx_feedback (~> 2.0.1)
pagy (~> 9.3)
pg
pry-rails
From ec53a67ccc0c9d82d6bc43c3a880dd2e4d2060d2 Mon Sep 17 00:00:00 2001
From: rsmokeUM
Date: Mon, 27 Jul 2026 14:18:44 -0400
Subject: [PATCH 3/4] Update Interstitial gateway to new Cloudflare payment and
redirect URLs.
QA and production now use separate nelnetApi payment and redirect endpoints ahead of the August 14 cutoff.
Co-authored-by: Cursor
---
README.md | 10 +++++---
app/controllers/payments_controller.rb | 17 ++++++++++----
config/credentials/development.yml.enc | 2 +-
config/credentials/production.yml.enc | 2 +-
config/credentials/staging.yml.enc | 2 +-
config/credentials/test.yml.enc | 2 +-
spec/controllers/payments_controller_spec.rb | 24 ++++++++++++++++----
7 files changed, 43 insertions(+), 16 deletions(-)
diff --git a/README.md b/README.md
index f5dc1b5..dc34fb9 100644
--- a/README.md
+++ b/README.md
@@ -94,16 +94,20 @@ The application will be available at `http://localhost:3000`
Configure your Nelnet payment gateway credentials in Rails credentials:
```ruby
-# config/credentials.yml.enc
+# config/credentials/.yml.enc
NELNET_SERVICE:
DEVELOPMENT_KEY: your_dev_key
- DEVELOPMENT_URL: your_dev_url
+ DEVELOPMENT_URL: https://auth-interstitial-qa-authorize.auth.it.umich.edu/nelnetApi/payment/
+ DEVELOPMENT_REDIRECT_URL: https://auth-interstitial-qa-authorize.auth.it.umich.edu/nelnetApi/redirect/
PRODUCTION_KEY: your_prod_key
- PRODUCTION_URL: your_prod_url
+ PRODUCTION_URL: https://auth-interstitial-authorize.auth.it.umich.edu/nelnetApi/payment/
+ PRODUCTION_REDIRECT_URL: https://auth-interstitial-authorize.auth.it.umich.edu/nelnetApi/redirect/
ORDERTYPE: your_order_type
SERVICE_SELECTOR: QA # or PROD
```
+Development and staging use the QA payment/redirect URLs. Production uses the production URLs.
+
### Admin Access
Create an admin user in the Rails console:
diff --git a/app/controllers/payments_controller.rb b/app/controllers/payments_controller.rb
index 28ac2e3..9889da4 100644
--- a/app/controllers/payments_controller.rb
+++ b/app/controllers/payments_controller.rb
@@ -62,17 +62,26 @@ def generate_hash(current_user, amount = current_program.application_fee.to_i)
end
development_key = service_credentials[:DEVELOPMENT_KEY] || 'dev_key_123'
- development_url = service_credentials[:DEVELOPMENT_URL] || 'https://test-auth-interstitial.dsc.umich.edu'
+ development_url = service_credentials[:DEVELOPMENT_URL] ||
+ 'https://auth-interstitial-qa-authorize.auth.it.umich.edu/nelnetApi/payment/'
+ development_redirect_url = service_credentials[:DEVELOPMENT_REDIRECT_URL] ||
+ 'https://auth-interstitial-qa-authorize.auth.it.umich.edu/nelnetApi/redirect/'
production_key = service_credentials[:PRODUCTION_KEY] || 'prod_key_456'
- production_url = service_credentials[:PRODUCTION_URL] || 'https://auth-interstitial.it.umich.edu'
+ production_url = service_credentials[:PRODUCTION_URL] ||
+ 'https://auth-interstitial-authorize.auth.it.umich.edu/nelnetApi/payment/'
+ production_redirect_url = service_credentials[:PRODUCTION_REDIRECT_URL] ||
+ 'https://auth-interstitial-authorize.auth.it.umich.edu/nelnetApi/redirect/'
connection_hash = {
'test_key' => development_key,
'test_URL' => development_url,
+ 'test_redirect_URL' => development_redirect_url,
'prod_key' => production_key,
'prod_URL' => production_url,
+ 'prod_redirect_URL' => production_redirect_url,
}
- redirect_url = connection_hash[url_to_use]
+ payment_url = connection_hash[url_to_use]
+ redirect_url = connection_hash[url_to_use == 'test_URL' ? 'test_redirect_URL' : 'prod_redirect_URL']
current_epoch_time = DateTime.now.strftime('%Q').to_i
initial_hash = {
orderNumber: user_account,
@@ -95,7 +104,7 @@ def generate_hash(current_user, amount = current_program.application_fee.to_i)
# Final URL
url_for_payment = initial_hash.map { |k, v| "#{k}=#{v}&" unless k == 'key' }.join
- "#{connection_hash[url_to_use]}?#{url_for_payment}hash=#{encoded_hash}"
+ "#{payment_url}?#{url_for_payment}hash=#{encoded_hash}"
end
def url_params
diff --git a/config/credentials/development.yml.enc b/config/credentials/development.yml.enc
index 2250acd..b127fdb 100644
--- a/config/credentials/development.yml.enc
+++ b/config/credentials/development.yml.enc
@@ -1 +1 @@
-+/3CEO2GMIZjXEbpA0SSejsNt+B0eQip3Ou21y5Sec3W6Rs+SLRWhNxVjG6kk1GP0S+gqFu8kg6PclwbcJDfBumM8D7Ry32k2ulxgqgol/+x2KGeqjYHGzmxvdQ6XuF6sOY5tS1Kq6UJ184JY2dogJPP+FytrszIU6Dd+9VnGVPAklN7Ald0S/zGcEhBHEagNVufEt0WqQyP9X8y/AQwqDLZIK+daRXby8PsAv7dpCOWwH0uCHmD1KmwRaoiHDix7T0zi0OyM6LENkWDGeCbWsojzN+aUw0CQnXbULRJ/jp9TEyijAB9O8XWaas5tZ2YtYsWEX2vhAQCS4D6mACX69tbkF/I1fLstqAMd3OaxxRiGRfzjCPtogdSE3cBkKbUt0ejnn/KnSNlUjzYDiGq27SuJ83PT9+Mnx45NHfpAsF/uFwGnXKQdIS9ygb748LwqERi0/EjrqT5HyDevDtYy2C8nOl0+dTa097XxbjrUAEDdeeHlF7CXuPJoa7OGU3EzNzCtgO1aHZ15jXiLTF7/p3Cnb+HUrip1PtfgQAQ/mT8Lk3lSIbnECDp93eFEvKY0C3AEQFmCA4+iu0P187pDpLdvfZ/DbqsNMa3bE3vQlEF/VVXzl1JZFTKv36QSpQrSpZPtI889ZAlTvnMNd5VgoEXLNFLw+dFZfpFPO3C8MwipJhRoB/e/3jcSHDd6vUXCc2DLfU0vuAbIl3J967X08lsom97YoE4NtwxxcukS0KKrMhOIbtGWUpyDVoEHkzFq7kCNNA2SMZS+/tcA79I1l0CA2q976V+AGdYZdF+DxpuJ/DQKbmYxa8rGUfawYZYw8miWWD3fdVGKO7MOkt/ye6UyZtjqVPkHNXBk8UC71vJ+tPWcJzvx9PyQ8V9vlUDI0srEGV4pVckODIOUx+uetpbhzrzLxq9CfgTL27N5P8J0QV0dofUnv22Jpov9JacSOuuRVxOqNFEZU+1wjorHlb+NpDdX9cUkt+J6LjZ5H+SabDlHT4j89pBXz7mY12j7hh5p+S33oQTqCHXYt1dheI8ow0VEWVkMsYcV5dghFLXuFIqJWvRjlg6JpAOpuu4F/IpXDj9n+2MiX1VcqAw/x2NZ0ZFdapxhubXITLB2I34sojNtivIyhTQsDp1ePdFQ+b+bXrsVOoJO8SuoNXVTGlBjMPph/2gvny5E+SsOlB+H0kFrc+vUbpTwdc1F2+bAMGJTtVi+x9hjUc11WjQgIvD6TrLvEfURg98n/ew4ai4MB0EBKKj17f4nvy4lBitd0g1+go4p4AqCJ3pNye0Lanb2Rbz4G75NSXTaFfjmIKi2LGQpygBgWwAQbtedQwzS057dfQ3dXsLoZqn3nyf2v1h8VUJ9yC09/AoStf/4+zMePtukP6ugU+vI/QSi5/bWvCfSJwiG4K0Enrj/FROJjfW4oBfZ5sAobRsBxiwL2K/DHfy2R118xCtcJG1Z2AT5vX0dA2MH4gB4rYPO8pmhqmzYal7hjiK1NCBhXVVpPlnJ/md/c1p0D25oedtotCEfQ6WAZ6+Bl45Pc7a8nZN1BC4XA6NlOlNg4vI7I2qLQS+apGDlP8Q/dmYObabGfvAnemerTwjyUJ8K0U2N93DaWUDdvrp0dIDRhQQTQ8ZCYL2d0xk+kNZJFkQ0X3iJsLPEeSncqabai2fKC/fo6KYtnQHHHWecXZpjzKVp59a9ecNb4wg9O8fl8cjQj/8JtakhvYs33TAtIMvNLl/k+46QwNBGxoTpvsQJwX03GjjVls79fPySVaw0w9Z9Xo=--CaSqhPvkYPJiKrBz--x1qSPBfZ2iLZRrkePB3z4w==
\ No newline at end of file
+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--MGeLkZ4Aad84fqkJ--tYZl1L4eZMpimVqhkxNbUQ==
\ No newline at end of file
diff --git a/config/credentials/production.yml.enc b/config/credentials/production.yml.enc
index 79fc53c..4ad407b 100644
--- a/config/credentials/production.yml.enc
+++ b/config/credentials/production.yml.enc
@@ -1 +1 @@
-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--TZ3PN5aARyWy1LNy--flQoIBJg9tCaJWX7ShfXbw==
\ No newline at end of file
+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--iKfAwZGzNLlTEC7X--Z1yPr2oPRoDT+MQhRKnAZA==
\ No newline at end of file
diff --git a/config/credentials/staging.yml.enc b/config/credentials/staging.yml.enc
index 15d178f..404fd55 100644
--- a/config/credentials/staging.yml.enc
+++ b/config/credentials/staging.yml.enc
@@ -1 +1 @@
-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--BSzg/fUn0V4Ui2WQ--YShmpFbnaLTcgxto1nZoEg==
\ No newline at end of file
+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--JO1OXeVangIZRrhv--R6iLytUsidkAqIW+EUpHwQ==
\ No newline at end of file
diff --git a/config/credentials/test.yml.enc b/config/credentials/test.yml.enc
index c3354f6..80990ac 100644
--- a/config/credentials/test.yml.enc
+++ b/config/credentials/test.yml.enc
@@ -1 +1 @@
-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--nX6PerhKALGNOoMQ--p8T4wsn8DWRM5YLXVqHNHw==
\ No newline at end of file
+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--n1LESmdpMlZwAIYz--nqOf23Jj8KA2Go9J7lMKqw==
\ No newline at end of file
diff --git a/spec/controllers/payments_controller_spec.rb b/spec/controllers/payments_controller_spec.rb
index 993b497..b521027 100644
--- a/spec/controllers/payments_controller_spec.rb
+++ b/spec/controllers/payments_controller_spec.rb
@@ -214,9 +214,11 @@
# Mock credentials
allow(Rails.application.credentials).to receive(:[]).with(:NELNET_SERVICE).and_return({
DEVELOPMENT_KEY: 'dev_key_123',
- DEVELOPMENT_URL: 'https://test-auth-interstitial.dsc.umich.edu',
+ DEVELOPMENT_URL: 'https://auth-interstitial-qa-authorize.auth.it.umich.edu/nelnetApi/payment/',
+ DEVELOPMENT_REDIRECT_URL: 'https://auth-interstitial-qa-authorize.auth.it.umich.edu/nelnetApi/redirect/',
PRODUCTION_KEY: 'prod_key_456',
- PRODUCTION_URL: 'https://auth-interstitial.it.umich.edu',
+ PRODUCTION_URL: 'https://auth-interstitial-authorize.auth.it.umich.edu/nelnetApi/payment/',
+ PRODUCTION_REDIRECT_URL: 'https://auth-interstitial-authorize.auth.it.umich.edu/nelnetApi/redirect/',
ORDERTYPE: 'LSADepartmentofEnglish',
SERVICE_SELECTOR: 'QA',
})
@@ -229,10 +231,15 @@
it 'uses development credentials' do
result = controller.send(:generate_hash, user, amount)
- expect(result).to include('https://test-auth-interstitial.dsc.umich.edu')
+ expect(result).to start_with('https://auth-interstitial-qa-authorize.auth.it.umich.edu/nelnetApi/payment/')
expect(result).to include('hash=')
end
+ it 'uses the QA redirect URL in order parameters' do
+ result = controller.send(:generate_hash, user, amount)
+ expect(result).to include('redirectUrl=https://auth-interstitial-qa-authorize.auth.it.umich.edu/nelnetApi/redirect/')
+ end
+
it 'includes correct payment parameters' do
result = controller.send(:generate_hash, user, amount)
expect(result).to include("amountDue=#{amount * 100}")
@@ -258,8 +265,10 @@
allow(Rails.application.credentials).to receive(:[]).with(:NELNET_SERVICE).and_return({
DEVELOPMENT_KEY: 'dev_key_123',
DEVELOPMENT_URL: 'https://dev.example.com/pay',
+ DEVELOPMENT_REDIRECT_URL: 'https://dev.example.com/redirect',
PRODUCTION_KEY: 'prod_key_456',
- PRODUCTION_URL: 'https://auth-interstitial.it.umich.edu',
+ PRODUCTION_URL: 'https://auth-interstitial-authorize.auth.it.umich.edu/nelnetApi/payment/',
+ PRODUCTION_REDIRECT_URL: 'https://auth-interstitial-authorize.auth.it.umich.edu/nelnetApi/redirect/',
ORDERTYPE: 'SALE',
SERVICE_SELECTOR: 'PROD',
})
@@ -267,7 +276,12 @@
it 'uses production credentials' do
result = controller.send(:generate_hash, user, amount)
- expect(result).to include('https://auth-interstitial.it.umich.edu')
+ expect(result).to start_with('https://auth-interstitial-authorize.auth.it.umich.edu/nelnetApi/payment/')
+ end
+
+ it 'uses the production redirect URL in order parameters' do
+ result = controller.send(:generate_hash, user, amount)
+ expect(result).to include('redirectUrl=https://auth-interstitial-authorize.auth.it.umich.edu/nelnetApi/redirect/')
end
end
From 0a33bfaafcdaaf31aa3ca41e78e35fb6bdf7b39f Mon Sep 17 00:00:00 2001
From: rsmokeUM
Date: Mon, 27 Jul 2026 15:41:53 -0400
Subject: [PATCH 4/4] Fix payment redirect by disabling Turbo and omitting the
secret key from the gateway URL.
Co-authored-by: Cursor
---
app/controllers/payments_controller.rb | 4 ++--
app/views/payments/payment_show.html.erb | 7 +++++--
spec/controllers/payments_controller_spec.rb | 6 ++++++
3 files changed, 13 insertions(+), 4 deletions(-)
diff --git a/app/controllers/payments_controller.rb b/app/controllers/payments_controller.rb
index 9889da4..cfda4db 100644
--- a/app/controllers/payments_controller.rb
+++ b/app/controllers/payments_controller.rb
@@ -102,8 +102,8 @@ def generate_hash(current_user, amount = current_program.application_fee.to_i)
hash_to_be_encoded = initial_hash.values.map(&:to_s).join
encoded_hash = Digest::SHA256.hexdigest hash_to_be_encoded
- # Final URL
- url_for_payment = initial_hash.map { |k, v| "#{k}=#{v}&" unless k == 'key' }.join
+ # Final URL โ omit the secret key from the query string (symbols, not strings)
+ url_for_payment = initial_hash.filter_map { |k, v| "#{k}=#{v}&" unless k == :key }.join
"#{payment_url}?#{url_for_payment}hash=#{encoded_hash}"
end
diff --git a/app/views/payments/payment_show.html.erb b/app/views/payments/payment_show.html.erb
index e7e5466..d6326cc 100644
--- a/app/views/payments/payment_show.html.erb
+++ b/app/views/payments/payment_show.html.erb
@@ -15,7 +15,10 @@
QuikPAY is a registered trademark of Nelnet Business Solutions, Inc. No
credit card information is stored on our servers.
- <%= button_to "Pay application fee", make_payment_path, params: {amount: current_program.application_fee.to_i }, class: 'btn btn-sm btn-success' %>
+ <%= button_to "Pay application fee", make_payment_path,
+ params: { amount: current_program.application_fee.to_i },
+ form: { data: { turbo: false } },
+ class: 'btn btn-sm btn-success' %>
<% else %>
Your Payment Receipts
@@ -48,7 +51,7 @@
<% if current_program.allow_payments && @balance_due.to_i > 0 %>
- <%= form_with url: make_payment_path, local: true do |f| %>
+ <%= form_with url: make_payment_path, data: { turbo: false } do |f| %>
You have a balance of <%= number_to_currency(@balance_due) %>
<%= f.number_field :amount, in: 1..@balance_due, required: true %>
diff --git a/spec/controllers/payments_controller_spec.rb b/spec/controllers/payments_controller_spec.rb
index b521027..c64a4a1 100644
--- a/spec/controllers/payments_controller_spec.rb
+++ b/spec/controllers/payments_controller_spec.rb
@@ -257,6 +257,12 @@
result = controller.send(:generate_hash, user, amount)
expect(result).to match(/hash=[a-f0-9]{64}$/) # SHA256 produces 64-char hex string
end
+
+ it 'does not include the secret key in the payment URL' do
+ result = controller.send(:generate_hash, user, amount)
+ expect(result).not_to include('key=')
+ expect(result).not_to include('dev_key_123')
+ end
end
context 'in production environment' do