feat: add membership fee status column to member list
- Add status column showing last completed or current cycle status - Add toggle to switch between last/current cycle view - Add color coding (green/red/gray) for paid/unpaid/suspended - Add filters for unpaid cycles in last/current cycle - Efficiently load cycles to avoid N+1 queries
This commit is contained in:
parent
06de9d2c8b
commit
99dc17bf4d
2 changed files with 240 additions and 35 deletions
|
|
@ -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,10 +630,21 @@ 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
|
||||||
case paid_filter do
|
base_params =
|
||||||
nil -> base_params
|
case paid_filter do
|
||||||
:paid -> Map.put(base_params, "paid_filter", "paid")
|
nil -> base_params
|
||||||
:not_paid -> Map.put(base_params, "paid_filter", "not_paid")
|
: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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
# -------------------------------------------------------------
|
# -------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue