diff --git a/lib/mv_web/live/components/payment_filter_component.ex b/lib/mv_web/live/components/payment_filter_component.ex
index 1ba9d8b..9caaa1f 100644
--- a/lib/mv_web/live/components/payment_filter_component.ex
+++ b/lib/mv_web/live/components/payment_filter_component.ex
@@ -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" />
- {filter_label(@paid_filter)}
- {@member_count}
+ {filter_label(@cycle_status_filter)}
+ {@member_count}
<.icon name="hero-users" class="h-4 w-4" />
- {gettext("All payment statuses")}
+ {gettext("All")}
-
@@ -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
diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex
index 7ed4007..fff5517 100644
--- a/lib/mv_web/live/member_live/index.ex
+++ b/lib/mv_web/live/member_live/index.ex
@@ -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
# -------------------------------------------------------------
diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex
index 5b27e6f..7426b16 100644
--- a/lib/mv_web/live/member_live/index.html.heex
+++ b/lib/mv_web/live/member_live/index.html.heex
@@ -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)}
/>
@@ -60,47 +60,6 @@
<.icon name="hero-arrow-path" class="size-4" />
{if(@show_current_cycle, do: gettext("Current Cycle"), else: gettext("Last Cycle"))}
-
-
-
- -
-
-
- -
-
-
- -
-
-
-
-
<.live_component
module={MvWeb.Components.FieldVisibilityDropdownComponent}
diff --git a/lib/mv_web/member_live/index/membership_fee_status.ex b/lib/mv_web/member_live/index/membership_fee_status.ex
index 4b31ebb..5c94be5 100644
--- a/lib/mv_web/member_live/index/membership_fee_status.ex
+++ b/lib/mv_web/member_live/index/membership_fee_status.ex
@@ -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
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index eb1bcf2..fb5636c 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -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"
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index 2c039c5..2fd0bbf 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -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."
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index ae8cd7a..8f43106 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -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"
diff --git a/test/mv_web/components/payment_filter_component_test.exs b/test/mv_web/components/payment_filter_component_test.exs
index c44bf41..7987efa 100644
--- a/test/mv_web/components/payment_filter_component_test.exs
+++ b/test/mv_web/components/payment_filter_component_test.exs
@@ -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
diff --git a/test/mv_web/member_live/index/membership_fee_status_test.exs b/test/mv_web/member_live/index/membership_fee_status_test.exs
index e10f280..3321c74 100644
--- a/test/mv_web/member_live/index/membership_fee_status_test.exs
+++ b/test/mv_web/member_live/index/membership_fee_status_test.exs
@@ -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
diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs
index 60bf2aa..73cd5bb 100644
--- a/test/mv_web/member_live/index_test.exs
+++ b/test/mv_web/member_live/index_test.exs
@@ -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