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

View file

@ -144,11 +144,9 @@ msgstr "Notizen"
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr "Bezahlt"
@ -188,7 +186,6 @@ msgid "Street"
msgstr "Straße"
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@ -201,7 +198,6 @@ msgid "Show Member"
msgstr "Mitglied anzeigen"
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
@ -788,7 +784,7 @@ msgid "This field cannot be empty"
msgstr "Dieses Feld darf nicht leer bleiben"
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "All"
msgstr "Alle"
@ -798,11 +794,6 @@ msgstr "Alle"
msgid "Filter by payment status"
msgstr "Nach Zahlungsstatus filtern"
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Not paid"
msgstr "Nicht bezahlt"
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Payment filter"
@ -1205,6 +1196,7 @@ msgstr "Zeitraum"
msgid "Total Contributions"
msgstr "Gesamtbeiträge"
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
@ -1570,7 +1562,6 @@ msgid "Mark as unpaid"
msgstr "Als unbezahlt markieren"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Membership Fee"
@ -1716,16 +1707,6 @@ msgstr "Zum letzten abgeschlossenen Zyklus wechseln"
msgid "Type"
msgstr "Art"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Unpaid in current cycle"
msgstr "Unbezahlt im aktuellen Zyklus"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Unpaid in last cycle"
msgstr "Unbezahlt im letzten Zyklus"
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Use this form to manage membership fee types in your database."
@ -1837,6 +1818,11 @@ msgstr ""
msgid "You are about to delete all %{count} cycles for this member."
msgstr ""
#~ #: lib/mv_web/live/components/payment_filter_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "All payment statuses"
#~ msgstr "Jeder Zahlungs-Zustand"
#~ #: lib/mv_web/live/custom_field_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Auto-generated identifier (immutable)"
@ -1889,6 +1875,11 @@ msgstr ""
#~ msgid "New Custom field"
#~ msgstr "Benutzerdefiniertes Feld speichern"
#~ #: lib/mv_web/live/components/payment_filter_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Not paid"
#~ msgstr "Nicht bezahlt"
#~ #: lib/mv_web/live/user_live/form.ex
#~ #: lib/mv_web/live/user_live/show.ex
#~ #, elixir-autogen, elixir-format
@ -1916,6 +1907,16 @@ msgstr ""
#~ msgid "This data is for demonstration purposes only (mockup)."
#~ msgstr "Diese Daten dienen nur zu Demonstrationszwecken (Mockup)."
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Unpaid in current cycle"
#~ msgstr "Unbezahlt im aktuellen Zyklus"
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Unpaid in last cycle"
#~ msgstr "Unbezahlt im letzten Zyklus"
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "View Example Member"

View file

@ -145,11 +145,9 @@ msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr ""
@ -189,7 +187,6 @@ msgid "Street"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@ -202,7 +199,6 @@ msgid "Show Member"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
@ -789,7 +785,7 @@ msgid "This field cannot be empty"
msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "All"
msgstr ""
@ -799,11 +795,6 @@ msgstr ""
msgid "Filter by payment status"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Not paid"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Payment filter"
@ -1206,6 +1197,7 @@ msgstr ""
msgid "Total Contributions"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
@ -1571,7 +1563,6 @@ msgid "Mark as unpaid"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Membership Fee"
@ -1717,16 +1708,6 @@ msgstr ""
msgid "Type"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Unpaid in current cycle"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Unpaid in last cycle"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Use this form to manage membership fee types in your database."

View file

@ -145,11 +145,9 @@ msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr ""
@ -189,7 +187,6 @@ msgid "Street"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@ -202,7 +199,6 @@ msgid "Show Member"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
@ -789,7 +785,7 @@ msgid "This field cannot be empty"
msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "All"
msgstr ""
@ -799,11 +795,6 @@ msgstr ""
msgid "Filter by payment status"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Not paid"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "Payment filter"
@ -1206,6 +1197,7 @@ msgstr ""
msgid "Total Contributions"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
@ -1571,7 +1563,6 @@ msgid "Mark as unpaid"
msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership Fee"
@ -1717,16 +1708,6 @@ msgstr ""
msgid "Type"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Unpaid in current cycle"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Unpaid in last cycle"
msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Use this form to manage membership fee types in your database."
@ -1838,6 +1819,11 @@ msgstr ""
msgid "You are about to delete all %{count} cycles for this member."
msgstr ""
#~ #: lib/mv_web/live/components/payment_filter_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "All payment statuses"
#~ msgstr ""
#~ #: lib/mv_web/live/custom_field_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Auto-generated identifier (immutable)"
@ -1912,6 +1898,11 @@ msgstr ""
#~ msgid "New Custom field"
#~ msgstr ""
#~ #: lib/mv_web/live/components/payment_filter_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Not paid"
#~ msgstr ""
#~ #: lib/mv_web/live/user_live/show.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Not set"
@ -1938,6 +1929,16 @@ msgstr ""
#~ msgid "This data is for demonstration purposes only (mockup)."
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Unpaid in current cycle"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Unpaid in last cycle"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "View Example Member"

