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 %> + +
    + + + + + + + + + + + <% @admin_users.each do |admin_user| %> + + + + + + + <% end %> + +
    IDEmailCreated At
    <%= 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?' } %> +
    +
    + +<%= 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?) %> +
    + + + + + + + + + + + + <% @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' %> + + + + + + + + <% end %> + +
    <%= admin_sort_link('User', 'user', current_column: @sort_column, current_order: @sort_order, page: @pagy&.page) %>Total PaidProgram Cost<%= admin_sort_link('Balance Due', 'balance_due', current_column: @sort_column, current_order: @sort_order, page: @pagy&.page) %>Status
    + <%= 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 %>
    +
    + +

    + <% 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? %> +
    + + + + + + + + + + + + + + <% @recent_payments.each do |payment| %> + + + + + + + + + + <% end %> + +
    UserTransaction IDAmountStatusAccount TypeDateActions
    + <%= 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) %>
    +
    +

    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 %> + +
    + + + + + + + + + + + + + + + + <% @payments.each do |payment| %> + + + + + + + + + + + + <% end %> + +
    UserTypeStatusTransaction IDTotal AmountTransaction DateAccount TypeProgram Year
    <%= 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) %> +
    +
    + +<%= 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_settings.each do |setting| %> + + + + + + + + + <% end %> + +
    Program YearApplication FeeProgram FeeActiveAllow Payments
    <%= 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?' } %> +
    +
    + +<%= 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' %> + +
    + + + + + + + + + <% @static_pages.each do |static_page| %> + + + + + <% end %> + +
    Location
    <%= static_page.location %><%= link_to 'Edit', edit_admin_static_page_path(static_page) %>
    +
    + +<%= 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' %> + + + +<%= 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 %> + +
    + + + + + + + + + + + + + + + + <% @users.each do |user| %> + + + + + + + + + + + + <% end %> + +
    IDEmailBalance DueCurrent Sign In AtSign In CountLast Sign In AtLast Sign In IPCreated At
    <%= 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) %>
    +
    + +<%= 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

    +
    + + + + + + + + + + + + <% @payments.each do |payment| %> + + + + + + + + <% end %> + +
    Transaction IDAmountStatusProgram Year
    <%= 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) %>
    +
    +<%= 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 %> + +<% 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 @@ + 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