diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 25c23f9..822bce6 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -35,6 +35,7 @@ defmodule MvWeb.MemberLive.Index do alias MvWeb.Helpers.DateFormatter alias MvWeb.MemberLive.Index.FieldSelection alias MvWeb.MemberLive.Index.FieldVisibility + alias MvWeb.MemberLive.Index.MembershipFeeStatus # Prefix used in sort field names for custom fields (e.g., "custom_field_") @custom_field_prefix Mv.Constants.custom_field_prefix() @@ -108,6 +109,8 @@ defmodule MvWeb.MemberLive.Index do :member_fields_visible, FieldVisibility.get_visible_member_fields(initial_selection) ) + |> assign(:show_current_cycle, false) + |> assign(:membership_fee_status_filter, nil) # We call handle params to use the query from the URL {:ok, socket} @@ -168,6 +171,41 @@ defmodule MvWeb.MemberLive.Index do |> update_selection_assigns()} end + @impl true + def handle_event("toggle_cycle_view", _params, socket) do + new_show_current = !socket.assigns.show_current_cycle + + socket = + socket + |> assign(:show_current_cycle, new_show_current) + |> load_members() + + {: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() + + query_params = + build_query_params( + socket.assigns.query, + socket.assigns.sort_field, + socket.assigns.sort_order, + socket.assigns.paid_filter + ) + |> maybe_add_membership_fee_filter(filter) + + new_path = ~p"/members?#{query_params}" + + {:noreply, push_patch(socket, to: new_path, replace: true)} + end + @impl true def handle_event("copy_emails", _params, socket) do selected_ids = socket.assigns.selected_members @@ -251,7 +289,14 @@ defmodule MvWeb.MemberLive.Index do # Build the URL with queries query_params = - build_query_params(q, existing_field_query, existing_sort_query, socket.assigns.paid_filter) + build_query_params( + q, + existing_field_query, + existing_sort_query, + socket.assigns.paid_filter, + socket.assigns.membership_fee_status_filter, + socket.assigns.show_current_cycle + ) # Set the new path with params new_path = ~p"/members?#{query_params}" @@ -278,7 +323,9 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.query, socket.assigns.sort_field, socket.assigns.sort_order, - filter + filter, + socket.assigns.membership_fee_status_filter, + socket.assigns.show_current_cycle ) new_path = ~p"/members?#{query_params}" @@ -393,6 +440,8 @@ defmodule MvWeb.MemberLive.Index do |> maybe_update_search(params) |> maybe_update_sort(params) |> maybe_update_paid_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) @@ -501,7 +550,9 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.query, field_str, Atom.to_string(order), - socket.assigns.paid_filter + socket.assigns.paid_filter, + socket.assigns.membership_fee_status_filter, + socket.assigns.show_current_cycle ) new_path = ~p"/members?#{query_params}" @@ -513,16 +564,6 @@ defmodule MvWeb.MemberLive.Index do )} end - # Builds query parameters including field selection - defp build_query_params(socket, base_params) do - # Use query from base_params if provided, otherwise fall back to socket.assigns.query - query_value = Map.get(base_params, "query") || socket.assigns.query || "" - - base_params - |> Map.put("query", query_value) - |> maybe_add_field_selection(socket.assigns[:user_field_selection]) - end - # Adds field selection to query params if present defp maybe_add_field_selection(params, nil), do: params @@ -535,29 +576,22 @@ defmodule MvWeb.MemberLive.Index do # Pushes URL with updated field selection defp push_field_selection_url(socket) do - base_params = %{ - "sort_field" => field_to_string(socket.assigns.sort_field), - "sort_order" => Atom.to_string(socket.assigns.sort_order) - } + query_params = + build_query_params( + socket.assigns.query, + socket.assigns.sort_field, + socket.assigns.sort_order, + socket.assigns.paid_filter, + socket.assigns.membership_fee_status_filter, + socket.assigns.show_current_cycle + ) + |> maybe_add_field_selection(socket.assigns[:user_field_selection]) - # Include paid_filter if set - base_params = - case socket.assigns.paid_filter do - nil -> base_params - :paid -> Map.put(base_params, "paid_filter", "paid") - :not_paid -> Map.put(base_params, "paid_filter", "not_paid") - end - - query_params = build_query_params(socket, base_params) new_path = ~p"/members?#{query_params}" push_patch(socket, to: new_path, replace: true) end - # Converts field to string - defp field_to_string(field) when is_atom(field), do: Atom.to_string(field) - defp field_to_string(field) when is_binary(field), do: field - # Updates session field selection (stored in socket for now, actual session update via controller) defp update_session_field_selection(socket, selection) do # Store in socket for now - actual session persistence would require a controller @@ -567,7 +601,14 @@ defmodule MvWeb.MemberLive.Index do # Builds URL query parameters map including all filter/sort state. # Converts paid_filter atom to string for URL. - defp build_query_params(query, sort_field, sort_order, paid_filter) do + defp build_query_params( + query, + sort_field, + sort_order, + paid_filter, + membership_fee_filter \\ nil, + show_current_cycle \\ false + ) do field_str = if is_atom(sort_field) do Atom.to_string(sort_field) @@ -589,10 +630,21 @@ defmodule MvWeb.MemberLive.Index do } # Only add paid_filter to URL if it's set - case paid_filter do - nil -> base_params - :paid -> Map.put(base_params, "paid_filter", "paid") - :not_paid -> Map.put(base_params, "paid_filter", "not_paid") + base_params = + case paid_filter do + nil -> base_params + :paid -> Map.put(base_params, "paid_filter", "paid") + :not_paid -> Map.put(base_params, "paid_filter", "not_paid") + 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") + else + base_params end end @@ -627,6 +679,9 @@ defmodule MvWeb.MemberLive.Index do visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || [] query = load_custom_field_values(query, visible_custom_field_ids) + # Load membership fee cycles for status display + query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle) + # Apply the search filter first query = apply_search_filter(query, search_query) @@ -650,6 +705,14 @@ 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 + members = + apply_membership_fee_status_filter( + members, + socket.assigns.membership_fee_status_filter, + socket.assigns.show_current_cycle + ) + # Sort in memory if needed (for custom fields) members = if sort_after_load do @@ -1050,6 +1113,54 @@ defmodule MvWeb.MemberLive.Index do defp determine_paid_filter("not_paid"), do: :not_paid defp determine_paid_filter(_), do: nil + # Updates show_current_cycle from URL parameters if present. + defp maybe_update_show_current_cycle(socket, %{"show_current_cycle" => "true"}) do + assign(socket, :show_current_cycle, true) + end + + defp maybe_update_show_current_cycle(socket, _params) 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 13c4367..47162d5 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -42,6 +42,66 @@ paid_filter={@paid_filter} member_count={length(@members)} /> +
+ + +
<.live_component module={MvWeb.Components.FieldVisibilityDropdownComponent} id="field-visibility-dropdown" @@ -255,6 +315,40 @@ {if member.paid == true, do: gettext("Yes"), else: gettext("No")} + <:col + :let={member} + label={ + ~H""" +
+ {gettext("Membership Fee Status")} + +
+ """ + } + > + <%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge( + MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle) + ) do %> + + <.icon name={badge.icon} class="size-4" /> + {badge.label} + + <% else %> + {gettext("No cycle")} + <% end %> + <:action :let={member}>
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}