diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 7bb6274..517ad2f 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -22,6 +22,10 @@ defmodule Mv.Constants do @boolean_filter_prefix "bf_" + @group_filter_prefix "group_" + + @fee_type_filter_prefix "fee_type_" + @max_boolean_filters 50 @max_uuid_length 36 @@ -70,6 +74,16 @@ defmodule Mv.Constants do """ def boolean_filter_prefix, do: @boolean_filter_prefix + @doc """ + Returns the prefix for group filter URL parameters (e.g. group_=in|not_in). + """ + def group_filter_prefix, do: @group_filter_prefix + + @doc """ + Returns the prefix for fee type filter URL parameters (e.g. fee_type_=in|not_in). + """ + def fee_type_filter_prefix, do: @fee_type_filter_prefix + @doc """ Returns the maximum number of boolean custom field filters allowed per request. diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex index 4a42bbc..ddd3538 100644 --- a/lib/mv_web/live/components/member_filter_component.ex +++ b/lib/mv_web/live/components/member_filter_component.ex @@ -19,6 +19,8 @@ defmodule MvWeb.Components.MemberFilterComponent do - `:groups` - List of groups (for per-group filter rows) - `:group_filters` - Map of active group filters: `%{group_id => :in | :not_in}` (nil = All for that group). Multiple active filters combine with AND (member must match all selected group conditions). + - `:fee_types` - List of membership fee types (for per-fee-type filter rows) + - `:fee_type_filters` - Map of active fee type filters: `%{fee_type_id => :in | :not_in}` (nil = All). - `:boolean_custom_fields` - List of boolean custom fields to display - `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}` - `:id` - Component ID (required) @@ -27,11 +29,15 @@ defmodule MvWeb.Components.MemberFilterComponent do ## Events - Sends `{:payment_filter_changed, filter}` to parent when payment filter changes - Sends `{:group_filter_changed, group_id_str, value}` to parent when a group filter changes (value: nil | :in | :not_in) + - Sends `{:fee_type_filter_changed, fee_type_id_str, value}` to parent when a fee type filter changes (value: nil | :in | :not_in) - Sends `{:boolean_filter_changed, custom_field_id, filter_value}` to parent when boolean filter changes """ use MvWeb, :live_component - @group_filter_prefix "group_" + alias MvWeb.MemberLive.Index.FilterParams + + @group_filter_prefix Mv.Constants.group_filter_prefix() + @fee_type_filter_prefix Mv.Constants.fee_type_filter_prefix() @impl true def mount(socket) do @@ -47,6 +53,9 @@ defmodule MvWeb.Components.MemberFilterComponent do |> assign(:groups, assigns[:groups] || []) |> assign(:group_filters, assigns[:group_filters] || %{}) |> assign(:group_filter_prefix, @group_filter_prefix) + |> assign(:fee_types, assigns[:fee_types] || []) + |> assign(:fee_type_filters, assigns[:fee_type_filters] || %{}) + |> assign(:fee_type_filter_prefix, @fee_type_filter_prefix) |> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || []) |> assign(:boolean_filters, assigns[:boolean_filters] || %{}) |> assign(:member_count, assigns[:member_count] || 0) @@ -71,6 +80,7 @@ defmodule MvWeb.Components.MemberFilterComponent do class={[ "gap-2", (@cycle_status_filter || map_size(@group_filters) > 0 || + map_size(@fee_type_filters) > 0 || active_boolean_filters_count(@boolean_filters) > 0) && "btn-active" ]} @@ -86,6 +96,8 @@ defmodule MvWeb.Components.MemberFilterComponent do @cycle_status_filter, @groups, @group_filters, + @fee_types, + @fee_type_filters, @boolean_custom_fields, @boolean_filters )} @@ -99,7 +111,7 @@ defmodule MvWeb.Components.MemberFilterComponent do <.badge :if={ - (@cycle_status_filter || map_size(@group_filters) > 0) && + (@cycle_status_filter || map_size(@group_filters) > 0 || map_size(@fee_type_filters) > 0) && active_boolean_filters_count(@boolean_filters) == 0 } variant="primary" @@ -191,7 +203,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
{group.name} @@ -250,6 +262,73 @@ defmodule MvWeb.Components.MemberFilterComponent do
+ +
0} class="mb-4"> +
+ {gettext("Fee types")} +
+
+
+ + {fee_type.name} + +
+ + + +
+
+
+
+
0} class="mb-2">
@@ -258,7 +337,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
{custom_field.name} @@ -356,69 +435,21 @@ defmodule MvWeb.Components.MemberFilterComponent do @impl true def handle_event("update_filters", params, socket) do - # Parse payment filter - payment_filter = - case Map.get(params, "payment_filter") do - "paid" -> :paid - "unpaid" -> :unpaid - _ -> nil - end - - # Parse per-group filters (params keys "group_" => "all"|"in"|"not_in") - prefix_len = String.length(@group_filter_prefix) + payment_filter = parse_payment_filter(params) group_filters_parsed = - params - |> Enum.filter(fn {key, _} -> String.starts_with?(key, @group_filter_prefix) end) - |> Enum.reduce(%{}, fn {key, value_str}, acc -> - group_id_str = String.slice(key, prefix_len, String.length(key) - prefix_len) - filter_value = parse_group_filter_value(value_str) - Map.put(acc, group_id_str, filter_value) - end) + parse_prefix_filters(params, @group_filter_prefix, &FilterParams.parse_in_not_in_value/1) - # Parse boolean custom field filters (including nil values for "all") - custom_boolean_filters_parsed = - params - |> Map.get("custom_boolean", %{}) - |> Enum.reduce(%{}, fn {custom_field_id_str, value_str}, acc -> - filter_value = parse_tri_state(value_str) - Map.put(acc, custom_field_id_str, filter_value) - end) + fee_type_filters_parsed = + parse_prefix_filters(params, @fee_type_filter_prefix, &FilterParams.parse_in_not_in_value/1) - # Update payment filter if changed - if payment_filter != socket.assigns.cycle_status_filter do - send(self(), {:payment_filter_changed, payment_filter}) - end + custom_boolean_filters_parsed = parse_custom_boolean_filters(params) - # Update group filters - send event for each changed group - current_group_filters = socket.assigns.group_filters - all_group_ids = MapSet.new(Enum.map(socket.assigns.groups, &to_string(&1.id))) + dispatch_payment_filter_change(socket, payment_filter) + dispatch_group_filter_changes(socket, group_filters_parsed) + dispatch_fee_type_filter_changes(socket, fee_type_filters_parsed) + dispatch_boolean_filter_changes(socket, custom_boolean_filters_parsed) - Enum.each(group_filters_parsed, fn {group_id_str, new_value} -> - in_set = MapSet.member?(all_group_ids, group_id_str) - current_value = Map.get(current_group_filters, group_id_str) - should_send = in_set and current_value != new_value - - if should_send do - send(self(), {:group_filter_changed, group_id_str, new_value}) - end - end) - - # Update boolean filters - send events for each changed filter - current_filters = socket.assigns.boolean_filters - - # Process all custom field filters from form (including those set to "all"/nil) - # Radio buttons in a group always send a value, so all active filters are in the form - Enum.each(custom_boolean_filters_parsed, fn {custom_field_id_str, new_value} -> - current_value = Map.get(current_filters, custom_field_id_str) - - # Only send event if value actually changed - if current_value != new_value do - send(self(), {:boolean_filter_changed, custom_field_id_str, new_value}) - end - end) - - # Don't close dropdown - allow multiple filter changes {:noreply, socket} end @@ -426,7 +457,16 @@ defmodule MvWeb.Components.MemberFilterComponent do def handle_event("reset_filters", _params, socket) do # Send single message to reset all filters at once (performance optimization) # This avoids N×2 load_members() calls when resetting multiple filters - send(self(), {:reset_all_filters, nil, %{}, %{}}) + send( + self(), + {:reset_all_filters, + %{ + cycle_status_filter: nil, + boolean_filters: %{}, + group_filters: %{}, + fee_type_filters: %{} + }} + ) # Close dropdown after reset {:noreply, assign(socket, :open, false)} @@ -438,33 +478,128 @@ defmodule MvWeb.Components.MemberFilterComponent do defp parse_tri_state("all"), do: nil defp parse_tri_state(_), do: nil - defp parse_group_filter_value("in"), do: :in - defp parse_group_filter_value("not_in"), do: :not_in - defp parse_group_filter_value(_), do: nil + defp parse_payment_filter(params) do + case Map.get(params, "payment_filter") do + "paid" -> :paid + "unpaid" -> :unpaid + _ -> nil + end + end + + defp parse_prefix_filters(params, prefix, parse_value_fn) do + prefix_len = String.length(prefix) + + params + |> Enum.filter(fn {key, _} -> String.starts_with?(key, prefix) end) + |> Enum.reduce(%{}, fn {key, value_str}, acc -> + id_str = String.slice(key, prefix_len, String.length(key) - prefix_len) + Map.put(acc, id_str, parse_value_fn.(value_str)) + end) + end + + defp parse_custom_boolean_filters(params) do + params + |> Map.get("custom_boolean", %{}) + |> Enum.reduce(%{}, fn {id_str, value_str}, acc -> + Map.put(acc, id_str, parse_tri_state(value_str)) + end) + end + + defp dispatch_payment_filter_change(socket, payment_filter) do + if payment_filter != socket.assigns.cycle_status_filter do + send(self(), {:payment_filter_changed, payment_filter}) + end + end + + defp dispatch_group_filter_changes(socket, group_filters_parsed) do + current = socket.assigns.group_filters + valid_ids = MapSet.new(Enum.map(socket.assigns.groups, &to_string(&1.id))) + + Enum.each(group_filters_parsed, fn {id_str, new_value} -> + if MapSet.member?(valid_ids, id_str) and Map.get(current, id_str) != new_value do + send(self(), {:group_filter_changed, id_str, new_value}) + end + end) + end + + defp dispatch_fee_type_filter_changes(socket, fee_type_filters_parsed) do + current = socket.assigns.fee_type_filters + valid_ids = MapSet.new(Enum.map(socket.assigns.fee_types, &to_string(&1.id))) + + Enum.each(fee_type_filters_parsed, fn {id_str, new_value} -> + if MapSet.member?(valid_ids, id_str) and Map.get(current, id_str) != new_value do + send(self(), {:fee_type_filter_changed, id_str, new_value}) + end + end) + end + + defp dispatch_boolean_filter_changes(socket, custom_boolean_filters_parsed) do + current = socket.assigns.boolean_filters + + Enum.each(custom_boolean_filters_parsed, fn {id_str, new_value} -> + if Map.get(current, id_str) != new_value do + send(self(), {:boolean_filter_changed, id_str, new_value}) + end + end) + end # Get display label for button defp button_label( cycle_status_filter, groups, group_filters, + fee_types, + fee_type_filters, boolean_custom_fields, boolean_filters ) do - cond do - cycle_status_filter -> - payment_filter_label(cycle_status_filter) + active_count = + count_active_filter_categories( + cycle_status_filter, + group_filters, + fee_type_filters, + boolean_filters + ) - map_size(group_filters) > 0 -> - group_filters_label(groups, group_filters) + if active_count >= 2 do + ngettext("%{count} filter active", "%{count} filters active", active_count, + count: active_count + ) + else + cond do + cycle_status_filter -> + payment_filter_label(cycle_status_filter) - map_size(boolean_filters) > 0 -> - boolean_filter_label(boolean_custom_fields, boolean_filters) + map_size(group_filters) > 0 -> + group_filters_label(groups, group_filters) - true -> - gettext("Apply filters") + map_size(fee_type_filters) > 0 -> + fee_type_filters_label(fee_types, fee_type_filters) + + map_size(boolean_filters) > 0 -> + boolean_filter_label(boolean_custom_fields, boolean_filters) + + true -> + gettext("Apply filters") + end end end + defp count_active_filter_categories( + cycle_status_filter, + group_filters, + fee_type_filters, + boolean_filters + ) do + [ + cycle_status_filter, + map_size(group_filters) > 0, + map_size(fee_type_filters) > 0, + map_size(boolean_filters) > 0 + ] + |> Enum.count(& &1) + end + defp group_filters_label(_groups, group_filters) when map_size(group_filters) == 0, do: gettext("All") @@ -480,6 +615,28 @@ defmodule MvWeb.Components.MemberFilterComponent do truncate_label(label, 30) end + defp fee_type_filters_label(_fee_types, fee_type_filters) when map_size(fee_type_filters) == 0, + do: gettext("All") + + defp fee_type_filters_label(fee_types, fee_type_filters) do + fee_types_by_id = Map.new(fee_types, fn ft -> {to_string(ft.id), ft.name} end) + + parts = + fee_type_filters + |> Enum.map(fn {fee_type_id_str, value} -> + fee_type_filter_part(Map.get(fee_types_by_id, fee_type_id_str), value) + end) + |> Enum.reject(&is_nil/1) + + label = Enum.join(parts, ", ") + truncate_label(label, 30) + end + + defp fee_type_filter_part(nil, _value), do: nil + + defp fee_type_filter_part(name, :not_in), do: gettext("without %{name}", name: name) + defp fee_type_filter_part(name, _), do: name + # Get payment filter label defp payment_filter_label(nil), do: gettext("All") defp payment_filter_label(:paid), do: gettext("Paid") @@ -553,39 +710,29 @@ defmodule MvWeb.Components.MemberFilterComponent do end end - # Get CSS classes for per-group filter label based on current state - defp group_filter_label_class(group_filters, group_id, expected_value) do + # Shared CSS classes for in/not_in filter labels (groups and fee types) + defp in_not_in_filter_label_class(filters, id, expected_value) do base_classes = "join-item btn btn-sm" - current_value = Map.get(group_filters, to_string(group_id)) + current_value = Map.get(filters, to_string(id)) is_active = current_value == expected_value - cond do - expected_value == nil -> - if is_active do - "#{base_classes} btn-active" - else - "#{base_classes} btn" - end - - expected_value == :in -> - if is_active do - "#{base_classes} btn-success btn-active" - else - "#{base_classes} btn" - end - - expected_value == :not_in -> - if is_active do - "#{base_classes} btn-error btn-active" - else - "#{base_classes} btn" - end - - true -> - "#{base_classes} btn-outline" + case {expected_value, is_active} do + {_, false} -> "#{base_classes} btn" + {nil, true} -> "#{base_classes} btn-active" + {:in, true} -> "#{base_classes} btn-success btn-active" + {:not_in, true} -> "#{base_classes} btn-error btn-active" + _ -> "#{base_classes} btn-outline" end end + defp group_filter_label_class(group_filters, group_id, expected_value) do + in_not_in_filter_label_class(group_filters, group_id, expected_value) + end + + defp fee_type_filter_label_class(fee_type_filters, fee_type_id, expected_value) do + in_not_in_filter_label_class(fee_type_filters, fee_type_id, expected_value) + end + # Get CSS classes for boolean filter label based on current state defp boolean_filter_label_class(boolean_filters, custom_field_id, expected_value) do base_classes = "join-item btn btn-sm" diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 6cf532d..e2e037d 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -33,15 +33,19 @@ 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 + alias MvWeb.MemberLive.Index.FilterParams alias MvWeb.MemberLive.Index.Formatter alias MvWeb.MemberLive.Index.MembershipFeeStatus @custom_field_prefix Mv.Constants.custom_field_prefix() @boolean_filter_prefix Mv.Constants.boolean_filter_prefix() - @group_filter_prefix "group_" + @group_filter_prefix Mv.Constants.group_filter_prefix() + @fee_type_filter_prefix Mv.Constants.fee_type_filter_prefix() # Maximum number of boolean custom field filters allowed per request (DoS protection) @max_boolean_filters Mv.Constants.max_boolean_filters() @@ -89,6 +93,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 +131,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) @@ -211,15 +223,7 @@ defmodule MvWeb.MemberLive.Index do |> 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], - new_show_current, - socket.assigns.boolean_custom_field_filters - ) + build_query_params(opts_for_query_params(socket, %{show_current_cycle: new_show_current})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false @@ -294,13 +298,10 @@ defmodule MvWeb.MemberLive.Index do # URL sync - push_patch happens synchronously in the event handler query_params = build_query_params( - socket.assigns.query, - export_sort_field(socket.assigns.sort_field), - export_sort_order(socket.assigns.sort_order), - socket.assigns.cycle_status_filter, - socket.assigns[:group_filters], - socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters + opts_for_query_params(socket, %{ + sort_field: export_sort_field(socket.assigns.sort_field), + sort_order: export_sort_order(socket.assigns.sort_order) + }) ) |> maybe_add_field_selection( socket.assigns[:user_field_selection], @@ -332,15 +333,7 @@ defmodule MvWeb.MemberLive.Index do |> update_selection_assigns() query_params = - build_query_params( - q, - 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 - ) + build_query_params(opts_for_query_params(socket, %{query: q})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false @@ -360,15 +353,7 @@ defmodule MvWeb.MemberLive.Index do |> update_selection_assigns() query_params = - build_query_params( - socket.assigns.query, - socket.assigns.sort_field, - socket.assigns.sort_order, - filter, - socket.assigns[:group_filters], - socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters - ) + build_query_params(opts_for_query_params(socket, %{cycle_status_filter: filter})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false @@ -394,15 +379,7 @@ defmodule MvWeb.MemberLive.Index do |> 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, - updated_filters - ) + build_query_params(opts_for_query_params(socket, %{boolean_filters: updated_filters})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false @@ -430,15 +407,7 @@ defmodule MvWeb.MemberLive.Index do |> 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_filters, - socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters - ) + build_query_params(opts_for_query_params(socket, %{group_filters: group_filters})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false @@ -449,32 +418,92 @@ defmodule MvWeb.MemberLive.Index do 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(opts_for_query_params(socket, %{fee_type_filters: 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 + + # Backward compatibility: tuple form delegates to map form 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: cycle_status_filter, + boolean_filters: boolean_filters, + group_filters: %{}, + fee_type_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: cycle_status_filter, + boolean_filters: boolean_filters, + group_filters: group_filters, + fee_type_filters: %{} + }}, + socket + ) + end + + def handle_info( + {:reset_all_filters, cycle_status_filter, boolean_filters, group_filters, + fee_type_filters}, + socket + ) do + handle_info( + {:reset_all_filters, + %{ + cycle_status_filter: cycle_status_filter, + boolean_filters: boolean_filters, + group_filters: group_filters, + fee_type_filters: fee_type_filters + }}, + socket + ) + end + + def handle_info({:reset_all_filters, %{} = opts}, socket) do socket = socket - |> assign(:cycle_status_filter, cycle_status_filter) - |> assign(:group_filters, group_filters) - |> assign(:boolean_custom_field_filters, boolean_filters) + |> assign(:cycle_status_filter, Map.get(opts, :cycle_status_filter)) + |> assign(:group_filters, Map.get(opts, :group_filters, %{})) + |> assign(:fee_type_filters, Map.get(opts, :fee_type_filters, %{})) + |> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{})) |> load_members() |> update_selection_assigns() query_params = - build_query_params( - socket.assigns.query, - socket.assigns.sort_field, - socket.assigns.sort_order, - cycle_status_filter, - socket.assigns[:group_filters], - socket.assigns.show_current_cycle, - boolean_filters - ) + build_query_params(opts_for_query_params(socket)) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false @@ -598,6 +627,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 +676,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, @@ -732,15 +763,7 @@ defmodule MvWeb.MemberLive.Index do defp push_field_selection_url(socket) do 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 - ) + build_query_params(opts_for_query_params(socket)) |> maybe_add_field_selection(socket.assigns[:user_field_selection], true) new_path = ~p"/members?#{query_params}" @@ -751,20 +774,34 @@ defmodule MvWeb.MemberLive.Index do assign(socket, :user_field_selection, selection) end - defp build_query_params( - query, - sort_field, - sort_order, - cycle_status_filter, - group_filters, - show_current_cycle, - boolean_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_show_current_cycle(base_params, show_current_cycle) - add_boolean_filters(base_params, boolean_filters) + defp build_query_params(opts) when is_map(opts) do + base_params = build_base_params(opts.query, opts.sort_field, opts.sort_order) + base_params = add_cycle_status_filter(base_params, opts.cycle_status_filter) + base_params = add_group_filters(base_params, opts.group_filters || %{}) + base_params = add_fee_type_filters(base_params, opts.fee_type_filters || %{}) + base_params = add_show_current_cycle(base_params, opts.show_current_cycle) + add_boolean_filters(base_params, opts.boolean_filters || %{}) + end + + defp opts_for_query_params(socket, overrides \\ %{}) do + %{ + query: socket.assigns.query, + sort_field: socket.assigns.sort_field, + sort_order: socket.assigns.sort_order, + cycle_status_filter: socket.assigns.cycle_status_filter, + group_filters: socket.assigns[:group_filters] || %{}, + show_current_cycle: socket.assigns.show_current_cycle, + boolean_filters: socket.assigns.boolean_custom_field_filters || %{}, + fee_type_filters: socket.assigns[:fee_type_filters] || %{} + } + |> Map.merge(overrides) + 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 @@ -941,6 +978,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 +1104,57 @@ defmodule MvWeb.MemberLive.Index do defp apply_one_group_filter(query, _, _), do: query + # Fee type filters: :in selections combine with OR (member has any of the selected types); + # :not_in selections combine with AND (member must not have type A and not have type B). + 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() + + {in_id_strs, not_in_filters} = + fee_type_filters + |> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end) + |> Enum.split_with(fn {_, value} -> value == :in end) + + in_uuids = + in_id_strs + |> Enum.map(fn {id_str, _} -> id_str end) + |> Enum.map(&Ecto.UUID.cast/1) + |> Enum.filter(&match?({:ok, _}, &1)) + |> Enum.map(fn {:ok, uuid} -> uuid end) + + query = + if in_uuids == [] do + query + else + Ash.Query.filter(query, expr(membership_fee_type_id in ^in_uuids)) + end + + Enum.reduce(not_in_filters, query, fn {fee_type_id_str, _}, q -> + apply_one_fee_type_filter(q, fee_type_id_str, :not_in) + 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) @@ -1392,11 +1483,62 @@ defmodule MvWeb.MemberLive.Index do add_group_filter_entry(acc, key, value_str, prefix_len) end) - assign(socket, :group_filters, filters) + valid_group_ids = + socket.assigns.groups + |> Enum.map(&normalize_uuid_string(to_string(&1.id))) + |> Enum.reject(&is_nil/1) + |> MapSet.new() + |> MapSet.to_list() + + assign(socket, :group_filters, Map.take(filters, valid_group_ids)) end 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) + + valid_fee_type_ids = + socket.assigns.fee_types + |> Enum.map(&normalize_uuid_string(to_string(&1.id))) + |> Enum.reject(&is_nil/1) + |> MapSet.new() + |> MapSet.to_list() + + assign(socket, :fee_type_filters, Map.take(filters, valid_fee_type_ids)) + 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 FilterParams.parse_in_not_in_value(value_str) do + nil -> acc + value -> Map.put(acc, fee_type_id_str, value) + end + else + acc + end + end + 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) @@ -1404,7 +1546,7 @@ defmodule MvWeb.MemberLive.Index do valid_id? = group_id_str && String.length(group_id_str) <= @max_uuid_length if valid_id? do - case parse_group_filter_value(value_str) do + case FilterParams.parse_in_not_in_value(value_str) do nil -> acc value -> Map.put(acc, group_id_str, value) end @@ -1423,15 +1565,6 @@ defmodule MvWeb.MemberLive.Index do defp normalize_uuid_string(_), do: nil - defp parse_group_filter_value("in"), do: :in - defp parse_group_filter_value("not_in"), do: :not_in - - defp parse_group_filter_value(val) when is_binary(val) do - parse_group_filter_value(String.trim(val)) - end - - defp parse_group_filter_value(_), 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 diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 84167c4..b35d426 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -50,6 +50,8 @@ cycle_status_filter={@cycle_status_filter} groups={@groups} group_filters={@group_filters} + fee_types={@fee_types} + fee_type_filters={@fee_type_filters} boolean_custom_fields={@boolean_custom_fields} boolean_filters={@boolean_custom_field_filters} member_count={length(@members)} diff --git a/lib/mv_web/live/member_live/index/filter_params.ex b/lib/mv_web/live/member_live/index/filter_params.ex new file mode 100644 index 0000000..9b5e800 --- /dev/null +++ b/lib/mv_web/live/member_live/index/filter_params.ex @@ -0,0 +1,22 @@ +defmodule MvWeb.MemberLive.Index.FilterParams do + @moduledoc """ + Shared parsing helpers for member list filter URL/params (in/not_in style). + Used by MemberLive.Index and MemberFilterComponent to avoid duplication and recursion bugs. + """ + @doc """ + Parses a value for group or fee-type filter params. + Returns `:in`, `:not_in`, or `nil`. Handles trimmed strings; no recursion. + """ + def parse_in_not_in_value("in"), do: :in + def parse_in_not_in_value("not_in"), do: :not_in + + def parse_in_not_in_value(val) when is_binary(val) do + case String.trim(val) do + "in" -> :in + "not_in" -> :not_in + _ -> nil + end + end + + def parse_in_not_in_value(_), do: nil +end diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 982798d..ec26b39 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3232,3 +3232,20 @@ msgstr "Standardart: Wird neuen Mitgliedern zugewiesen; pro Mitglied änderbar." #, elixir-autogen, elixir-format msgid "Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle." msgstr "Beitrittszyklus einbeziehen: Aktiv = Zahlung ab Beitrittszyklus; inaktiv = ab dem nächsten vollen Zyklus." + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Fee types" +msgstr "Beitragsarten" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "%{count} filter active" +msgid_plural "%{count} filters active" +msgstr[0] "%{count} Filter aktiv" +msgstr[1] "%{count} Filter aktiv" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "without %{name}" +msgstr "ohne %{name}" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 5b6ef4c..d5efdd8 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3232,3 +3232,20 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle." msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Fee types" +msgstr "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "%{count} filter active" +msgid_plural "%{count} filters active" +msgstr[0] "" +msgstr[1] "" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "without %{name}" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index a566be0..9a76cc8 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -3232,3 +3232,20 @@ msgstr "Default type: Assigned to new members; can be changed per member." #, elixir-autogen, elixir-format msgid "Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle." msgstr "Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle." + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "Fee types" +msgstr "Fee types" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "%{count} filter active" +msgid_plural "%{count} filters active" +msgstr[0] "%{count} filter active" +msgstr[1] "%{count} filters active" + +#: lib/mv_web/live/components/member_filter_component.ex +#, elixir-autogen, elixir-format +msgid "without %{name}" +msgstr "without %{name}"