View file

@ -3,7 +3,7 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
Unit tests for the PaymentFilterComponent.
Tests cover:
- Rendering in all 3 filter states (nil, :paid, :not_paid)
- Rendering in all 3 filter states (nil, :paid, :unpaid)
- Event emission when selecting options
- ARIA attributes for accessibility
- Dropdown open/close behavior
@ -25,15 +25,15 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
test "renders with paid filter active", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
{:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
# Should show badge when filter is active
assert has_element?(view, "#payment-filter .badge")
end
test "renders with not_paid filter active", %{conn: conn} do
test "renders with unpaid filter active", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=not_paid")
{:ok, view, _html} = live(conn, "/members?cycle_status_filter=unpaid")
# Should show badge when filter is active
assert has_element?(view, "#payment-filter .badge")
@ -82,7 +82,7 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
describe "filter selection" do
test "selecting 'All' clears the filter and updates URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
{:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
# Open dropdown
view
@ -94,7 +94,7 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
|> element("#payment-filter button[phx-value-filter='']")
|> render_click()
# URL should not contain paid_filter param - wait for patch
# URL should not contain cycle_status_filter param - wait for patch
assert_patch(view)
end
@ -112,12 +112,12 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
|> element("#payment-filter button[phx-value-filter='paid']")
|> render_click()
# Wait for patch and check URL contains paid_filter=paid
# Wait for patch and check URL contains cycle_status_filter=paid
path = assert_patch(view)
assert path =~ "paid_filter=paid"
assert path =~ "cycle_status_filter=paid"
end
test "selecting 'Not paid' sets the filter and updates URL", %{conn: conn} do
test "selecting 'Unpaid' sets the filter and updates URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
@ -126,14 +126,14 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Select "Not paid" option
# Select "Unpaid" option
view
|> element("#payment-filter button[phx-value-filter='not_paid']")
|> element("#payment-filter button[phx-value-filter='unpaid']")
|> render_click()
# Wait for patch and check URL contains paid_filter=not_paid
# Wait for patch and check URL contains cycle_status_filter=unpaid
path = assert_patch(view)
assert path =~ "paid_filter=not_paid"
assert path =~ "cycle_status_filter=unpaid"
end
end
@ -166,7 +166,7 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
test "has aria-checked on selected option", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
{:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
# Open dropdown
view

View file

@ -235,4 +235,134 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
assert result == nil
end
end
describe "filter_members_by_cycle_status/3" do
test "filters paid members in last cycle" do
fee_type = create_fee_type(%{interval: :yearly})
today = Date.utc_today()
last_year_start = Date.new!(today.year - 1, 1, 1)
# Member with paid last cycle
member1 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member1, fee_type, %{cycle_start: last_year_start, status: :paid})
# Member with unpaid last cycle
member2 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{cycle_start: last_year_start, status: :unpaid})
members =
[member1, member2]
|> Enum.map(fn m ->
m
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type)
end)
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :paid, false)
assert length(filtered) == 1
assert List.first(filtered).id == member1.id
end
test "filters unpaid members in last cycle" do
fee_type = create_fee_type(%{interval: :yearly})
today = Date.utc_today()
last_year_start = Date.new!(today.year - 1, 1, 1)
# Member with paid last cycle
member1 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member1, fee_type, %{cycle_start: last_year_start, status: :paid})
# Member with unpaid last cycle
member2 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{cycle_start: last_year_start, status: :unpaid})
members =
[member1, member2]
|> Enum.map(fn m ->
m
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type)
end)
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :unpaid, false)
assert length(filtered) == 1
assert List.first(filtered).id == member2.id
end
test "filters paid members in current cycle" do
fee_type = create_fee_type(%{interval: :yearly})
today = Date.utc_today()
current_year_start = Date.new!(today.year, 1, 1)
# Member with paid current cycle
member1 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member1, fee_type, %{cycle_start: current_year_start, status: :paid})
# Member with unpaid current cycle
member2 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :unpaid})
members =
[member1, member2]
|> Enum.map(fn m ->
m
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type)
end)
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :paid, true)
assert length(filtered) == 1
assert List.first(filtered).id == member1.id
end
test "filters unpaid members in current cycle" do
fee_type = create_fee_type(%{interval: :yearly})
today = Date.utc_today()
current_year_start = Date.new!(today.year, 1, 1)
# Member with paid current cycle
member1 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member1, fee_type, %{cycle_start: current_year_start, status: :paid})
# Member with unpaid current cycle
member2 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :unpaid})
members =
[member1, member2]
|> Enum.map(fn m ->
m
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type)
end)
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :unpaid, true)
assert length(filtered) == 1
assert List.first(filtered).id == member2.id
end
test "returns all members when filter is nil" do
fee_type = create_fee_type(%{interval: :yearly})
member1 = create_member(%{membership_fee_type_id: fee_type.id})
member2 = create_member(%{membership_fee_type_id: fee_type.id})
members =
[member1, member2]
|> Enum.map(fn m ->
m
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|> Ash.load!(:membership_fee_type)
end)
# filter_unpaid_members should still work for backwards compatibility
filtered = MembershipFeeStatus.filter_unpaid_members(members, false)
# Both members have no cycles, so both should be filtered out
assert length(filtered) == 0
end
end
end

