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

@ -2,11 +2,12 @@ defmodule MvWeb.Components.PaymentFilterComponent do
@moduledoc """
Provides the PaymentFilter Live-Component.
A dropdown filter for filtering members by payment status (paid/not paid/all).
A dropdown filter for filtering members by cycle payment status (paid/unpaid/all).
Uses DaisyUI dropdown styling and sends filter changes to parent LiveView.
Filter is based on cycle status (last or current cycle, depending on cycle view toggle).
## Props
- `:paid_filter` - Current filter state: `nil` (all), `:paid`, or `:not_paid`
- `:cycle_status_filter` - Current filter state: `nil` (all), `:paid`, or `:unpaid`
- `:id` - Component ID (required)
- `:member_count` - Number of filtered members to display in badge (optional, default: 0)
@ -25,7 +26,7 @@ defmodule MvWeb.Components.PaymentFilterComponent do
socket =
socket
|> assign(:id, assigns.id)
|> assign(:paid_filter, assigns[:paid_filter])
|> assign(:cycle_status_filter, assigns[:cycle_status_filter])
|> assign(:member_count, assigns[:member_count] || 0)
{:ok, socket}
@ -45,7 +46,7 @@ defmodule MvWeb.Components.PaymentFilterComponent do
type="button"
class={[
"btn gap-2",
@paid_filter && "btn-active"
@cycle_status_filter && "btn-active"
]}
phx-click="toggle_dropdown"
phx-target={@myself}
@ -54,8 +55,8 @@ defmodule MvWeb.Components.PaymentFilterComponent do
aria-label={gettext("Filter by payment status")}
>
<.icon name="hero-funnel" class="h-5 w-5" />
<span class="hidden sm:inline">{filter_label(@paid_filter)}</span>
<span :if={@paid_filter} class="badge badge-primary badge-sm">{@member_count}</span>
<span class="hidden sm:inline">{filter_label(@cycle_status_filter)}</span>
<span :if={@cycle_status_filter} class="badge badge-primary badge-sm">{@member_count}</span>
</button>
<ul
@ -70,22 +71,22 @@ defmodule MvWeb.Components.PaymentFilterComponent do
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@paid_filter == nil)}
class={@paid_filter == nil && "active"}
aria-checked={to_string(@cycle_status_filter == nil)}
class={@cycle_status_filter == nil && "active"}
phx-click="select_filter"
phx-value-filter=""
phx-target={@myself}
>
<.icon name="hero-users" class="h-4 w-4" />
{gettext("All payment statuses")}
{gettext("All")}
</button>
</li>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@paid_filter == :paid)}
class={@paid_filter == :paid && "active"}
aria-checked={to_string(@cycle_status_filter == :paid)}
class={@cycle_status_filter == :paid && "active"}
phx-click="select_filter"
phx-value-filter="paid"
phx-target={@myself}
@ -98,14 +99,14 @@ defmodule MvWeb.Components.PaymentFilterComponent do
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@paid_filter == :not_paid)}
class={@paid_filter == :not_paid && "active"}
aria-checked={to_string(@cycle_status_filter == :unpaid)}
class={@cycle_status_filter == :unpaid && "active"}
phx-click="select_filter"
phx-value-filter="not_paid"
phx-value-filter="unpaid"
phx-target={@myself}
>
<.icon name="hero-x-circle" class="h-4 w-4 text-error" />
{gettext("Not paid")}
{gettext("Unpaid")}
</button>
</li>
</ul>
@ -136,11 +137,11 @@ defmodule MvWeb.Components.PaymentFilterComponent do
# Parse filter string to atom
defp parse_filter("paid"), do: :paid
defp parse_filter("not_paid"), do: :not_paid
defp parse_filter("unpaid"), do: :unpaid
defp parse_filter(_), do: nil
# Get display label for current filter
defp filter_label(nil), do: gettext("All payment statuses")
defp filter_label(nil), do: gettext("All")
defp filter_label(:paid), do: gettext("Paid")
defp filter_label(:not_paid), do: gettext("Not paid")
defp filter_label(:unpaid), do: gettext("Unpaid")
end

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}

View file

@ -113,6 +113,41 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
}
end
@doc """
Filters members by cycle status (paid or unpaid).
Returns members that have the specified status in either the last completed cycle
or the current cycle, depending on `show_current`.
## Parameters
- `members` - List of member structs with loaded cycles
- `status` - Cycle status to filter by (`:paid` or `:unpaid`)
- `show_current` - If true, filter by current cycle; if false, filter by last completed cycle
## Returns
List of members with the specified cycle status
## Examples
# Filter unpaid members in last cycle
iex> filter_members_by_cycle_status(members, :unpaid, false)
[%Member{}, ...]
# Filter paid members in current cycle
iex> filter_members_by_cycle_status(members, :paid, true)
[%Member{}, ...]
"""
@spec filter_members_by_cycle_status([Member.t()], :paid | :unpaid, boolean()) :: [Member.t()]
def filter_members_by_cycle_status(members, status, show_current \\ false)
when status in [:paid, :unpaid] do
Enum.filter(members, fn member ->
member_status = get_cycle_status_for_member(member, show_current)
member_status == status
end)
end
@doc """
Filters members by unpaid cycle status.
@ -127,13 +162,14 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
## Returns
List of members with unpaid cycles
## Deprecated
This function is kept for backwards compatibility. Use `filter_members_by_cycle_status/3` instead.
"""
@spec filter_unpaid_members([Member.t()], boolean()) :: [Member.t()]
def filter_unpaid_members(members, show_current \\ false) do
Enum.filter(members, fn member ->
status = get_cycle_status_for_member(member, show_current)
status == :unpaid
end)
filter_members_by_cycle_status(members, :unpaid, show_current)
end
# Private helper function to format status label