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:
parent
098b3b0a2a
commit
c65b3808bf
10 changed files with 490 additions and 247 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# -------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue