feat: add groups to member overview

This commit is contained in:
Simon 2026-02-13 09:28:16 +01:00
parent 82e908a7e4
commit dce4b2cf33
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
6 changed files with 459 additions and 13 deletions

View file

@ -85,6 +85,12 @@ defmodule MvWeb.MemberLive.Index do
|> Enum.filter(&(&1.value_type == :boolean))
|> Enum.sort_by(& &1.name, :asc)
# Load groups for filter dropdown (sorted by name)
groups =
Mv.Membership.Group
|> Ash.Query.sort(name: :asc)
|> Ash.read!(actor: actor)
# Load settings once to avoid N+1 queries
settings =
case Membership.get_settings() do
@ -115,6 +121,8 @@ defmodule MvWeb.MemberLive.Index do
|> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end)
|> assign(:cycle_status_filter, nil)
|> assign(:group_filter, nil)
|> assign(:groups, groups)
|> assign(:boolean_custom_field_filters, %{})
|> assign(:selected_members, MapSet.new())
|> assign(:settings, settings)
@ -242,6 +250,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
socket.assigns[:group_filter],
new_show_current,
socket.assigns.boolean_custom_field_filters
)
@ -255,6 +264,35 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, push_patch(socket, to: new_path, replace: true)}
end
@impl true
def handle_event("group_filter_changed", %{"group_filter" => group_id_param}, socket) do
group_filter = normalize_group_filter(group_id_param, socket.assigns.groups)
socket =
socket
|> assign(:group_filter, group_filter)
|> load_members()
|> update_selection_assigns()
query_params =
build_query_params(
socket.assigns.query,
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
group_filter,
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
|> maybe_add_field_selection(
socket.assigns[:user_field_selection],
socket.assigns[:fields_in_url?] || false
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)}
end
@impl true
def handle_event("copy_emails", _params, socket) do
selected_ids = socket.assigns.selected_members
@ -352,6 +390,7 @@ defmodule MvWeb.MemberLive.Index do
export_sort_field(socket.assigns.sort_field),
export_sort_order(socket.assigns.sort_order),
socket.assigns.cycle_status_filter,
socket.assigns[:group_filter],
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
@ -377,6 +416,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
socket.assigns[:group_filter],
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
@ -404,6 +444,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
filter,
socket.assigns[:group_filter],
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
@ -437,6 +478,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
socket.assigns[:group_filter],
socket.assigns.show_current_cycle,
updated_filters
)
@ -454,6 +496,7 @@ defmodule MvWeb.MemberLive.Index do
socket =
socket
|> assign(:cycle_status_filter, cycle_status_filter)
|> assign(:group_filter, nil)
|> assign(:boolean_custom_field_filters, boolean_filters)
|> load_members()
|> update_selection_assigns()
@ -464,6 +507,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
cycle_status_filter,
socket.assigns[:group_filter],
socket.assigns.show_current_cycle,
boolean_filters
)
@ -600,6 +644,7 @@ defmodule MvWeb.MemberLive.Index do
|> maybe_update_search(params)
|> maybe_update_sort(params)
|> maybe_update_cycle_status_filter(params)
|> maybe_update_group_filter(params)
|> maybe_update_boolean_filters(params)
|> maybe_update_show_current_cycle(params)
|> assign(:fields_in_url?, fields_in_url?)
@ -633,6 +678,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
socket.assigns[:group_filter],
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters,
socket.assigns.user_field_selection,
@ -726,6 +772,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
socket.assigns[:group_filter],
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
@ -744,6 +791,7 @@ defmodule MvWeb.MemberLive.Index do
sort_field,
sort_order,
cycle_status_filter,
group_filter,
show_current_cycle,
boolean_filters
) do
@ -774,6 +822,13 @@ defmodule MvWeb.MemberLive.Index do
:unpaid -> Map.put(base_params, "cycle_status_filter", "unpaid")
end
base_params =
if group_filter && group_filter != "" do
Map.put(base_params, "group_filter", to_string(group_filter))
else
base_params
end
base_params =
if show_current_cycle do
Map.put(base_params, "show_current_cycle", "true")
@ -823,8 +878,14 @@ defmodule MvWeb.MemberLive.Index do
query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle)
# Load groups for each member (id, name, slug only)
query =
Ash.Query.load(query, groups: [:id, :name, :slug])
query = apply_search_filter(query, search_query)
query = apply_group_filter(query, socket.assigns[:group_filter])
# Use ALL custom fields for sorting (not just show_in_overview subset)
custom_fields_for_sort = socket.assigns.all_custom_fields
@ -860,7 +921,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.all_custom_fields
)
# Sort in memory if needed (custom fields only; computed fields are blocked)
# Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked)
members =
if sort_after_load and
socket.assigns.sort_field not in FieldVisibility.computed_member_fields() do
@ -902,6 +963,13 @@ defmodule MvWeb.MemberLive.Index do
end
end
defp apply_group_filter(query, nil), do: query
defp apply_group_filter(query, ""), do: query
defp apply_group_filter(query, group_id) when is_binary(group_id) do
Ash.Query.filter(query, expr(exists(groups, id == ^group_id)))
end
defp apply_cycle_status_filter(members, nil, _show_current), do: members
defp apply_cycle_status_filter(members, status, show_current)
@ -937,6 +1005,10 @@ defmodule MvWeb.MemberLive.Index do
defp apply_sort_to_query(query, field, order) do
cond do
# Groups sort -> after load (in memory)
field in [:groups, "groups"] ->
{query, true}
# Custom field sort -> after load
custom_field_sort?(field) ->
{query, true}
@ -976,11 +1048,12 @@ defmodule MvWeb.MemberLive.Index do
defp valid_sort_field_db_or_custom?(field) when is_atom(field) do
non_sortable_fields = [:notes]
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
field in valid_fields or custom_field_sort?(field)
field in valid_fields or custom_field_sort?(field) or field == :groups
end
defp valid_sort_field_db_or_custom?(field) when is_binary(field) do
custom_field_sort?(field) or
field == "groups" or
custom_field_sort?(field) or
((atom = safe_member_field_atom_only(field)) != nil and valid_sort_field_db_or_custom?(atom))
end
@ -1024,14 +1097,37 @@ defmodule MvWeb.MemberLive.Index do
end
defp sort_members_in_memory(members, field, order, custom_fields) do
custom_field_id_str = extract_custom_field_id(field)
cond do
field in [:groups, "groups"] ->
sort_members_by_groups(members, order)
case custom_field_id_str do
nil -> members
id_str -> sort_members_by_custom_field(members, id_str, order, custom_fields)
true ->
custom_field_id_str = extract_custom_field_id(field)
case custom_field_id_str do
nil -> members
id_str -> sort_members_by_custom_field(members, id_str, order, custom_fields)
end
end
end
defp sort_members_by_groups(members, order) do
# Members with groups first, then by first group name alphabetically
first_group_name = fn member ->
groups = member.groups || []
names = Enum.map(groups, & &1.name) |> Enum.sort()
List.first(names)
end
members
|> Enum.sort_by(fn member ->
name = first_group_name.(member)
# Nil (no groups) sorts last in asc, first in desc
{name == nil, name || ""}
end)
|> then(fn list -> if order == :desc, do: Enum.reverse(list), else: list end)
end
defp sort_members_by_custom_field(members, id_str, order, custom_fields) do
custom_field = find_custom_field_by_id(custom_fields, id_str)
@ -1126,11 +1222,12 @@ defmodule MvWeb.MemberLive.Index do
defp determine_field(default, _), do: default
defp determine_field_after_computed_check(default, sf) when is_binary(sf) do
if custom_field_sort?(sf) do
if valid_sort_field?(sf), do: sf, else: default
else
atom = safe_member_field_atom_only(sf)
if atom != nil and valid_sort_field?(atom), do: atom, else: default
cond do
sf == "groups" -> :groups
custom_field_sort?(sf) -> if valid_sort_field?(sf), do: sf, else: default
true ->
atom = safe_member_field_atom_only(sf)
if atom != nil and valid_sort_field?(atom), do: atom, else: default
end
end
@ -1160,6 +1257,32 @@ defmodule MvWeb.MemberLive.Index do
defp maybe_update_cycle_status_filter(socket, _params),
do: assign(socket, :cycle_status_filter, nil)
defp maybe_update_group_filter(socket, %{"group_filter" => group_id_param}) do
group_filter = normalize_group_filter(group_id_param, socket.assigns.groups)
assign(socket, :group_filter, group_filter)
end
defp maybe_update_group_filter(socket, _params), do: socket
defp normalize_group_filter("", _groups), do: nil
defp normalize_group_filter(nil, _groups), do: nil
defp normalize_group_filter(group_id_param, groups) when is_binary(group_id_param) do
case Ecto.UUID.cast(group_id_param) do
{:ok, _uuid} ->
if Enum.any?(groups, fn g -> to_string(g.id) == group_id_param end) do
group_id_param
else
nil
end
:error ->
nil
end
end
defp normalize_group_filter(_, _), do: nil
defp determine_cycle_status_filter("paid"), do: :paid
defp determine_cycle_status_filter("unpaid"), do: :unpaid
defp determine_cycle_status_filter(_), do: nil

View file

@ -52,6 +52,22 @@
query={@query}
placeholder={gettext("Search...")}
/>
<form id="group-filter-form" phx-change="group_filter_changed" class="contents">
<select
name="group_filter"
class="select select-bordered select-sm max-w-xs"
aria-label={gettext("Filter by group")}
>
<option value="" selected={@group_filter == nil}>
{gettext("All groups")}
</option>
<%= for group <- @groups do %>
<option value={group.id} selected={@group_filter == to_string(group.id)}>
{group.name}
</option>
<% end %>
</select>
</form>
<.live_component
module={MvWeb.Components.MemberFilterComponent}
id="member-filter"
@ -310,6 +326,34 @@
<span class="badge badge-ghost">{gettext("No cycle")}</span>
<% end %>
</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_groups}
field={:groups}
label={gettext("Groups")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
<%= for group <- (member.groups || []) do %>
<span
class="badge badge-outline badge-primary"
role="status"
aria-label={gettext("Member of group %{name}", name: group.name)}
>
{group.name}
</span>
<% end %>
<%= if (member.groups || []) == [] do %>
<span class="text-base-content/50">—</span>
<% end %>
</:col>
<:action :let={member}>
<div class="sr-only">
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>