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

@ -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,13 @@ 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_"
@fee_type_filter_prefix "fee_type_"
@impl true
def mount(socket) do
@ -47,6 +51,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 +78,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 +94,8 @@ defmodule MvWeb.Components.MemberFilterComponent do
@cycle_status_filter,
@groups,
@group_filters,
@fee_types,
@fee_type_filters,
@boolean_custom_fields,
@boolean_filters
)}
@ -99,7 +109,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
</.badge>
<.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"
@ -250,6 +260,73 @@ defmodule MvWeb.Components.MemberFilterComponent do
</div>
</div>
<!-- Fee Types: one row per fee type with All / Yes / No (same style as Groups) -->
<div :if={length(@fee_types) > 0} class="mb-4">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
{gettext("Fee types")}
</div>
<div class="max-h-60 overflow-y-auto pr-2">
<fieldset
:for={fee_type <- @fee_types}
class="grid grid-cols-[1fr_auto] items-center gap-3 py-2 border-b border-base-200 last:border-0 border-0 p-0 m-0 min-w-0"
>
<legend class="text-sm font-medium col-start-1 float-left w-auto">
{fee_type.name}
</legend>
<div class="join col-start-2">
<label
class={"#{fee_type_filter_label_class(@fee_type_filters, fee_type.id, nil)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for={"fee-type-filter-#{fee_type.id}-all"}
>
<input
type="radio"
id={"fee-type-filter-#{fee_type.id}-all"}
name={"#{@fee_type_filter_prefix}#{fee_type.id}"}
value="all"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={Map.get(@fee_type_filters, to_string(fee_type.id)) == nil}
/>
<span class="text-xs">{gettext("All")}</span>
</label>
<label
class={"#{fee_type_filter_label_class(@fee_type_filters, fee_type.id, :in)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for={"fee-type-filter-#{fee_type.id}-in"}
aria-label={gettext("Yes")}
title={gettext("Yes")}
>
<input
type="radio"
id={"fee-type-filter-#{fee_type.id}-in"}
name={"#{@fee_type_filter_prefix}#{fee_type.id}"}
value="in"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={Map.get(@fee_type_filters, to_string(fee_type.id)) == :in}
/>
<.icon name="hero-check-circle" class="h-5 w-5" />
<span class="text-xs">{gettext("Yes")}</span>
</label>
<label
class={"#{fee_type_filter_label_class(@fee_type_filters, fee_type.id, :not_in)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for={"fee-type-filter-#{fee_type.id}-not-in"}
aria-label={gettext("No")}
title={gettext("No")}
>
<input
type="radio"
id={"fee-type-filter-#{fee_type.id}-not-in"}
name={"#{@fee_type_filter_prefix}#{fee_type.id}"}
value="not_in"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={Map.get(@fee_type_filters, to_string(fee_type.id)) == :not_in}
/>
<.icon name="hero-x-circle" class="h-5 w-5" />
<span class="text-xs">{gettext("No")}</span>
</label>
</div>
</fieldset>
</div>
</div>
<!-- Custom Fields Group -->
<div :if={length(@boolean_custom_fields) > 0} class="mb-2">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
@ -356,69 +433,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_<uuid>" => "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, &parse_group_filter_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, &parse_fee_type_filter_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 +455,7 @@ 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, nil, %{}, %{}, %{}})
# Close dropdown after reset
{:noreply, assign(socket, :open, false)}
@ -442,11 +471,82 @@ defmodule MvWeb.Components.MemberFilterComponent do
defp parse_group_filter_value("not_in"), do: :not_in
defp parse_group_filter_value(_), do: nil
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(_), 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
@ -457,6 +557,9 @@ defmodule MvWeb.Components.MemberFilterComponent do
map_size(group_filters) > 0 ->
group_filters_label(groups, group_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)
@ -480,6 +583,21 @@ 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)
names =
fee_type_filters
|> Enum.map(fn {fee_type_id_str, _} -> Map.get(fee_types_by_id, fee_type_id_str) end)
|> Enum.reject(&is_nil/1)
label = Enum.join(names, ", ")
truncate_label(label, 30)
end
# Get payment filter label
defp payment_filter_label(nil), do: gettext("All")
defp payment_filter_label(:paid), do: gettext("Paid")
@ -586,6 +704,39 @@ defmodule MvWeb.Components.MemberFilterComponent do
end
end
# Get CSS classes for per-fee-type filter label based on current state
defp fee_type_filter_label_class(fee_type_filters, fee_type_id, expected_value) do
base_classes = "join-item btn btn-sm"
current_value = Map.get(fee_type_filters, to_string(fee_type_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"
end
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"