View file

@ -456,4 +456,205 @@ defmodule MvWeb.MemberLive.IndexTest do
assert has_element?(view, "#flash-group")
end
end
describe "cycle status filter" do
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
# Helper to create a member
defp create_member(attrs) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
email: "test.member.#{System.unique_integer([:positive])}@example.com"
}
attrs = Map.merge(default_attrs, attrs)
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
end
# Helper to create a cycle
defp create_cycle(member, fee_type, attrs) do
# Delete any auto-generated cycles first to avoid conflicts
existing_cycles =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member.id)
|> Ash.read!()
Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle) end)
default_attrs = %{
cycle_start: ~D[2023-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :unpaid
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!()
end
test "filter shows only members with paid status in last cycle", %{conn: conn} do
conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly})
today = Date.utc_today()
last_year_start = Date.new!(today.year - 1, 1, 1)
# Member with paid last cycle
paid_member =
create_member(%{
first_name: "PaidLast",
membership_fee_type_id: fee_type.id
})
create_cycle(paid_member, fee_type, %{cycle_start: last_year_start, status: :paid})
# Member with unpaid last cycle
unpaid_member =
create_member(%{
first_name: "UnpaidLast",
membership_fee_type_id: fee_type.id
})
create_cycle(unpaid_member, fee_type, %{cycle_start: last_year_start, status: :unpaid})
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid")
assert html =~ "PaidLast"
refute html =~ "UnpaidLast"
end
test "filter shows only members with unpaid status in last cycle", %{conn: conn} do
conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly})
today = Date.utc_today()
last_year_start = Date.new!(today.year - 1, 1, 1)
# Member with paid last cycle
paid_member =
create_member(%{
first_name: "PaidLast",
membership_fee_type_id: fee_type.id
})
create_cycle(paid_member, fee_type, %{cycle_start: last_year_start, status: :paid})
# Member with unpaid last cycle
unpaid_member =
create_member(%{
first_name: "UnpaidLast",
membership_fee_type_id: fee_type.id
})
create_cycle(unpaid_member, fee_type, %{cycle_start: last_year_start, status: :unpaid})
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=unpaid")
refute html =~ "PaidLast"
assert html =~ "UnpaidLast"
end
test "filter shows only members with paid status in current cycle", %{conn: conn} do
conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly})
today = Date.utc_today()
current_year_start = Date.new!(today.year, 1, 1)
# Member with paid current cycle
paid_member =
create_member(%{
first_name: "PaidCurrent",
membership_fee_type_id: fee_type.id
})
create_cycle(paid_member, fee_type, %{cycle_start: current_year_start, status: :paid})
# Member with unpaid current cycle
unpaid_member =
create_member(%{
first_name: "UnpaidCurrent",
membership_fee_type_id: fee_type.id
})
create_cycle(unpaid_member, fee_type, %{cycle_start: current_year_start, status: :unpaid})
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid&show_current_cycle=true")
assert html =~ "PaidCurrent"
refute html =~ "UnpaidCurrent"
end
test "filter shows only members with unpaid status in current cycle", %{conn: conn} do
conn = conn_with_oidc_user(conn)
fee_type = create_fee_type(%{interval: :yearly})
today = Date.utc_today()
current_year_start = Date.new!(today.year, 1, 1)
# Member with paid current cycle
paid_member =
create_member(%{
first_name: "PaidCurrent",
membership_fee_type_id: fee_type.id
})
create_cycle(paid_member, fee_type, %{cycle_start: current_year_start, status: :paid})
# Member with unpaid current cycle
unpaid_member =
create_member(%{
first_name: "UnpaidCurrent",
membership_fee_type_id: fee_type.id
})
create_cycle(unpaid_member, fee_type, %{cycle_start: current_year_start, status: :unpaid})
{:ok, _view, html} =
live(conn, "/members?cycle_status_filter=unpaid&show_current_cycle=true")
refute html =~ "PaidCurrent"
assert html =~ "UnpaidCurrent"
end
test "toggle cycle view updates URL and preserves filter", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Start with last cycle view and paid filter
{:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
# Toggle to current cycle - this should update URL and preserve filter
# Use the button in the membership fee status column header
view
|> element("button[phx-click='toggle_cycle_view'].btn-xs")
|> render_click()
# Wait for patch to complete
path = assert_patch(view)
# URL should contain both filter and show_current_cycle
assert path =~ "cycle_status_filter=paid"
assert path =~ "show_current_cycle=true"
end
end
end