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