Add member fee type filter to member list

- Filter by membership fee type in same style as groups (All/Yes/No per type)
- Index: load fee types, fee_type_filters, URL params, apply_fee_type_filters
- MemberFilterComponent: fee types section, events, reset, button label
- Refactor update_filters: extract parse/dispatch helpers to satisfy Credo complexity
This commit is contained in:
Moritz 2026-03-04 20:46:31 +01:00 committed by moritz
parent 312ec19deb
commit a8f12d1c91
3 changed files with 399 additions and 69 deletions

View file

@ -33,6 +33,8 @@ defmodule MvWeb.MemberLive.Index do
alias Mv.Membership
alias Mv.Membership.Member, as: MemberResource
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.DateFormatter
alias MvWeb.MemberLive.Index.FieldSelection
alias MvWeb.MemberLive.Index.FieldVisibility
@ -42,6 +44,7 @@ defmodule MvWeb.MemberLive.Index do
@custom_field_prefix Mv.Constants.custom_field_prefix()
@boolean_filter_prefix Mv.Constants.boolean_filter_prefix()
@group_filter_prefix "group_"
@fee_type_filter_prefix "fee_type_"
# Maximum number of boolean custom field filters allowed per request (DoS protection)
@max_boolean_filters Mv.Constants.max_boolean_filters()
@ -89,6 +92,12 @@ defmodule MvWeb.MemberLive.Index do
|> Ash.Query.sort(name: :asc)
|> Ash.read!(actor: actor)
# Load membership fee types for filter dropdown (sorted by name)
fee_types =
MembershipFeeType
|> Ash.Query.sort(name: :asc)
|> Ash.read!(domain: MembershipFees, actor: actor)
# Load settings once to avoid N+1 queries
settings =
case Membership.get_settings() do
@ -121,6 +130,8 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:cycle_status_filter, nil)
|> assign(:group_filters, %{})
|> assign(:groups, groups)
|> assign(:fee_type_filters, %{})
|> assign(:fee_types, fee_types)
|> assign(:boolean_custom_field_filters, %{})
|> assign(:selected_members, MapSet.new())
|> assign(:selected_member_id, nil)
@ -218,7 +229,8 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.cycle_status_filter,
socket.assigns[:group_filters],
new_show_current,
socket.assigns.boolean_custom_field_filters
socket.assigns.boolean_custom_field_filters,
socket.assigns[:fee_type_filters]
)
|> maybe_add_field_selection(
socket.assigns[:user_field_selection],
@ -300,7 +312,8 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.cycle_status_filter,
socket.assigns[:group_filters],
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
socket.assigns.boolean_custom_field_filters,
socket.assigns[:fee_type_filters]
)
|> maybe_add_field_selection(
socket.assigns[:user_field_selection],
@ -339,7 +352,8 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.cycle_status_filter,
socket.assigns[:group_filters],
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
socket.assigns.boolean_custom_field_filters,
socket.assigns[:fee_type_filters]
)
|> maybe_add_field_selection(
socket.assigns[:user_field_selection],
@ -367,7 +381,8 @@ defmodule MvWeb.MemberLive.Index do
filter,
socket.assigns[:group_filters],
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
socket.assigns.boolean_custom_field_filters,
socket.assigns[:fee_type_filters]
)
|> maybe_add_field_selection(
socket.assigns[:user_field_selection],
@ -401,7 +416,8 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.cycle_status_filter,
socket.assigns[:group_filters],
socket.assigns.show_current_cycle,
updated_filters
updated_filters,
socket.assigns[:fee_type_filters]
)
|> maybe_add_field_selection(
socket.assigns[:user_field_selection],
@ -437,7 +453,45 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.cycle_status_filter,
group_filters,
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
socket.assigns.boolean_custom_field_filters,
socket.assigns[:fee_type_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_info({:fee_type_filter_changed, fee_type_id_str, filter_value}, socket) do
normalized_id = normalize_uuid_string(fee_type_id_str) || fee_type_id_str
fee_type_filters =
if filter_value == nil do
Map.delete(socket.assigns.fee_type_filters, normalized_id)
else
Map.put(socket.assigns.fee_type_filters, normalized_id, filter_value)
end
socket =
socket
|> assign(:fee_type_filters, fee_type_filters)
|> 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,
socket.assigns[:group_filters],
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters,
fee_type_filters
)
|> maybe_add_field_selection(
socket.assigns[:user_field_selection],
@ -450,17 +504,29 @@ defmodule MvWeb.MemberLive.Index do
@impl true
def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do
handle_info({:reset_all_filters, cycle_status_filter, boolean_filters, %{}}, socket)
handle_info({:reset_all_filters, cycle_status_filter, boolean_filters, %{}, %{}}, socket)
end
def handle_info(
{:reset_all_filters, cycle_status_filter, boolean_filters, group_filters},
socket
) do
handle_info(
{:reset_all_filters, cycle_status_filter, boolean_filters, group_filters, %{}},
socket
)
end
def handle_info(
{:reset_all_filters, cycle_status_filter, boolean_filters, group_filters,
fee_type_filters},
socket
) do
socket =
socket
|> assign(:cycle_status_filter, cycle_status_filter)
|> assign(:group_filters, group_filters)
|> assign(:fee_type_filters, fee_type_filters)
|> assign(:boolean_custom_field_filters, boolean_filters)
|> load_members()
|> update_selection_assigns()
@ -473,7 +539,8 @@ defmodule MvWeb.MemberLive.Index do
cycle_status_filter,
socket.assigns[:group_filters],
socket.assigns.show_current_cycle,
boolean_filters
boolean_filters,
fee_type_filters
)
|> maybe_add_field_selection(
socket.assigns[:user_field_selection],
@ -598,6 +665,7 @@ defmodule MvWeb.MemberLive.Index do
|> maybe_update_sort(params)
|> maybe_update_cycle_status_filter(params)
|> maybe_update_group_filters(params)
|> maybe_update_fee_type_filters(params)
|> maybe_update_boolean_filters(params)
|> maybe_update_show_current_cycle(params)
|> assign(:fields_in_url?, fields_in_url?)
@ -646,6 +714,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
socket.assigns[:group_filters],
socket.assigns[:fee_type_filters],
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters,
socket.assigns.user_field_selection,
@ -739,7 +808,8 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.cycle_status_filter,
socket.assigns[:group_filters],
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
socket.assigns.boolean_custom_field_filters,
socket.assigns[:fee_type_filters]
)
|> maybe_add_field_selection(socket.assigns[:user_field_selection], true)
@ -758,15 +828,24 @@ defmodule MvWeb.MemberLive.Index do
cycle_status_filter,
group_filters,
show_current_cycle,
boolean_filters
boolean_filters,
fee_type_filters
) do
base_params = build_base_params(query, sort_field, sort_order)
base_params = add_cycle_status_filter(base_params, cycle_status_filter)
base_params = add_group_filters(base_params, group_filters)
base_params = add_fee_type_filters(base_params, fee_type_filters || %{})
base_params = add_show_current_cycle(base_params, show_current_cycle)
add_boolean_filters(base_params, boolean_filters)
end
defp add_fee_type_filters(params, fee_type_filters) do
Enum.reduce(fee_type_filters, params, fn {fee_type_id_str, value}, acc ->
param_value = if value == :in, do: "in", else: "not_in"
Map.put(acc, "#{@fee_type_filter_prefix}#{fee_type_id_str}", param_value)
end)
end
defp compute_final_field_selection(true, url_selection, socket) do
only_url =
FieldVisibility.selection_from_url_only(url_selection, socket.assigns.all_custom_fields)
@ -941,6 +1020,9 @@ defmodule MvWeb.MemberLive.Index do
query = apply_group_filters(query, socket.assigns[:group_filters], socket.assigns[:groups])
query =
apply_fee_type_filters(query, socket.assigns[:fee_type_filters], socket.assigns[:fee_types])
# Use ALL custom fields for sorting (not just show_in_overview subset)
custom_fields_for_sort = socket.assigns.all_custom_fields
@ -1064,6 +1146,55 @@ defmodule MvWeb.MemberLive.Index do
defp apply_one_group_filter(query, _, _), do: query
# Multiple fee type filters combine with AND: member must match all selected fee type conditions.
defp apply_fee_type_filters(query, fee_type_filters, _fee_types) when fee_type_filters == %{},
do: query
defp apply_fee_type_filters(query, fee_type_filters, fee_types) do
valid_ids =
fee_types
|> Enum.map(&normalize_uuid_string(to_string(&1.id)))
|> Enum.reject(&is_nil/1)
|> MapSet.new()
Enum.reduce(fee_type_filters, query, fn {fee_type_id_str, value}, q ->
member? = MapSet.member?(valid_ids, fee_type_id_str)
if member? do
apply_one_fee_type_filter(q, fee_type_id_str, value)
else
q
end
end)
end
defp apply_one_fee_type_filter(query, _fee_type_id_str, nil), do: query
defp apply_one_fee_type_filter(query, fee_type_id_str, :in) do
case Ecto.UUID.cast(fee_type_id_str) do
{:ok, fee_type_uuid} ->
Ash.Query.filter(query, expr(membership_fee_type_id == ^fee_type_uuid))
_ ->
query
end
end
defp apply_one_fee_type_filter(query, fee_type_id_str, :not_in) do
case Ecto.UUID.cast(fee_type_id_str) do
{:ok, fee_type_uuid} ->
Ash.Query.filter(
query,
expr(membership_fee_type_id != ^fee_type_uuid or is_nil(membership_fee_type_id))
)
_ ->
query
end
end
defp apply_one_fee_type_filter(query, _, _), do: query
defp apply_cycle_status_filter(members, nil, _show_current), do: members
defp apply_cycle_status_filter(members, status, show_current)
@ -1397,6 +1528,52 @@ defmodule MvWeb.MemberLive.Index do
defp maybe_update_group_filters(socket, _), do: socket
defp maybe_update_fee_type_filters(socket, params) when is_map(params) do
prefix = @fee_type_filter_prefix
prefix_len = String.length(prefix)
fee_type_param_entries =
params
|> Enum.filter(fn {key, _} ->
key_str = to_string(key)
String.starts_with?(key_str, prefix)
end)
filters =
Enum.reduce(fee_type_param_entries, %{}, fn {key, value_str}, acc ->
add_fee_type_filter_entry(acc, key, value_str, prefix_len)
end)
assign(socket, :fee_type_filters, filters)
end
defp maybe_update_fee_type_filters(socket, _), do: socket
defp add_fee_type_filter_entry(acc, key, value_str, prefix_len) do
key_str = to_string(key)
raw_id = String.slice(key_str, prefix_len, String.length(key_str) - prefix_len)
fee_type_id_str = normalize_uuid_string(raw_id)
valid_id? = fee_type_id_str && String.length(fee_type_id_str) <= @max_uuid_length
if valid_id? do
case parse_fee_type_filter_value(value_str) do
nil -> acc
value -> Map.put(acc, fee_type_id_str, value)
end
else
acc
end
end
defp parse_fee_type_filter_value("in"), do: :in
defp parse_fee_type_filter_value("not_in"), do: :not_in
defp parse_fee_type_filter_value(val) when is_binary(val) do
parse_fee_type_filter_value(String.trim(val))
end
defp parse_fee_type_filter_value(_), do: nil
defp add_group_filter_entry(acc, key, value_str, prefix_len) do
key_str = to_string(key)
raw_id = String.slice(key_str, prefix_len, String.length(key_str) - prefix_len)