Refactor filters to use cycle status instead of paid field

Replace paid_filter with cycle_status_filter that filters based on
membership fee cycle status (last or current cycle). Update
PaymentFilterComponent to use new filter with options All, Paid, Unpaid.
Remove membership fee status filter dropdown. Extend
filter_members_by_cycle_status/3 to support both paid and unpaid filtering.
Update toggle_cycle_view to preserve filter state in URL.
This commit is contained in:
Moritz 2025-12-18 13:10:00 +01:00
parent 098b3b0a2a
commit c65b3808bf
Signed by: moritz
GPG key ID: 1020A035E5DD0824
10 changed files with 490 additions and 247 deletions

View file

@ -98,7 +98,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:query, "")
|> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end)
|> assign(:paid_filter, nil)
|> assign(:cycle_status_filter, nil)
|> assign(:selected_members, MapSet.new())
|> assign(:settings, settings)
|> assign(:custom_fields_visible, custom_fields_visible)
@ -179,27 +179,17 @@ defmodule MvWeb.MemberLive.Index do
socket
|> assign(:show_current_cycle, new_show_current)
|> load_members()
|> update_selection_assigns()
{:noreply, socket}
end
@impl true
def handle_event("filter_unpaid_cycles", %{"filter" => filter_str}, socket) do
filter = determine_membership_fee_filter(filter_str)
socket =
socket
|> assign(:membership_fee_status_filter, filter)
|> load_members()
# Update URL to reflect cycle view change
query_params =
build_query_params(
socket.assigns.query,
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.paid_filter
socket.assigns.cycle_status_filter,
new_show_current
)
|> maybe_add_membership_fee_filter(filter)
new_path = ~p"/members?#{query_params}"
@ -293,8 +283,7 @@ defmodule MvWeb.MemberLive.Index do
q,
existing_field_query,
existing_sort_query,
socket.assigns.paid_filter,
socket.assigns.membership_fee_status_filter,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
)
@ -313,7 +302,7 @@ defmodule MvWeb.MemberLive.Index do
def handle_info({:payment_filter_changed, filter}, socket) do
socket =
socket
|> assign(:paid_filter, filter)
|> assign(:cycle_status_filter, filter)
|> load_members()
|> update_selection_assigns()
@ -324,7 +313,6 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
filter,
socket.assigns.membership_fee_status_filter,
socket.assigns.show_current_cycle
)
@ -439,9 +427,8 @@ defmodule MvWeb.MemberLive.Index do
socket
|> maybe_update_search(params)
|> maybe_update_sort(params)
|> maybe_update_paid_filter(params)
|> maybe_update_cycle_status_filter(params)
|> maybe_update_show_current_cycle(params)
|> maybe_update_membership_fee_status_filter(params)
|> assign(:query, params["query"])
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
@ -550,8 +537,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.query,
field_str,
Atom.to_string(order),
socket.assigns.paid_filter,
socket.assigns.membership_fee_status_filter,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
)
@ -581,8 +567,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.query,
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.paid_filter,
socket.assigns.membership_fee_status_filter,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
)
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
@ -600,14 +585,13 @@ defmodule MvWeb.MemberLive.Index do
end
# Builds URL query parameters map including all filter/sort state.
# Converts paid_filter atom to string for URL.
# Converts cycle_status_filter atom to string for URL.
defp build_query_params(
query,
sort_field,
sort_order,
paid_filter,
membership_fee_filter \\ nil,
show_current_cycle \\ false
cycle_status_filter,
show_current_cycle
) do
field_str =
if is_atom(sort_field) do
@ -629,17 +613,14 @@ defmodule MvWeb.MemberLive.Index do
"sort_order" => order_str
}
# Only add paid_filter to URL if it's set
# Only add cycle_status_filter to URL if it's set
base_params =
case paid_filter do
case cycle_status_filter do
nil -> base_params
:paid -> Map.put(base_params, "paid_filter", "paid")
:not_paid -> Map.put(base_params, "paid_filter", "not_paid")
:paid -> Map.put(base_params, "cycle_status_filter", "paid")
:unpaid -> Map.put(base_params, "cycle_status_filter", "unpaid")
end
# Add membership fee filter if set
base_params = maybe_add_membership_fee_filter(base_params, membership_fee_filter)
# Add show_current_cycle if true
if show_current_cycle do
Map.put(base_params, "show_current_cycle", "true")
@ -685,9 +666,6 @@ defmodule MvWeb.MemberLive.Index do
# Apply the search filter first
query = apply_search_filter(query, search_query)
# Apply payment status filter
query = apply_paid_filter(query, socket.assigns.paid_filter)
# Apply sorting based on current socket state
# For custom fields, we sort after loading
{query, sort_after_load} =
@ -705,11 +683,11 @@ defmodule MvWeb.MemberLive.Index do
# Custom field values are already filtered at the database level in load_custom_field_values/2
# No need for in-memory filtering anymore
# Apply membership fee status filter if set
# Apply cycle status filter if set
members =
apply_membership_fee_status_filter(
apply_cycle_status_filter(
members,
socket.assigns.membership_fee_status_filter,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
)
@ -770,22 +748,17 @@ defmodule MvWeb.MemberLive.Index do
end
end
# Applies payment status filter to the query.
# Applies cycle status filter to members list.
#
# Filter values:
# - nil: No filter, return all members
# - :paid: Only members with paid == true
# - :not_paid: Members with paid == false or paid == nil (not paid)
defp apply_paid_filter(query, nil), do: query
# - :paid: Only members with paid status in the selected cycle (last or current)
# - :unpaid: Only members with unpaid status in the selected cycle (last or current)
defp apply_cycle_status_filter(members, nil, _show_current), do: members
defp apply_paid_filter(query, :paid) do
Ash.Query.filter(query, expr(paid == true))
end
defp apply_paid_filter(query, :not_paid) do
# Include both false and nil as "not paid"
# Note: paid != true doesn't work correctly with NULL values in SQL
Ash.Query.filter(query, expr(paid == false or is_nil(paid)))
defp apply_cycle_status_filter(members, status, show_current)
when status in [:paid, :unpaid] do
MembershipFeeStatus.filter_members_by_cycle_status(members, status, show_current)
end
# Functions to toggle sorting order
@ -1090,28 +1063,27 @@ defmodule MvWeb.MemberLive.Index do
socket
end
# Updates paid filter from URL parameters if present.
# Updates cycle status filter from URL parameters if present.
#
# Validates the filter value, falling back to nil (no filter) if invalid.
defp maybe_update_paid_filter(socket, %{"paid_filter" => filter_str}) do
filter = determine_paid_filter(filter_str)
assign(socket, :paid_filter, filter)
defp maybe_update_cycle_status_filter(socket, %{"cycle_status_filter" => filter_str}) do
filter = determine_cycle_status_filter(filter_str)
assign(socket, :cycle_status_filter, filter)
end
defp maybe_update_paid_filter(socket, _params) do
defp maybe_update_cycle_status_filter(socket, _params) do
# Reset filter if not in URL params
assign(socket, :paid_filter, nil)
assign(socket, :cycle_status_filter, nil)
end
# Determines valid paid filter from URL parameter.
# Determines valid cycle status filter from URL parameter.
#
# SECURITY: This function whitelists allowed filter values. Only "paid" and "not_paid"
# SECURITY: This function whitelists allowed filter values. Only "paid" and "unpaid"
# are accepted - all other input (including malicious strings) falls back to nil.
# This ensures no raw user input is ever passed to Ash.Query.filter/2, following
# Ash's security recommendation to never pass untrusted input directly to filters.
defp determine_paid_filter("paid"), do: :paid
defp determine_paid_filter("not_paid"), do: :not_paid
defp determine_paid_filter(_), do: nil
# This ensures no raw user input is ever passed to filter functions.
defp determine_cycle_status_filter("paid"), do: :paid
defp determine_cycle_status_filter("unpaid"), do: :unpaid
defp determine_cycle_status_filter(_), do: nil
# Updates show_current_cycle from URL parameters if present.
defp maybe_update_show_current_cycle(socket, %{"show_current_cycle" => "true"}) do
@ -1122,45 +1094,6 @@ defmodule MvWeb.MemberLive.Index do
socket
end
# Updates membership fee status filter from URL parameters if present.
defp maybe_update_membership_fee_status_filter(socket, %{"membership_fee_filter" => filter_str}) do
filter = determine_membership_fee_filter(filter_str)
assign(socket, :membership_fee_status_filter, filter)
end
defp maybe_update_membership_fee_status_filter(socket, _params) do
socket
end
# Determines valid membership fee filter from URL parameter.
#
# SECURITY: This function whitelists allowed filter values.
defp determine_membership_fee_filter("unpaid_last"), do: :unpaid_last
defp determine_membership_fee_filter("unpaid_current"), do: :unpaid_current
defp determine_membership_fee_filter(_), do: nil
# Applies membership fee status filter to members list.
defp apply_membership_fee_status_filter(members, nil, _show_current), do: members
defp apply_membership_fee_status_filter(members, :unpaid_last, _show_current) do
MembershipFeeStatus.filter_unpaid_members(members, false)
end
defp apply_membership_fee_status_filter(members, :unpaid_current, _show_current) do
MembershipFeeStatus.filter_unpaid_members(members, true)
end
# Adds membership fee filter to query params if set.
defp maybe_add_membership_fee_filter(params, nil), do: params
defp maybe_add_membership_fee_filter(params, :unpaid_last) do
Map.put(params, "membership_fee_filter", "unpaid_last")
end
defp maybe_add_membership_fee_filter(params, :unpaid_current) do
Map.put(params, "membership_fee_filter", "unpaid_current")
end
# -------------------------------------------------------------
# Helper Functions for Custom Field Values
# -------------------------------------------------------------

View file

@ -39,7 +39,7 @@
<.live_component
module={MvWeb.Components.PaymentFilterComponent}
id="payment-filter"
paid_filter={@paid_filter}
cycle_status_filter={@cycle_status_filter}
member_count={length(@members)}
/>
<div class="flex gap-2 items-center">
@ -60,47 +60,6 @@
<.icon name="hero-arrow-path" class="size-4" />
{if(@show_current_cycle, do: gettext("Current Cycle"), else: gettext("Last Cycle"))}
</button>
<div class="dropdown">
<label tabindex="0" class="btn btn-sm btn-outline">
<.icon name="hero-funnel" class="size-4" />
{gettext("Membership Fee")}
</label>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow"
>
<li>
<button
type="button"
phx-click="filter_unpaid_cycles"
phx-value-filter="unpaid_last"
class={if(@membership_fee_status_filter == :unpaid_last, do: "active", else: "")}
>
{gettext("Unpaid in last cycle")}
</button>
</li>
<li>
<button
type="button"
phx-click="filter_unpaid_cycles"
phx-value-filter="unpaid_current"
class={if(@membership_fee_status_filter == :unpaid_current, do: "active", else: "")}
>
{gettext("Unpaid in current cycle")}
</button>
</li>
<li>
<button
type="button"
phx-click="filter_unpaid_cycles"
phx-value-filter=""
class={if(@membership_fee_status_filter == nil, do: "active", else: "")}
>
{gettext("All")}
</button>
</li>
</ul>
</div>
</div>
<.live_component
module={MvWeb.Components.FieldVisibilityDropdownComponent}