diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index fb3ae12d9..d460b1ac5 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -6,10 +6,35 @@ module Admin def index authorize User - @users = policy_scope(User) + scope = policy_scope(User) .left_joins(family: :subscription) .includes(family: :subscription) - .order(Arel.sql("subscriptions.trial_ends_at IS NULL, subscriptions.trial_ends_at ASC, users.email ASC")) + + scope = scope.where(role: params[:role]) if params[:role].present? + scope = apply_trial_filter(scope) if params[:trial_status].present? + + @users = scope.order( + Arel.sql( + "CASE " \ + "WHEN subscriptions.status = 'trialing' THEN 0 " \ + "WHEN subscriptions.id IS NULL THEN 1 " \ + "ELSE 2 END, " \ + "subscriptions.trial_ends_at ASC NULLS LAST, users.email ASC" + ) + ) + + family_ids = @users.map(&:family_id).uniq + @accounts_count_by_family = Account.where(family_id: family_ids).group(:family_id).count + @entries_count_by_family = Entry.joins(:account).where(accounts: { family_id: family_ids }).group("accounts.family_id").count + + user_ids = @users.map(&:id).uniq + @last_login_by_user = Session.where(user_id: user_ids).group(:user_id).maximum(:created_at) + @sessions_count_by_user = Session.where(user_id: user_ids).group(:user_id).count + + @trials_expiring_in_7_days = Subscription + .where(status: :trialing) + .where(trial_ends_at: Time.current..7.days.from_now) + .count end def update @@ -37,5 +62,17 @@ module Admin def user_params params.require(:user).permit(:role) end + + def apply_trial_filter(scope) + case params[:trial_status] + when "expiring_soon" + scope.where(subscriptions: { status: :trialing }) + .where(subscriptions: { trial_ends_at: Time.current..7.days.from_now }) + when "trialing" + scope.where(subscriptions: { status: :trialing }) + else + scope + end + end end end diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb index b4b8781e6..234fc9a3a 100644 --- a/app/views/admin/users/index.html.erb +++ b/app/views/admin/users/index.html.erb @@ -1,57 +1,120 @@ <%= content_for :page_title, t(".title") %> -
-

<%= t(".description") %>

+
+
+

<%= t(".description") %>

+
- <%= settings_section title: t(".section_title") do %> -
- <% @users.each do |user| %> -
-
-
- <%= user.initials %> -
-
-

<%= user.display_name %>

-

<%= user.email %>

