From c65b3808bf57560ec52cf689bd87efa7e20934df Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 18 Dec 2025 13:10:00 +0100 Subject: [PATCH] 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. --- .../components/payment_filter_component.ex | 37 ++-- lib/mv_web/live/member_live/index.ex | 145 ++++--------- lib/mv_web/live/member_live/index.html.heex | 43 +--- .../index/membership_fee_status.ex | 44 +++- priv/gettext/de/LC_MESSAGES/default.po | 43 ++-- priv/gettext/default.pot | 23 +- priv/gettext/en/LC_MESSAGES/default.po | 43 ++-- .../payment_filter_component_test.exs | 28 +-- .../index/membership_fee_status_test.exs | 130 +++++++++++ test/mv_web/member_live/index_test.exs | 201 ++++++++++++++++++ 10 files changed, 490 insertions(+), 247 deletions(-) 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" /> - - {@member_count} + + {@member_count} @@ -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