Membership Fee 6 - UI Components & LiveViews closes #280 #304

Open
moritz wants to merge 65 commits from feature/280_membership_fee_ui into main
2 changed files with 240 additions and 35 deletions
Showing only changes of commit 99dc17bf4d - Show all commits

View file

@ -35,6 +35,7 @@ defmodule MvWeb.MemberLive.Index do
alias MvWeb.Helpers.DateFormatter alias MvWeb.Helpers.DateFormatter
alias MvWeb.MemberLive.Index.FieldSelection alias MvWeb.MemberLive.Index.FieldSelection
alias MvWeb.MemberLive.Index.FieldVisibility alias MvWeb.MemberLive.Index.FieldVisibility
alias MvWeb.MemberLive.Index.MembershipFeeStatus
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>") # Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
@custom_field_prefix Mv.Constants.custom_field_prefix() @custom_field_prefix Mv.Constants.custom_field_prefix()
@ -108,6 +109,8 @@ defmodule MvWeb.MemberLive.Index do
:member_fields_visible, :member_fields_visible,
FieldVisibility.get_visible_member_fields(initial_selection) 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 # We call handle params to use the query from the URL
{:ok, socket} {:ok, socket}
@ -168,6 +171,41 @@ defmodule MvWeb.MemberLive.Index do
|> update_selection_assigns()} |> update_selection_assigns()}
end 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 @impl true
def handle_event("copy_emails", _params, socket) do def handle_event("copy_emails", _params, socket) do
selected_ids = socket.assigns.selected_members selected_ids = socket.assigns.selected_members
@ -251,7 +289,14 @@ defmodule MvWeb.MemberLive.Index do
# Build the URL with queries # Build the URL with queries
query_params = 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 # Set the new path with params
new_path = ~p"/members?#{query_params}" new_path = ~p"/members?#{query_params}"
@ -278,7 +323,9 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.query, socket.assigns.query,
socket.assigns.sort_field, socket.assigns.sort_field,
socket.assigns.sort_order, socket.assigns.sort_order,
filter filter,
socket.assigns.membership_fee_status_filter,
socket.assigns.show_current_cycle
) )
new_path = ~p"/members?#{query_params}" new_path = ~p"/members?#{query_params}"
@ -393,6 +440,8 @@ defmodule MvWeb.MemberLive.Index do
|> maybe_update_search(params) |> maybe_update_search(params)
|> maybe_update_sort(params) |> maybe_update_sort(params)
|> maybe_update_paid_filter(params) |> maybe_update_paid_filter(params)
|> maybe_update_show_current_cycle(params)
|> maybe_update_membership_fee_status_filter(params)
|> assign(:query, params["query"]) |> assign(:query, params["query"])
|> assign(:user_field_selection, final_selection) |> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields) |> assign(:member_fields_visible, visible_member_fields)
@ -501,7 +550,9 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.query, socket.assigns.query,
field_str, field_str,
Atom.to_string(order), 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}" new_path = ~p"/members?#{query_params}"
@ -513,16 +564,6 @@ defmodule MvWeb.MemberLive.Index do
)} )}
end 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 # Adds field selection to query params if present
defp maybe_add_field_selection(params, nil), do: params defp maybe_add_field_selection(params, nil), do: params
@ -535,29 +576,22 @@ defmodule MvWeb.MemberLive.Index do
# Pushes URL with updated field selection # Pushes URL with updated field selection
defp push_field_selection_url(socket) do defp push_field_selection_url(socket) do
base_params = %{ query_params =
"sort_field" => field_to_string(socket.assigns.sort_field), build_query_params(
"sort_order" => Atom.to_string(socket.assigns.sort_order) 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}" new_path = ~p"/members?#{query_params}"
push_patch(socket, to: new_path, replace: true) push_patch(socket, to: new_path, replace: true)
end 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) # Updates session field selection (stored in socket for now, actual session update via controller)
defp update_session_field_selection(socket, selection) do defp update_session_field_selection(socket, selection) do
# Store in socket for now - actual session persistence would require a controller # 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. # Builds URL query parameters map including all filter/sort state.
# Converts paid_filter atom to string for URL. # 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 = field_str =
if is_atom(sort_field) do if is_atom(sort_field) do
Atom.to_string(sort_field) Atom.to_string(sort_field)
@ -589,11 +630,22 @@ defmodule MvWeb.MemberLive.Index do
} }
# Only add paid_filter to URL if it's set # Only add paid_filter to URL if it's set
base_params =
case paid_filter do case paid_filter do
nil -> base_params nil -> base_params
:paid -> Map.put(base_params, "paid_filter", "paid") :paid -> Map.put(base_params, "paid_filter", "paid")
:not_paid -> Map.put(base_params, "paid_filter", "not_paid") :not_paid -> Map.put(base_params, "paid_filter", "not_paid")
end 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 end
# Loads members from the database with custom field values and applies search/sort/payment filters. # Loads members from the database with custom field values and applies search/sort/payment filters.
@ -627,6 +679,9 @@ defmodule MvWeb.MemberLive.Index do
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || [] visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
query = load_custom_field_values(query, 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 # Apply the search filter first
query = apply_search_filter(query, search_query) 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 # Custom field values are already filtered at the database level in load_custom_field_values/2
# No need for in-memory filtering anymore # 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) # Sort in memory if needed (for custom fields)
members = members =
if sort_after_load do 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("not_paid"), do: :not_paid
defp determine_paid_filter(_), do: nil 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 # Helper Functions for Custom Field Values
# ------------------------------------------------------------- # -------------------------------------------------------------

View file

@ -42,6 +42,66 @@
paid_filter={@paid_filter} paid_filter={@paid_filter}
member_count={length(@members)} member_count={length(@members)}
/> />
<div class="flex gap-2 items-center">
<button
type="button"
phx-click="toggle_cycle_view"
class={[
"btn btn-sm",
if(@show_current_cycle, do: "btn-primary", else: "btn-outline")
]}
aria-label={
if(@show_current_cycle,
do: gettext("Show last completed cycle"),
else: gettext("Show current cycle")
)
}
>
<.icon name="hero-arrow-path" class="size-4" />
{if(@show_current_cycle, do: gettext("Current Cycle"), else: gettext("Last Cycle"))}
</button>
<div class="dropdown">
<label tabindex="0" class="btn btn-sm btn-outline">
<.icon name="hero-funnel" class="size-4" />
{gettext("Membership Fee")}
</label>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow"
>
<li>
<button
type="button"
phx-click="filter_unpaid_cycles"
phx-value-filter="unpaid_last"
class={if(@membership_fee_status_filter == :unpaid_last, do: "active", else: "")}
>
{gettext("Unpaid in last cycle")}
</button>
</li>
<li>
<button
type="button"
phx-click="filter_unpaid_cycles"
phx-value-filter="unpaid_current"
class={if(@membership_fee_status_filter == :unpaid_current, do: "active", else: "")}
>
{gettext("Unpaid in current cycle")}
</button>
</li>
<li>
<button
type="button"
phx-click="filter_unpaid_cycles"
phx-value-filter=""
class={if(@membership_fee_status_filter == nil, do: "active", else: "")}
>
{gettext("All")}
</button>
</li>
</ul>
</div>
</div>
<.live_component <.live_component
module={MvWeb.Components.FieldVisibilityDropdownComponent} module={MvWeb.Components.FieldVisibilityDropdownComponent}
id="field-visibility-dropdown" id="field-visibility-dropdown"
@ -255,6 +315,40 @@
{if member.paid == true, do: gettext("Yes"), else: gettext("No")} {if member.paid == true, do: gettext("Yes"), else: gettext("No")}
</span> </span>
</:col> </:col>
<:col
:let={member}
label={
~H"""
<div class="flex items-center gap-2">
<span>{gettext("Membership Fee Status")}</span>
<button
type="button"
phx-click="toggle_cycle_view"
class="btn btn-xs btn-ghost"
title={
if(@show_current_cycle,
do: gettext("Switch to last completed cycle"),
else: gettext("Switch to current cycle")
)
}
>
<.icon name="hero-arrow-path" class="size-3" />
</button>
</div>
"""
}
>
<%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(
MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle)
) do %>
<span class={["badge", badge.color]}>
<.icon name={badge.icon} class="size-4" />
{badge.label}
</span>
<% else %>
<span class="badge badge-ghost">{gettext("No cycle")}</span>
<% end %>
</:col>
<:action :let={member}> <:action :let={member}>
<div class="sr-only"> <div class="sr-only">
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link> <.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>