+ +
+ <%= form_with url: admin_users_path, method: :get, class: "flex gap-4 items-end flex-wrap" do |f| %> +
+ <%= f.label :role, t(".filters.role"), class: "block text-sm font-medium text-primary mb-1" %> + <%= f.select :role, + options_for_select( + [[t(".filters.role_all"), ""], [t(".roles.guest"), "guest"], [t(".roles.member", default: "Member"), "member"], [t(".roles.admin"), "admin"], [t(".roles.super_admin"), "super_admin"]], + params[:role]

<%= t(".trial_ends_at") %>: - <%= user.family.subscription&.trial_ends_at&.to_fs(:long) || t(".not_available") %> + <%= user.family&.subscription&.trial_ends_at&.to_fs(:long) || t(".not_available") %>

-
-
-
- <% if user.id == Current.user.id %> - <%= t(".you") %> - - <%= t(".roles.#{user.role}", default: user.role.humanize) %> - - <% else %> - <%= form_with model: [:admin, user], method: :patch, class: "flex items-center gap-2" do |form| %> - <%= form.select :role, - options_for_select([ - [t(".roles.guest"), "guest"], - [t(".roles.member", default: "Member"), "member"], - [t(".roles.admin"), "admin"], - [t(".roles.super_admin"), "super_admin"] - ], user.role), - {}, - class: "text-sm rounded-lg border-primary bg-container text-primary px-2 py-1", - onchange: "this.form.requestSubmit()" %> - <% end %> +
+ <%= f.label :trial_status, t(".filters.trial_status"), class: "block text-sm font-medium text-primary mb-1" %> + <%= f.select :trial_status, + options_for_select( + [[t(".filters.trial_all"), ""], [t(".filters.trial_expiring_soon"), "expiring_soon"], [t(".filters.trial_trialing"), "trialing"]], + params[:trial_status] + ), + {}, + class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container-inset text-primary w-full" %> +
+ <%= render DS::Button.new(variant: :primary, size: :md, type: "submit", text: t(".filters.submit"), class: "md:w-auto w-full justify-center") %> + <% end %> +
+ + +
+
+
+ <%= icon "calendar-clock", class: "w-5 h-5 text-secondary" %> +

<%= t(".summary.trials_expiring_7_days") %>

+
+

<%= @trials_expiring_in_7_days %>

+
+
+ + +
+

<%= t(".section_title") %>

+
+ <% if @users.any? %> + + + + + + + + + + + + <% @users.each do |user| %> + + + + + + + <% end %> - + +
<%= t(".table.user") %><%= t(".table.trial_ends_at") %><%= t(".table.family_accounts") %><%= t(".table.family_transactions") %><%= t(".table.role") %>
+
+
+ <%= user.initials %> +
+
+

<%= user.display_name %>

+

<%= user.email %>

+

+ <%= t(".table.last_login") %>: <%= @last_login_by_user[user.id]&.to_fs(:long) || t(".table.never") %> + <%= t(".table.session_count") %>: <%= number_with_delimiter(@sessions_count_by_user[user.id] || 0) %> +

+
+
+
+ <%= user.family.subscription&.trial_ends_at&.to_fs(:long) || t(".not_available") %> + + <%= number_with_delimiter(@accounts_count_by_family[user.family_id] || 0) %> + + <%= number_with_delimiter(@entries_count_by_family[user.family_id] || 0) %> + + <% if user.id == Current.user.id %> + <%= t(".you") %> + <% else %> + <%= form_with model: [:admin, user], method: :patch, class: "flex items-center justify-end gap-2" do |form| %> + <%= form.select :role, + options_for_select([ + [t(".roles.guest"), "guest"], + [t(".roles.member", default: "Member"), "member"], + [t(".roles.admin"), "admin"], + [t(".roles.super_admin"), "super_admin"] + ], user.role), + {}, + class: "text-sm rounded-lg border border-primary bg-container text-primary px-2 py-1", + onchange: "this.form.requestSubmit()" %> + <% end %> + <% end %> +
+ <% else %> +
+ <%= icon "users", class: "w-12 h-12 mx-auto text-secondary mb-3" %> +

<%= t(".no_users") %>

<% end %>
- - <% if @users.empty? %> -
- <%= icon "users", class: "w-12 h-12 mx-auto text-secondary mb-3" %> -

<%= t(".no_users") %>

-
- <% end %> - <% end %> +
<%= settings_section title: t(".role_descriptions_title"), collapsible: true, open: false do %>
diff --git a/config/locales/views/admin/users/en.yml b/config/locales/views/admin/users/en.yml index fc6c160f1..7eb7102a7 100644 --- a/config/locales/views/admin/users/en.yml +++ b/config/locales/views/admin/users/en.yml @@ -10,6 +10,25 @@ en: trial_ends_at: "Trial ends" not_available: "n/a" no_users: "No users found." + filters: + role: "Role" + role_all: "All roles" + trial_status: "Trial status" + trial_all: "All" + trial_expiring_soon: "Expiring in 7 days" + trial_trialing: "On trial" + submit: "Filter" + summary: + trials_expiring_7_days: "Trials expiring in next 7 days" + table: + user: "User" + trial_ends_at: "Trial ends" + family_accounts: "Family accounts" + family_transactions: "Family transactions" + last_login: "Last login" + session_count: "Session count" + never: "Never" + role: "Role" role_descriptions_title: "Role Descriptions" roles: guest: "Guest" diff --git a/test/controllers/admin/users_controller_test.rb b/test/controllers/admin/users_controller_test.rb index a02f670f3..8cbca0bbc 100644 --- a/test/controllers/admin/users_controller_test.rb +++ b/test/controllers/admin/users_controller_test.rb @@ -23,6 +23,6 @@ class Admin::UsersControllerTest < ActionDispatch::IntegrationTest get admin_users_url assert_response :success - assert_match(/Trial ends:\s*n\/a/, response.body) + assert_match(/n\/a/, response.body, "Page should show n/a for users without trial end date") end end