Compare commits
4 commits
8da22b3d88
...
91115103ae
| Author | SHA1 | Date | |
|---|---|---|---|
| 91115103ae | |||
| 2a07ddef80 | |||
| 5ab73aada6 | |||
| a2cbac0744 |
8 changed files with 570 additions and 201 deletions
|
|
@ -22,6 +22,10 @@ defmodule Mv.Constants do
|
||||||
|
|
||||||
@boolean_filter_prefix "bf_"
|
@boolean_filter_prefix "bf_"
|
||||||
|
|
||||||
|
@group_filter_prefix "group_"
|
||||||
|
|
||||||
|
@fee_type_filter_prefix "fee_type_"
|
||||||
|
|
||||||
@max_boolean_filters 50
|
@max_boolean_filters 50
|
||||||
|
|
||||||
@max_uuid_length 36
|
@max_uuid_length 36
|
||||||
|
|
@ -70,6 +74,16 @@ defmodule Mv.Constants do
|
||||||
"""
|
"""
|
||||||
def boolean_filter_prefix, do: @boolean_filter_prefix
|
def boolean_filter_prefix, do: @boolean_filter_prefix
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the prefix for group filter URL parameters (e.g. group_<uuid>=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_<uuid>=in|not_in).
|
||||||
|
"""
|
||||||
|
def fee_type_filter_prefix, do: @fee_type_filter_prefix
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the maximum number of boolean custom field filters allowed per request.
|
Returns the maximum number of boolean custom field filters allowed per request.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
- `:groups` - List of groups (for per-group filter rows)
|
- `: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).
|
- `: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).
|
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_custom_fields` - List of boolean custom fields to display
|
||||||
- `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}`
|
- `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}`
|
||||||
- `:id` - Component ID (required)
|
- `:id` - Component ID (required)
|
||||||
|
|
@ -27,11 +29,15 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
## Events
|
## Events
|
||||||
- Sends `{:payment_filter_changed, filter}` to parent when payment filter changes
|
- 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 `{: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
|
- Sends `{:boolean_filter_changed, custom_field_id, filter_value}` to parent when boolean filter changes
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_component
|
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
|
@impl true
|
||||||
def mount(socket) do
|
def mount(socket) do
|
||||||
|
|
@ -47,6 +53,9 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
|> assign(:groups, assigns[:groups] || [])
|
|> assign(:groups, assigns[:groups] || [])
|
||||||
|> assign(:group_filters, assigns[:group_filters] || %{})
|
|> assign(:group_filters, assigns[:group_filters] || %{})
|
||||||
|> assign(:group_filter_prefix, @group_filter_prefix)
|
|> 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_custom_fields, assigns[:boolean_custom_fields] || [])
|
||||||
|> assign(:boolean_filters, assigns[:boolean_filters] || %{})
|
|> assign(:boolean_filters, assigns[:boolean_filters] || %{})
|
||||||
|> assign(:member_count, assigns[:member_count] || 0)
|
|> assign(:member_count, assigns[:member_count] || 0)
|
||||||
|
|
@ -71,6 +80,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
class={[
|
class={[
|
||||||
"gap-2",
|
"gap-2",
|
||||||
(@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) &&
|
active_boolean_filters_count(@boolean_filters) > 0) &&
|
||||||
"btn-active"
|
"btn-active"
|
||||||
]}
|
]}
|
||||||
|
|
@ -86,6 +96,8 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
@cycle_status_filter,
|
@cycle_status_filter,
|
||||||
@groups,
|
@groups,
|
||||||
@group_filters,
|
@group_filters,
|
||||||
|
@fee_types,
|
||||||
|
@fee_type_filters,
|
||||||
@boolean_custom_fields,
|
@boolean_custom_fields,
|
||||||
@boolean_filters
|
@boolean_filters
|
||||||
)}
|
)}
|
||||||
|
|
@ -99,7 +111,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
</.badge>
|
</.badge>
|
||||||
<.badge
|
<.badge
|
||||||
:if={
|
: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
|
active_boolean_filters_count(@boolean_filters) == 0
|
||||||
}
|
}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
|
@ -191,7 +203,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
<div class="max-h-60 overflow-y-auto pr-2">
|
<div class="max-h-60 overflow-y-auto pr-2">
|
||||||
<fieldset
|
<fieldset
|
||||||
:for={group <- @groups}
|
:for={group <- @groups}
|
||||||
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"
|
class="grid grid-cols-[1fr_auto] items-center gap-3 py-2 border-0 border-b border-base-200 last:border-b-0 p-0 m-0 min-w-0"
|
||||||
>
|
>
|
||||||
<legend class="text-sm font-medium col-start-1 float-left w-auto">
|
<legend class="text-sm font-medium col-start-1 float-left w-auto">
|
||||||
{group.name}
|
{group.name}
|
||||||
|
|
@ -250,6 +262,73 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
</div>
|
</div>
|
||||||
</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-0 border-b border-base-200 last:border-b-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 -->
|
<!-- Custom Fields Group -->
|
||||||
<div :if={length(@boolean_custom_fields) > 0} class="mb-2">
|
<div :if={length(@boolean_custom_fields) > 0} class="mb-2">
|
||||||
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
|
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
|
||||||
|
|
@ -258,7 +337,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
<div class="max-h-60 overflow-y-auto pr-2">
|
<div class="max-h-60 overflow-y-auto pr-2">
|
||||||
<fieldset
|
<fieldset
|
||||||
:for={custom_field <- @boolean_custom_fields}
|
:for={custom_field <- @boolean_custom_fields}
|
||||||
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"
|
class="grid grid-cols-[1fr_auto] items-center gap-3 py-2 border-0 border-b border-base-200 last:border-b-0 p-0 m-0 min-w-0"
|
||||||
>
|
>
|
||||||
<legend class="text-sm font-medium col-start-1 float-left w-auto">
|
<legend class="text-sm font-medium col-start-1 float-left w-auto">
|
||||||
{custom_field.name}
|
{custom_field.name}
|
||||||
|
|
@ -356,69 +435,21 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("update_filters", params, socket) do
|
def handle_event("update_filters", params, socket) do
|
||||||
# Parse payment filter
|
payment_filter = parse_payment_filter(params)
|
||||||
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)
|
|
||||||
|
|
||||||
group_filters_parsed =
|
group_filters_parsed =
|
||||||
params
|
parse_prefix_filters(params, @group_filter_prefix, &FilterParams.parse_in_not_in_value/1)
|
||||||
|> 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 boolean custom field filters (including nil values for "all")
|
fee_type_filters_parsed =
|
||||||
custom_boolean_filters_parsed =
|
parse_prefix_filters(params, @fee_type_filter_prefix, &FilterParams.parse_in_not_in_value/1)
|
||||||
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)
|
|
||||||
|
|
||||||
# Update payment filter if changed
|
custom_boolean_filters_parsed = parse_custom_boolean_filters(params)
|
||||||
if payment_filter != socket.assigns.cycle_status_filter do
|
|
||||||
send(self(), {:payment_filter_changed, payment_filter})
|
|
||||||
end
|
|
||||||
|
|
||||||
# Update group filters - send event for each changed group
|
dispatch_payment_filter_change(socket, payment_filter)
|
||||||
current_group_filters = socket.assigns.group_filters
|
dispatch_group_filter_changes(socket, group_filters_parsed)
|
||||||
all_group_ids = MapSet.new(Enum.map(socket.assigns.groups, &to_string(&1.id)))
|
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}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -426,7 +457,16 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
def handle_event("reset_filters", _params, socket) do
|
def handle_event("reset_filters", _params, socket) do
|
||||||
# Send single message to reset all filters at once (performance optimization)
|
# Send single message to reset all filters at once (performance optimization)
|
||||||
# This avoids N×2 load_members() calls when resetting multiple filters
|
# 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
|
# Close dropdown after reset
|
||||||
{:noreply, assign(socket, :open, false)}
|
{:noreply, assign(socket, :open, false)}
|
||||||
|
|
@ -438,18 +478,94 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
defp parse_tri_state("all"), do: nil
|
defp parse_tri_state("all"), do: nil
|
||||||
defp parse_tri_state(_), do: nil
|
defp parse_tri_state(_), do: nil
|
||||||
|
|
||||||
defp parse_group_filter_value("in"), do: :in
|
defp parse_payment_filter(params) do
|
||||||
defp parse_group_filter_value("not_in"), do: :not_in
|
case Map.get(params, "payment_filter") do
|
||||||
defp parse_group_filter_value(_), do: nil
|
"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
|
# Get display label for button
|
||||||
defp button_label(
|
defp button_label(
|
||||||
cycle_status_filter,
|
cycle_status_filter,
|
||||||
groups,
|
groups,
|
||||||
group_filters,
|
group_filters,
|
||||||
|
fee_types,
|
||||||
|
fee_type_filters,
|
||||||
boolean_custom_fields,
|
boolean_custom_fields,
|
||||||
boolean_filters
|
boolean_filters
|
||||||
) do
|
) do
|
||||||
|
active_count =
|
||||||
|
count_active_filter_categories(
|
||||||
|
cycle_status_filter,
|
||||||
|
group_filters,
|
||||||
|
fee_type_filters,
|
||||||
|
boolean_filters
|
||||||
|
)
|
||||||
|
|
||||||
|
if active_count >= 2 do
|
||||||
|
ngettext("%{count} filter active", "%{count} filters active", active_count,
|
||||||
|
count: active_count
|
||||||
|
)
|
||||||
|
else
|
||||||
cond do
|
cond do
|
||||||
cycle_status_filter ->
|
cycle_status_filter ->
|
||||||
payment_filter_label(cycle_status_filter)
|
payment_filter_label(cycle_status_filter)
|
||||||
|
|
@ -457,6 +573,9 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
map_size(group_filters) > 0 ->
|
map_size(group_filters) > 0 ->
|
||||||
group_filters_label(groups, group_filters)
|
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 ->
|
map_size(boolean_filters) > 0 ->
|
||||||
boolean_filter_label(boolean_custom_fields, boolean_filters)
|
boolean_filter_label(boolean_custom_fields, boolean_filters)
|
||||||
|
|
||||||
|
|
@ -464,6 +583,22 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
gettext("Apply filters")
|
gettext("Apply filters")
|
||||||
end
|
end
|
||||||
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,
|
defp group_filters_label(_groups, group_filters) when map_size(group_filters) == 0,
|
||||||
do: gettext("All")
|
do: gettext("All")
|
||||||
|
|
@ -480,6 +615,28 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
truncate_label(label, 30)
|
truncate_label(label, 30)
|
||||||
end
|
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
|
# Get payment filter label
|
||||||
defp payment_filter_label(nil), do: gettext("All")
|
defp payment_filter_label(nil), do: gettext("All")
|
||||||
defp payment_filter_label(:paid), do: gettext("Paid")
|
defp payment_filter_label(:paid), do: gettext("Paid")
|
||||||
|
|
@ -553,37 +710,27 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get CSS classes for per-group filter label based on current state
|
# Shared CSS classes for in/not_in filter labels (groups and fee types)
|
||||||
defp group_filter_label_class(group_filters, group_id, expected_value) do
|
defp in_not_in_filter_label_class(filters, id, expected_value) do
|
||||||
base_classes = "join-item btn btn-sm"
|
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
|
is_active = current_value == expected_value
|
||||||
|
|
||||||
cond do
|
case {expected_value, is_active} do
|
||||||
expected_value == nil ->
|
{_, false} -> "#{base_classes} btn"
|
||||||
if is_active do
|
{nil, true} -> "#{base_classes} btn-active"
|
||||||
"#{base_classes} btn-active"
|
{:in, true} -> "#{base_classes} btn-success btn-active"
|
||||||
else
|
{:not_in, true} -> "#{base_classes} btn-error btn-active"
|
||||||
"#{base_classes} btn"
|
_ -> "#{base_classes} btn-outline"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
expected_value == :in ->
|
defp group_filter_label_class(group_filters, group_id, expected_value) do
|
||||||
if is_active do
|
in_not_in_filter_label_class(group_filters, group_id, expected_value)
|
||||||
"#{base_classes} btn-success btn-active"
|
|
||||||
else
|
|
||||||
"#{base_classes} btn"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
expected_value == :not_in ->
|
defp fee_type_filter_label_class(fee_type_filters, fee_type_id, expected_value) do
|
||||||
if is_active do
|
in_not_in_filter_label_class(fee_type_filters, fee_type_id, expected_value)
|
||||||
"#{base_classes} btn-error btn-active"
|
|
||||||
else
|
|
||||||
"#{base_classes} btn"
|
|
||||||
end
|
|
||||||
|
|
||||||
true ->
|
|
||||||
"#{base_classes} btn-outline"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get CSS classes for boolean filter label based on current state
|
# Get CSS classes for boolean filter label based on current state
|
||||||
|
|
|
||||||
|
|
@ -33,15 +33,19 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
alias Mv.Membership.Member, as: MemberResource
|
alias Mv.Membership.Member, as: MemberResource
|
||||||
|
alias Mv.MembershipFees
|
||||||
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
alias MvWeb.Helpers.DateFormatter
|
alias MvWeb.Helpers.DateFormatter
|
||||||
alias MvWeb.MemberLive.Index.FieldSelection
|
alias MvWeb.MemberLive.Index.FieldSelection
|
||||||
alias MvWeb.MemberLive.Index.FieldVisibility
|
alias MvWeb.MemberLive.Index.FieldVisibility
|
||||||
|
alias MvWeb.MemberLive.Index.FilterParams
|
||||||
alias MvWeb.MemberLive.Index.Formatter
|
alias MvWeb.MemberLive.Index.Formatter
|
||||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||||
|
|
||||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||||
@boolean_filter_prefix Mv.Constants.boolean_filter_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)
|
# Maximum number of boolean custom field filters allowed per request (DoS protection)
|
||||||
@max_boolean_filters Mv.Constants.max_boolean_filters()
|
@max_boolean_filters Mv.Constants.max_boolean_filters()
|
||||||
|
|
@ -89,6 +93,12 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> Ash.Query.sort(name: :asc)
|
|> Ash.Query.sort(name: :asc)
|
||||||
|> Ash.read!(actor: actor)
|
|> 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
|
# Load settings once to avoid N+1 queries
|
||||||
settings =
|
settings =
|
||||||
case Membership.get_settings() do
|
case Membership.get_settings() do
|
||||||
|
|
@ -121,6 +131,8 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> assign(:cycle_status_filter, nil)
|
|> assign(:cycle_status_filter, nil)
|
||||||
|> assign(:group_filters, %{})
|
|> assign(:group_filters, %{})
|
||||||
|> assign(:groups, groups)
|
|> assign(:groups, groups)
|
||||||
|
|> assign(:fee_type_filters, %{})
|
||||||
|
|> assign(:fee_types, fee_types)
|
||||||
|> assign(:boolean_custom_field_filters, %{})
|
|> assign(:boolean_custom_field_filters, %{})
|
||||||
|> assign(:selected_members, MapSet.new())
|
|> assign(:selected_members, MapSet.new())
|
||||||
|> assign(:selected_member_id, nil)
|
|> assign(:selected_member_id, nil)
|
||||||
|
|
@ -211,15 +223,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> update_selection_assigns()
|
|> update_selection_assigns()
|
||||||
|
|
||||||
query_params =
|
query_params =
|
||||||
build_query_params(
|
build_query_params(opts_for_query_params(socket, %{show_current_cycle: new_show_current}))
|
||||||
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
|
|
||||||
)
|
|
||||||
|> maybe_add_field_selection(
|
|> maybe_add_field_selection(
|
||||||
socket.assigns[:user_field_selection],
|
socket.assigns[:user_field_selection],
|
||||||
socket.assigns[:fields_in_url?] || false
|
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
|
# URL sync - push_patch happens synchronously in the event handler
|
||||||
query_params =
|
query_params =
|
||||||
build_query_params(
|
build_query_params(
|
||||||
socket.assigns.query,
|
opts_for_query_params(socket, %{
|
||||||
export_sort_field(socket.assigns.sort_field),
|
sort_field: export_sort_field(socket.assigns.sort_field),
|
||||||
export_sort_order(socket.assigns.sort_order),
|
sort_order: 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
|
|
||||||
)
|
)
|
||||||
|> maybe_add_field_selection(
|
|> maybe_add_field_selection(
|
||||||
socket.assigns[:user_field_selection],
|
socket.assigns[:user_field_selection],
|
||||||
|
|
@ -332,15 +333,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> update_selection_assigns()
|
|> update_selection_assigns()
|
||||||
|
|
||||||
query_params =
|
query_params =
|
||||||
build_query_params(
|
build_query_params(opts_for_query_params(socket, %{query: q}))
|
||||||
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
|
|
||||||
)
|
|
||||||
|> maybe_add_field_selection(
|
|> maybe_add_field_selection(
|
||||||
socket.assigns[:user_field_selection],
|
socket.assigns[:user_field_selection],
|
||||||
socket.assigns[:fields_in_url?] || false
|
socket.assigns[:fields_in_url?] || false
|
||||||
|
|
@ -360,15 +353,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> update_selection_assigns()
|
|> update_selection_assigns()
|
||||||
|
|
||||||
query_params =
|
query_params =
|
||||||
build_query_params(
|
build_query_params(opts_for_query_params(socket, %{cycle_status_filter: filter}))
|
||||||
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
|
|
||||||
)
|
|
||||||
|> maybe_add_field_selection(
|
|> maybe_add_field_selection(
|
||||||
socket.assigns[:user_field_selection],
|
socket.assigns[:user_field_selection],
|
||||||
socket.assigns[:fields_in_url?] || false
|
socket.assigns[:fields_in_url?] || false
|
||||||
|
|
@ -394,15 +379,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> update_selection_assigns()
|
|> update_selection_assigns()
|
||||||
|
|
||||||
query_params =
|
query_params =
|
||||||
build_query_params(
|
build_query_params(opts_for_query_params(socket, %{boolean_filters: updated_filters}))
|
||||||
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
|
|
||||||
)
|
|
||||||
|> maybe_add_field_selection(
|
|> maybe_add_field_selection(
|
||||||
socket.assigns[:user_field_selection],
|
socket.assigns[:user_field_selection],
|
||||||
socket.assigns[:fields_in_url?] || false
|
socket.assigns[:fields_in_url?] || false
|
||||||
|
|
@ -430,15 +407,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> update_selection_assigns()
|
|> update_selection_assigns()
|
||||||
|
|
||||||
query_params =
|
query_params =
|
||||||
build_query_params(
|
build_query_params(opts_for_query_params(socket, %{group_filters: group_filters}))
|
||||||
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
|
|
||||||
)
|
|
||||||
|> maybe_add_field_selection(
|
|> maybe_add_field_selection(
|
||||||
socket.assigns[:user_field_selection],
|
socket.assigns[:user_field_selection],
|
||||||
socket.assigns[:fields_in_url?] || false
|
socket.assigns[:fields_in_url?] || false
|
||||||
|
|
@ -449,32 +418,92 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@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
|
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
|
end
|
||||||
|
|
||||||
def handle_info(
|
def handle_info(
|
||||||
{:reset_all_filters, cycle_status_filter, boolean_filters, group_filters},
|
{:reset_all_filters, cycle_status_filter, boolean_filters, group_filters},
|
||||||
socket
|
socket
|
||||||
) do
|
) 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 =
|
||||||
socket
|
socket
|
||||||
|> assign(:cycle_status_filter, cycle_status_filter)
|
|> assign(:cycle_status_filter, Map.get(opts, :cycle_status_filter))
|
||||||
|> assign(:group_filters, group_filters)
|
|> assign(:group_filters, Map.get(opts, :group_filters, %{}))
|
||||||
|> assign(:boolean_custom_field_filters, boolean_filters)
|
|> assign(:fee_type_filters, Map.get(opts, :fee_type_filters, %{}))
|
||||||
|
|> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{}))
|
||||||
|> load_members()
|
|> load_members()
|
||||||
|> update_selection_assigns()
|
|> update_selection_assigns()
|
||||||
|
|
||||||
query_params =
|
query_params =
|
||||||
build_query_params(
|
build_query_params(opts_for_query_params(socket))
|
||||||
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
|
|
||||||
)
|
|
||||||
|> maybe_add_field_selection(
|
|> maybe_add_field_selection(
|
||||||
socket.assigns[:user_field_selection],
|
socket.assigns[:user_field_selection],
|
||||||
socket.assigns[:fields_in_url?] || false
|
socket.assigns[:fields_in_url?] || false
|
||||||
|
|
@ -598,6 +627,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> maybe_update_sort(params)
|
|> maybe_update_sort(params)
|
||||||
|> maybe_update_cycle_status_filter(params)
|
|> maybe_update_cycle_status_filter(params)
|
||||||
|> maybe_update_group_filters(params)
|
|> maybe_update_group_filters(params)
|
||||||
|
|> maybe_update_fee_type_filters(params)
|
||||||
|> maybe_update_boolean_filters(params)
|
|> maybe_update_boolean_filters(params)
|
||||||
|> maybe_update_show_current_cycle(params)
|
|> maybe_update_show_current_cycle(params)
|
||||||
|> assign(:fields_in_url?, fields_in_url?)
|
|> assign(:fields_in_url?, fields_in_url?)
|
||||||
|
|
@ -646,6 +676,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket.assigns.sort_order,
|
socket.assigns.sort_order,
|
||||||
socket.assigns.cycle_status_filter,
|
socket.assigns.cycle_status_filter,
|
||||||
socket.assigns[:group_filters],
|
socket.assigns[:group_filters],
|
||||||
|
socket.assigns[:fee_type_filters],
|
||||||
socket.assigns.show_current_cycle,
|
socket.assigns.show_current_cycle,
|
||||||
socket.assigns.boolean_custom_field_filters,
|
socket.assigns.boolean_custom_field_filters,
|
||||||
socket.assigns.user_field_selection,
|
socket.assigns.user_field_selection,
|
||||||
|
|
@ -732,15 +763,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|
|
||||||
defp push_field_selection_url(socket) do
|
defp push_field_selection_url(socket) do
|
||||||
query_params =
|
query_params =
|
||||||
build_query_params(
|
build_query_params(opts_for_query_params(socket))
|
||||||
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
|
|
||||||
)
|
|
||||||
|> maybe_add_field_selection(socket.assigns[:user_field_selection], true)
|
|> maybe_add_field_selection(socket.assigns[:user_field_selection], true)
|
||||||
|
|
||||||
new_path = ~p"/members?#{query_params}"
|
new_path = ~p"/members?#{query_params}"
|
||||||
|
|
@ -751,20 +774,34 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
assign(socket, :user_field_selection, selection)
|
assign(socket, :user_field_selection, selection)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp build_query_params(
|
defp build_query_params(opts) when is_map(opts) do
|
||||||
query,
|
base_params = build_base_params(opts.query, opts.sort_field, opts.sort_order)
|
||||||
sort_field,
|
base_params = add_cycle_status_filter(base_params, opts.cycle_status_filter)
|
||||||
sort_order,
|
base_params = add_group_filters(base_params, opts.group_filters || %{})
|
||||||
cycle_status_filter,
|
base_params = add_fee_type_filters(base_params, opts.fee_type_filters || %{})
|
||||||
group_filters,
|
base_params = add_show_current_cycle(base_params, opts.show_current_cycle)
|
||||||
show_current_cycle,
|
add_boolean_filters(base_params, opts.boolean_filters || %{})
|
||||||
boolean_filters
|
end
|
||||||
) do
|
|
||||||
base_params = build_base_params(query, sort_field, sort_order)
|
defp opts_for_query_params(socket, overrides \\ %{}) do
|
||||||
base_params = add_cycle_status_filter(base_params, cycle_status_filter)
|
%{
|
||||||
base_params = add_group_filters(base_params, group_filters)
|
query: socket.assigns.query,
|
||||||
base_params = add_show_current_cycle(base_params, show_current_cycle)
|
sort_field: socket.assigns.sort_field,
|
||||||
add_boolean_filters(base_params, boolean_filters)
|
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
|
end
|
||||||
|
|
||||||
defp compute_final_field_selection(true, url_selection, socket) do
|
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_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)
|
# Use ALL custom fields for sorting (not just show_in_overview subset)
|
||||||
custom_fields_for_sort = socket.assigns.all_custom_fields
|
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
|
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, nil, _show_current), do: members
|
||||||
|
|
||||||
defp apply_cycle_status_filter(members, status, show_current)
|
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)
|
add_group_filter_entry(acc, key, value_str, prefix_len)
|
||||||
end)
|
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
|
end
|
||||||
|
|
||||||
defp maybe_update_group_filters(socket, _), do: socket
|
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
|
defp add_group_filter_entry(acc, key, value_str, prefix_len) do
|
||||||
key_str = to_string(key)
|
key_str = to_string(key)
|
||||||
raw_id = String.slice(key_str, prefix_len, String.length(key_str) - prefix_len)
|
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
|
valid_id? = group_id_str && String.length(group_id_str) <= @max_uuid_length
|
||||||
|
|
||||||
if valid_id? do
|
if valid_id? do
|
||||||
case parse_group_filter_value(value_str) do
|
case FilterParams.parse_in_not_in_value(value_str) do
|
||||||
nil -> acc
|
nil -> acc
|
||||||
value -> Map.put(acc, group_id_str, value)
|
value -> Map.put(acc, group_id_str, value)
|
||||||
end
|
end
|
||||||
|
|
@ -1423,15 +1565,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|
|
||||||
defp normalize_uuid_string(_), do: nil
|
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("paid"), do: :paid
|
||||||
defp determine_cycle_status_filter("unpaid"), do: :unpaid
|
defp determine_cycle_status_filter("unpaid"), do: :unpaid
|
||||||
defp determine_cycle_status_filter(_), do: nil
|
defp determine_cycle_status_filter(_), do: nil
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@
|
||||||
cycle_status_filter={@cycle_status_filter}
|
cycle_status_filter={@cycle_status_filter}
|
||||||
groups={@groups}
|
groups={@groups}
|
||||||
group_filters={@group_filters}
|
group_filters={@group_filters}
|
||||||
|
fee_types={@fee_types}
|
||||||
|
fee_type_filters={@fee_type_filters}
|
||||||
boolean_custom_fields={@boolean_custom_fields}
|
boolean_custom_fields={@boolean_custom_fields}
|
||||||
boolean_filters={@boolean_custom_field_filters}
|
boolean_filters={@boolean_custom_field_filters}
|
||||||
member_count={length(@members)}
|
member_count={length(@members)}
|
||||||
|
|
|
||||||
22
lib/mv_web/live/member_live/index/filter_params.ex
Normal file
22
lib/mv_web/live/member_live/index/filter_params.ex
Normal file
|
|
@ -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
|
||||||
|
|
@ -3232,3 +3232,20 @@ msgstr "Standardart: Wird neuen Mitgliedern zugewiesen; pro Mitglied änderbar."
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle."
|
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."
|
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}"
|
||||||
|
|
|
||||||
|
|
@ -3232,3 +3232,20 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle."
|
msgid "Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle."
|
||||||
msgstr ""
|
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 ""
|
||||||
|
|
|
||||||
|
|
@ -3232,3 +3232,20 @@ msgstr "Default type: Assigned to new members; can be changed per member."
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle."
|
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."
|
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}"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue