The growing row of bulk-action buttons above the member overview is replaced by one "Aktionen" dropdown holding all four actions (open in email program, copy addresses, export CSV, export PDF). With no selection the actions operate on all — or the currently filtered — members; the email-program action is disabled past a recipient cap, because the browser cannot reliably hand a very long mailto over to the mail client. The trigger shows the active scope as a badge: an emphasized count when members are selected, a muted "alle"/"gefiltert" otherwise.
2035 lines
68 KiB
Elixir
2035 lines
68 KiB
Elixir
defmodule MvWeb.MemberLive.Index do
|
||
@moduledoc """
|
||
LiveView for displaying and managing the member list.
|
||
|
||
## Features
|
||
- Full-text search across member profiles using PostgreSQL tsvector
|
||
- Sortable columns (name, email, address fields)
|
||
- Bulk selection for future batch operations
|
||
- Real-time updates via LiveView
|
||
- Bookmarkable URLs with query parameters
|
||
|
||
## URL Parameters
|
||
- `query` - Search query string for full-text search
|
||
- `sort_field` - Field to sort by (e.g., :first_name, :email, :join_date)
|
||
- `sort_order` - Sort direction (:asc or :desc)
|
||
|
||
## Events
|
||
- `select_member` - Toggle individual member selection
|
||
- `select_all` - Toggle selection of all visible members
|
||
- `copy_emails` - Copy email addresses of the selected members, or of all/filtered members when nothing is selected
|
||
|
||
## Implementation Notes
|
||
- Search uses PostgreSQL full-text search (plainto_tsquery)
|
||
- Sort state is synced with URL for bookmarkability
|
||
- Components communicate via `handle_info` for decoupling
|
||
"""
|
||
use MvWeb, :live_view
|
||
|
||
require Ash.Query
|
||
require Logger
|
||
import Ash.Expr
|
||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||
|
||
alias Mv.Membership
|
||
alias Mv.Membership.Member, as: MemberResource
|
||
alias Mv.MembershipFees
|
||
alias Mv.MembershipFees.MembershipFeeType
|
||
alias MvWeb.Helpers.DateFormatter
|
||
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
|
||
alias MvWeb.MemberLive.Index.DateFilter
|
||
alias MvWeb.MemberLive.Index.FieldSelection
|
||
alias MvWeb.MemberLive.Index.FieldVisibility
|
||
alias MvWeb.MemberLive.Index.FilterParams
|
||
alias MvWeb.MemberLive.Index.Formatter
|
||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||
|
||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||
@boolean_filter_prefix Mv.Constants.boolean_filter_prefix()
|
||
@group_filter_prefix Mv.Constants.group_filter_prefix()
|
||
@fee_type_filter_prefix Mv.Constants.fee_type_filter_prefix()
|
||
|
||
# Maximum number of boolean custom field filters allowed per request (DoS protection)
|
||
@max_boolean_filters Mv.Constants.max_boolean_filters()
|
||
|
||
# Maximum length of UUID string (36 characters including hyphens)
|
||
@max_uuid_length Mv.Constants.max_uuid_length()
|
||
|
||
# Member fields that are loaded for the overview
|
||
# Uses constants from Mv.Constants to ensure consistency
|
||
# Note: :id is always included for member identification
|
||
# All member fields are loaded, but visibility is controlled via settings
|
||
@overview_fields [:id | Mv.Constants.member_fields()]
|
||
|
||
@doc """
|
||
Initializes the LiveView state.
|
||
|
||
Sets up initial assigns for page title, search query, sort configuration,
|
||
payment filter, and member selection. Actual data loading happens in `handle_params/3`.
|
||
"""
|
||
@impl true
|
||
def mount(_params, session, socket) do
|
||
# Load custom fields that should be shown in overview (for display)
|
||
# Errors in mount are handled by Phoenix LiveView and result in a 500 error page.
|
||
# This is appropriate for initialization errors that should be visible to the user.
|
||
actor = current_actor(socket)
|
||
|
||
all_custom_fields =
|
||
Mv.Membership.CustomField
|
||
|> Ash.Query.sort(name: :asc)
|
||
|> Ash.read!(actor: actor)
|
||
|
||
custom_fields_visible =
|
||
all_custom_fields
|
||
|> Enum.filter(& &1.show_in_overview)
|
||
|
||
# Load boolean custom fields (filtered and sorted from all_custom_fields)
|
||
boolean_custom_fields =
|
||
all_custom_fields
|
||
|> Enum.filter(&(&1.value_type == :boolean))
|
||
|> Enum.sort_by(& &1.name, :asc)
|
||
|
||
# Date-typed custom fields surface in the new "Custom date fields" filter
|
||
# section and are needed by DateFilter.from_params/2 to validate UUIDs.
|
||
date_custom_fields =
|
||
all_custom_fields
|
||
|> Enum.filter(&(&1.value_type == :date))
|
||
|> Enum.sort_by(& &1.name, :asc)
|
||
|
||
# Load groups for filter dropdown (sorted by name)
|
||
groups =
|
||
Mv.Membership.Group
|
||
|> Ash.Query.sort(name: :asc)
|
||
|> Ash.read!(actor: actor)
|
||
|
||
# Load membership fee types for filter dropdown (sorted by name)
|
||
fee_types =
|
||
MembershipFeeType
|
||
|> Ash.Query.sort(name: :asc)
|
||
|> Ash.read!(domain: MembershipFees, actor: actor)
|
||
|
||
# Load settings once to avoid N+1 queries
|
||
settings =
|
||
case Membership.get_settings() do
|
||
{:ok, s} -> s
|
||
# Fallback if settings can't be loaded
|
||
{:error, _} -> %{member_field_visibility: %{}}
|
||
end
|
||
|
||
# Ensure nested module is loaded (can be missing after code reload in dev if load order changes)
|
||
Code.ensure_loaded!(FieldSelection)
|
||
|
||
# Load user field selection from session
|
||
session_selection = FieldSelection.get_from_session(session)
|
||
|
||
# FIX: ensure dropdown doesn’t show duplicate fields (e.g. membership fee status twice)
|
||
all_available_fields =
|
||
all_custom_fields
|
||
|> FieldVisibility.get_all_available_fields()
|
||
|
||
initial_selection =
|
||
FieldVisibility.merge_with_global_settings(
|
||
session_selection,
|
||
settings,
|
||
all_custom_fields
|
||
)
|
||
|
||
socket =
|
||
socket
|
||
|> Layouts.assign_page_title(gettext("Members"))
|
||
|> assign(:query, "")
|
||
|> assign_new(:sort_field, fn -> :first_name end)
|
||
|> assign_new(:sort_order, fn -> :asc end)
|
||
|> assign(:cycle_status_filter, nil)
|
||
|> assign(:group_filters, %{})
|
||
|> assign(:groups, groups)
|
||
|> assign(:fee_type_filters, %{})
|
||
|> assign(:fee_types, fee_types)
|
||
|> assign(:boolean_custom_field_filters, %{})
|
||
|> assign(:selected_members, MapSet.new())
|
||
|> assign(:selected_member_id, nil)
|
||
|> assign(:settings, settings)
|
||
|> assign(:custom_fields_visible, custom_fields_visible)
|
||
|> assign(:all_custom_fields, all_custom_fields)
|
||
|> assign(:boolean_custom_fields, boolean_custom_fields)
|
||
|> assign(:date_custom_fields, date_custom_fields)
|
||
|> assign(:date_filters, DateFilter.default())
|
||
|> assign(:all_available_fields, all_available_fields)
|
||
|> assign(:user_field_selection, initial_selection)
|
||
|> assign(:fields_in_url?, false)
|
||
|> assign(
|
||
:member_fields_visible,
|
||
FieldVisibility.get_visible_member_fields(initial_selection)
|
||
)
|
||
|> assign(
|
||
:member_fields_visible_db,
|
||
FieldVisibility.get_visible_member_fields_db(initial_selection)
|
||
)
|
||
|> assign(
|
||
:member_fields_visible_computed,
|
||
FieldVisibility.get_visible_member_fields_computed(initial_selection)
|
||
)
|
||
|> assign(:show_current_cycle, false)
|
||
|> assign(:membership_fee_status_filter, nil)
|
||
|> assign_export_payload()
|
||
|
||
{:ok, socket}
|
||
end
|
||
|
||
# -----------------------------------------------------------------
|
||
# Handle Events
|
||
# -----------------------------------------------------------------
|
||
|
||
@doc """
|
||
Handles member-related UI events.
|
||
|
||
## Supported events:
|
||
- `"select_member"` - Toggles individual member selection
|
||
- `"select_all"` - Toggles selection of all visible members
|
||
- `"sort"` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
|
||
"""
|
||
@impl true
|
||
def handle_event("select_row_and_navigate", %{"id" => id}, socket) do
|
||
# Navigate to member show. Back button on show page uses ?highlight=id so returning to index shows row as selected.
|
||
{:noreply, push_navigate(socket, to: ~p"/members/#{id}")}
|
||
end
|
||
|
||
@impl true
|
||
def handle_event("select_member", %{"id" => id}, socket) do
|
||
selected =
|
||
if MapSet.member?(socket.assigns.selected_members, id) do
|
||
MapSet.delete(socket.assigns.selected_members, id)
|
||
else
|
||
MapSet.put(socket.assigns.selected_members, id)
|
||
end
|
||
|
||
{:noreply,
|
||
socket
|
||
|> assign(:selected_members, selected)
|
||
|> update_selection_assigns()}
|
||
end
|
||
|
||
@impl true
|
||
def handle_event("select_all", _params, socket) do
|
||
all_ids = socket.assigns.members |> Enum.map(& &1.id) |> MapSet.new()
|
||
|
||
selected =
|
||
if MapSet.equal?(socket.assigns.selected_members, all_ids) do
|
||
MapSet.new()
|
||
else
|
||
all_ids
|
||
end
|
||
|
||
{:noreply,
|
||
socket
|
||
|> assign(:selected_members, selected)
|
||
|> 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()
|
||
|> update_selection_assigns()
|
||
|
||
query_params =
|
||
build_query_params(opts_for_query_params(socket, %{show_current_cycle: new_show_current}))
|
||
|> maybe_add_field_selection(
|
||
socket.assigns[:user_field_selection],
|
||
socket.assigns[:fields_in_url?] || false
|
||
)
|
||
|
||
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
|
||
members = socket.assigns.members
|
||
selected_ids = socket.assigns.selected_members
|
||
any_selected? = Enum.any?(members, &MapSet.member?(selected_ids, &1.id))
|
||
|
||
# Recipients follow the current scope: the selection when present, otherwise
|
||
# every member in the (filtered) list. Members without an email are excluded
|
||
# in both cases (unchanged missing-email handling). With no selection we no
|
||
# longer hard-stop with "No members selected" — we act on the scope; the
|
||
# empty-recipient feedback below is preserved.
|
||
formatted_emails = scope_member_emails(members, selected_ids, any_selected?)
|
||
email_count = length(formatted_emails)
|
||
|
||
if email_count == 0 do
|
||
{:noreply, put_flash(socket, :error, gettext("No email addresses found"))}
|
||
else
|
||
# RFC 5322 uses comma as separator for email address lists
|
||
email_string = Enum.join(formatted_emails, ", ")
|
||
|
||
socket =
|
||
socket
|
||
|> push_event("copy_to_clipboard", %{text: email_string})
|
||
|> put_flash(
|
||
:success,
|
||
ngettext(
|
||
"Copied %{count} email address to clipboard",
|
||
"Copied %{count} email addresses to clipboard",
|
||
email_count,
|
||
count: email_count
|
||
)
|
||
)
|
||
|> put_flash(
|
||
:warning,
|
||
gettext("Tip: Paste email addresses into the BCC field for privacy compliance")
|
||
)
|
||
|
||
{:noreply, socket}
|
||
end
|
||
end
|
||
|
||
@impl true
|
||
def handle_event("sort", %{"field" => field_str}, socket) do
|
||
# Handle both atom and string field names (for custom fields)
|
||
field =
|
||
try do
|
||
String.to_existing_atom(field_str)
|
||
rescue
|
||
ArgumentError -> field_str
|
||
end
|
||
|
||
{new_field, new_order} = determine_new_sort(field, socket)
|
||
old_field = socket.assigns.sort_field
|
||
|
||
socket =
|
||
socket
|
||
|> assign(:sort_field, new_field)
|
||
|> assign(:sort_order, new_order)
|
||
|> update_sort_components(old_field, new_field, new_order)
|
||
|> load_members()
|
||
|> update_selection_assigns()
|
||
|
||
# URL sync - push_patch happens synchronously in the event handler
|
||
query_params =
|
||
build_query_params(
|
||
opts_for_query_params(socket, %{
|
||
sort_field: export_sort_field(socket.assigns.sort_field),
|
||
sort_order: export_sort_order(socket.assigns.sort_order)
|
||
})
|
||
)
|
||
|> maybe_add_field_selection(
|
||
socket.assigns[:user_field_selection],
|
||
socket.assigns[:fields_in_url?] || false
|
||
)
|
||
|
||
{:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)}
|
||
end
|
||
|
||
# -----------------------------------------------------------------
|
||
# Handle Infos from Child Components
|
||
# -----------------------------------------------------------------
|
||
|
||
@doc """
|
||
Handles messages from child components.
|
||
|
||
## Supported messages:
|
||
- `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL
|
||
- `{:field_toggled, field, visible}` - Field toggle event from FieldVisibilityDropdownComponent
|
||
- `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent
|
||
"""
|
||
|
||
@impl true
|
||
def handle_info({:search_changed, q}, socket) do
|
||
socket =
|
||
socket
|
||
|> assign(:query, q)
|
||
|> load_members()
|
||
|> update_selection_assigns()
|
||
|
||
query_params =
|
||
build_query_params(opts_for_query_params(socket, %{query: q}))
|
||
|> maybe_add_field_selection(
|
||
socket.assigns[:user_field_selection],
|
||
socket.assigns[:fields_in_url?] || false
|
||
)
|
||
|
||
new_path = ~p"/members?#{query_params}"
|
||
|
||
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_info({:payment_filter_changed, filter}, socket) do
|
||
socket =
|
||
socket
|
||
|> assign(:cycle_status_filter, filter)
|
||
|> load_members()
|
||
|> update_selection_assigns()
|
||
|
||
query_params =
|
||
build_query_params(opts_for_query_params(socket, %{cycle_status_filter: filter}))
|
||
|> maybe_add_field_selection(
|
||
socket.assigns[:user_field_selection],
|
||
socket.assigns[:fields_in_url?] || false
|
||
)
|
||
|
||
new_path = ~p"/members?#{query_params}"
|
||
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_info({:boolean_filter_changed, custom_field_id_str, filter_value}, socket) do
|
||
updated_filters =
|
||
if filter_value == nil do
|
||
Map.delete(socket.assigns.boolean_custom_field_filters, custom_field_id_str)
|
||
else
|
||
Map.put(socket.assigns.boolean_custom_field_filters, custom_field_id_str, filter_value)
|
||
end
|
||
|
||
socket =
|
||
socket
|
||
|> assign(:boolean_custom_field_filters, updated_filters)
|
||
|> load_members()
|
||
|> update_selection_assigns()
|
||
|
||
query_params =
|
||
build_query_params(opts_for_query_params(socket, %{boolean_filters: updated_filters}))
|
||
|> maybe_add_field_selection(
|
||
socket.assigns[:user_field_selection],
|
||
socket.assigns[:fields_in_url?] || false
|
||
)
|
||
|
||
new_path = ~p"/members?#{query_params}"
|
||
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_info({:group_filter_changed, group_id_str, filter_value}, socket) do
|
||
normalized_id = normalize_uuid_string(group_id_str) || group_id_str
|
||
|
||
group_filters =
|
||
if filter_value == nil do
|
||
Map.delete(socket.assigns.group_filters, normalized_id)
|
||
else
|
||
Map.put(socket.assigns.group_filters, normalized_id, filter_value)
|
||
end
|
||
|
||
socket =
|
||
socket
|
||
|> assign(:group_filters, group_filters)
|
||
|> load_members()
|
||
|> update_selection_assigns()
|
||
|
||
query_params =
|
||
build_query_params(opts_for_query_params(socket, %{group_filters: group_filters}))
|
||
|> maybe_add_field_selection(
|
||
socket.assigns[:user_field_selection],
|
||
socket.assigns[:fields_in_url?] || false
|
||
)
|
||
|
||
new_path = ~p"/members?#{query_params}"
|
||
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_info({:fee_type_filter_changed, fee_type_id_str, filter_value}, socket) do
|
||
normalized_id = normalize_uuid_string(fee_type_id_str) || fee_type_id_str
|
||
|
||
fee_type_filters =
|
||
if filter_value == nil do
|
||
Map.delete(socket.assigns.fee_type_filters, normalized_id)
|
||
else
|
||
Map.put(socket.assigns.fee_type_filters, normalized_id, filter_value)
|
||
end
|
||
|
||
socket =
|
||
socket
|
||
|> assign(:fee_type_filters, fee_type_filters)
|
||
|> load_members()
|
||
|> update_selection_assigns()
|
||
|
||
query_params =
|
||
build_query_params(opts_for_query_params(socket, %{fee_type_filters: fee_type_filters}))
|
||
|> maybe_add_field_selection(
|
||
socket.assigns[:user_field_selection],
|
||
socket.assigns[:fields_in_url?] || false
|
||
)
|
||
|
||
new_path = ~p"/members?#{query_params}"
|
||
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_info({:date_filters_changed, new_date_filters}, socket) do
|
||
socket =
|
||
socket
|
||
|> assign(:date_filters, new_date_filters)
|
||
|> load_members()
|
||
|> update_selection_assigns()
|
||
|
||
query_params =
|
||
build_query_params(opts_for_query_params(socket, %{date_filters: new_date_filters}))
|
||
|> maybe_add_field_selection(
|
||
socket.assigns[:user_field_selection],
|
||
socket.assigns[:fields_in_url?] || false
|
||
)
|
||
|
||
new_path = ~p"/members?#{query_params}"
|
||
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
||
end
|
||
|
||
# Backward compatibility: tuple form delegates to map form
|
||
def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do
|
||
handle_info(
|
||
{:reset_all_filters,
|
||
%{
|
||
cycle_status_filter: cycle_status_filter,
|
||
boolean_filters: boolean_filters,
|
||
group_filters: %{},
|
||
fee_type_filters: %{}
|
||
}},
|
||
socket
|
||
)
|
||
end
|
||
|
||
def handle_info(
|
||
{:reset_all_filters, cycle_status_filter, boolean_filters, group_filters},
|
||
socket
|
||
) do
|
||
handle_info(
|
||
{:reset_all_filters,
|
||
%{
|
||
cycle_status_filter: cycle_status_filter,
|
||
boolean_filters: boolean_filters,
|
||
group_filters: group_filters,
|
||
fee_type_filters: %{}
|
||
}},
|
||
socket
|
||
)
|
||
end
|
||
|
||
def handle_info(
|
||
{:reset_all_filters, cycle_status_filter, boolean_filters, group_filters,
|
||
fee_type_filters},
|
||
socket
|
||
) do
|
||
handle_info(
|
||
{:reset_all_filters,
|
||
%{
|
||
cycle_status_filter: cycle_status_filter,
|
||
boolean_filters: boolean_filters,
|
||
group_filters: group_filters,
|
||
fee_type_filters: fee_type_filters
|
||
}},
|
||
socket
|
||
)
|
||
end
|
||
|
||
def handle_info({:reset_all_filters, %{} = opts}, socket) do
|
||
socket =
|
||
socket
|
||
|> assign(:cycle_status_filter, Map.get(opts, :cycle_status_filter))
|
||
|> assign(:group_filters, Map.get(opts, :group_filters, %{}))
|
||
|> assign(:fee_type_filters, Map.get(opts, :fee_type_filters, %{}))
|
||
|> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{}))
|
||
|> assign(:date_filters, Map.get(opts, :date_filters, DateFilter.default()))
|
||
|> load_members()
|
||
|> update_selection_assigns()
|
||
|
||
query_params =
|
||
build_query_params(opts_for_query_params(socket))
|
||
|> maybe_add_field_selection(
|
||
socket.assigns[:user_field_selection],
|
||
socket.assigns[:fields_in_url?] || false
|
||
)
|
||
|
||
new_path = ~p"/members?#{query_params}"
|
||
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_info({:field_toggled, field_string, visible}, socket) do
|
||
new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible)
|
||
socket = update_session_field_selection(socket, new_selection)
|
||
|
||
final_selection =
|
||
FieldVisibility.merge_with_global_settings(
|
||
new_selection,
|
||
socket.assigns.settings,
|
||
socket.assigns.all_custom_fields
|
||
)
|
||
|
||
visible_member_fields =
|
||
final_selection
|
||
|> FieldVisibility.get_visible_member_fields()
|
||
|> Enum.uniq()
|
||
|
||
visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection)
|
||
|
||
visible_member_fields_computed =
|
||
FieldVisibility.get_visible_member_fields_computed(final_selection)
|
||
|
||
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
|
||
|
||
socket =
|
||
socket
|
||
|> assign(:user_field_selection, final_selection)
|
||
|> assign(:member_fields_visible, visible_member_fields)
|
||
|> assign(:member_fields_visible_db, visible_member_fields_db)
|
||
|> assign(:member_fields_visible_computed, visible_member_fields_computed)
|
||
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
||
|> load_members()
|
||
|> prepare_dynamic_cols()
|
||
|> update_selection_assigns()
|
||
|> push_field_selection_url()
|
||
|
||
{:noreply, socket}
|
||
end
|
||
|
||
@impl true
|
||
def handle_info({:fields_selected, selection}, socket) do
|
||
socket = update_session_field_selection(socket, selection)
|
||
|
||
final_selection =
|
||
FieldVisibility.merge_with_global_settings(
|
||
selection,
|
||
socket.assigns.settings,
|
||
socket.assigns.all_custom_fields
|
||
)
|
||
|
||
visible_member_fields =
|
||
final_selection
|
||
|> FieldVisibility.get_visible_member_fields()
|
||
|> Enum.uniq()
|
||
|
||
visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection)
|
||
|
||
visible_member_fields_computed =
|
||
FieldVisibility.get_visible_member_fields_computed(final_selection)
|
||
|
||
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
|
||
|
||
socket =
|
||
socket
|
||
|> assign(:user_field_selection, final_selection)
|
||
|> assign(:member_fields_visible, visible_member_fields)
|
||
|> assign(:member_fields_visible_db, visible_member_fields_db)
|
||
|> assign(:member_fields_visible_computed, visible_member_fields_computed)
|
||
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
||
|> load_members()
|
||
|> prepare_dynamic_cols()
|
||
|> update_selection_assigns()
|
||
|> push_field_selection_url()
|
||
|
||
{:noreply, socket}
|
||
end
|
||
|
||
# -----------------------------------------------------------------
|
||
# Handle Params from the URL
|
||
# -----------------------------------------------------------------
|
||
|
||
@impl true
|
||
def handle_params(params, url, socket) do
|
||
url = url || request_url_from_socket(socket)
|
||
params = merge_fields_param_from_uri(params, url)
|
||
prev_sig = build_signature(socket)
|
||
|
||
fields_in_url? =
|
||
case Map.get(params, "fields") do
|
||
v when is_binary(v) and v != "" -> true
|
||
_ -> false
|
||
end
|
||
|
||
url_selection = FieldSelection.parse_from_url(params)
|
||
final_selection = compute_final_field_selection(fields_in_url?, url_selection, socket)
|
||
|
||
visible_member_fields =
|
||
final_selection
|
||
|> FieldVisibility.get_visible_member_fields()
|
||
|> Enum.uniq()
|
||
|
||
visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection)
|
||
|
||
visible_member_fields_computed =
|
||
FieldVisibility.get_visible_member_fields_computed(final_selection)
|
||
|
||
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
|
||
|
||
socket =
|
||
socket
|
||
|> maybe_update_search(params)
|
||
|> maybe_update_sort(params)
|
||
|> maybe_update_cycle_status_filter(params)
|
||
|> maybe_update_group_filters(params)
|
||
|> maybe_update_fee_type_filters(params)
|
||
|> maybe_update_boolean_filters(params)
|
||
|> maybe_update_date_filters(params)
|
||
|> maybe_update_show_current_cycle(params)
|
||
|> assign(:fields_in_url?, fields_in_url?)
|
||
|> assign(:query, params["query"])
|
||
|> assign(:user_field_selection, final_selection)
|
||
|> assign(:member_fields_visible, visible_member_fields)
|
||
|> assign(:member_fields_visible_db, visible_member_fields_db)
|
||
|> assign(:member_fields_visible_computed, visible_member_fields_computed)
|
||
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
||
|> assign(:selected_member_id, parse_highlight_param(params["highlight"]))
|
||
|
||
next_sig = build_signature(socket)
|
||
|
||
socket =
|
||
if prev_sig == next_sig && Map.has_key?(socket.assigns, :members) do
|
||
socket
|
||
|> prepare_dynamic_cols()
|
||
|> update_selection_assigns()
|
||
else
|
||
socket
|
||
|> load_members()
|
||
|> prepare_dynamic_cols()
|
||
|> update_selection_assigns()
|
||
end
|
||
|
||
# Update sort components after rendering
|
||
socket =
|
||
if socket.assigns[:sort_needs_update] do
|
||
old_field = socket.assigns[:previous_sort_field] || socket.assigns.sort_field
|
||
|
||
socket
|
||
|> update_sort_components(old_field, socket.assigns.sort_field, socket.assigns.sort_order)
|
||
|> assign(:sort_needs_update, false)
|
||
|> assign(:previous_sort_field, nil)
|
||
else
|
||
socket
|
||
end
|
||
|
||
{:noreply, socket}
|
||
end
|
||
|
||
defp build_signature(socket) do
|
||
{
|
||
socket.assigns.query,
|
||
socket.assigns.sort_field,
|
||
socket.assigns.sort_order,
|
||
socket.assigns.cycle_status_filter,
|
||
socket.assigns[:group_filters],
|
||
socket.assigns[:fee_type_filters],
|
||
socket.assigns.show_current_cycle,
|
||
socket.assigns.boolean_custom_field_filters,
|
||
socket.assigns.user_field_selection,
|
||
socket.assigns[:visible_custom_field_ids] || [],
|
||
socket.assigns[:date_filters]
|
||
}
|
||
end
|
||
|
||
defp prepare_dynamic_cols(socket) do
|
||
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
|
||
visible_set = MapSet.new(visible_custom_field_ids)
|
||
|
||
dynamic_cols =
|
||
socket.assigns.all_custom_fields
|
||
|> Enum.filter(fn custom_field ->
|
||
MapSet.member?(visible_set, to_string(custom_field.id))
|
||
end)
|
||
|> Enum.map(fn custom_field ->
|
||
%{
|
||
custom_field: custom_field,
|
||
render: fn member ->
|
||
case get_custom_field_value(member, custom_field) do
|
||
nil -> ""
|
||
cfv -> Formatter.format_custom_field_value(cfv.value, custom_field)
|
||
end
|
||
end
|
||
}
|
||
end)
|
||
|
||
assign(socket, :dynamic_cols, dynamic_cols)
|
||
end
|
||
|
||
# -------------------------------------------------------------
|
||
# Sorting
|
||
# -------------------------------------------------------------
|
||
|
||
defp determine_new_sort(field, socket) do
|
||
if socket.assigns.sort_field == field do
|
||
{field, toggle_order(socket.assigns.sort_order)}
|
||
else
|
||
{field, :asc}
|
||
end
|
||
end
|
||
|
||
defp update_sort_components(socket, old_field, new_field, new_order) do
|
||
active_id = to_sort_id(new_field)
|
||
old_id = to_sort_id(old_field)
|
||
|
||
send_update(MvWeb.Components.SortHeaderComponent,
|
||
id: active_id,
|
||
sort_field: new_field,
|
||
sort_order: new_order
|
||
)
|
||
|
||
send_update(MvWeb.Components.SortHeaderComponent,
|
||
id: old_id,
|
||
sort_field: new_field,
|
||
sort_order: new_order
|
||
)
|
||
|
||
socket
|
||
end
|
||
|
||
defp to_sort_id(field) when is_binary(field) do
|
||
String.to_existing_atom("sort_#{field}")
|
||
rescue
|
||
ArgumentError -> :"sort_#{field}"
|
||
end
|
||
|
||
defp to_sort_id(field) when is_atom(field), do: :"sort_#{field}"
|
||
|
||
# Only keep `fields` in the URL when it was already present (bookmark/share),
|
||
# OR when we intentionally push it via push_field_selection_url/1.
|
||
defp maybe_add_field_selection(params, selection, true) when is_map(selection) do
|
||
fields_param = FieldSelection.to_url_param(selection)
|
||
|
||
if fields_param == "" do
|
||
Map.delete(params, "fields")
|
||
else
|
||
Map.put(params, "fields", fields_param)
|
||
end
|
||
end
|
||
|
||
defp maybe_add_field_selection(params, _selection, _include?), do: params
|
||
|
||
defp push_field_selection_url(socket) do
|
||
query_params =
|
||
build_query_params(opts_for_query_params(socket))
|
||
|> maybe_add_field_selection(socket.assigns[:user_field_selection], true)
|
||
|
||
new_path = ~p"/members?#{query_params}"
|
||
push_patch(socket, to: new_path, replace: true)
|
||
end
|
||
|
||
defp update_session_field_selection(socket, selection) do
|
||
assign(socket, :user_field_selection, selection)
|
||
end
|
||
|
||
defp build_query_params(opts) when is_map(opts) do
|
||
base_params = build_base_params(opts.query, opts.sort_field, opts.sort_order)
|
||
base_params = add_cycle_status_filter(base_params, opts.cycle_status_filter)
|
||
base_params = add_group_filters(base_params, opts.group_filters || %{})
|
||
base_params = add_fee_type_filters(base_params, opts.fee_type_filters || %{})
|
||
base_params = add_show_current_cycle(base_params, opts.show_current_cycle)
|
||
base_params = add_boolean_filters(base_params, opts.boolean_filters || %{})
|
||
add_date_filters(base_params, opts.date_filters)
|
||
end
|
||
|
||
defp add_date_filters(params, date_filters) do
|
||
Map.merge(params, DateFilter.to_params(date_filters))
|
||
end
|
||
|
||
defp opts_for_query_params(socket, overrides \\ %{}) do
|
||
%{
|
||
query: socket.assigns.query,
|
||
sort_field: socket.assigns.sort_field,
|
||
sort_order: socket.assigns.sort_order,
|
||
cycle_status_filter: socket.assigns.cycle_status_filter,
|
||
group_filters: socket.assigns[:group_filters] || %{},
|
||
show_current_cycle: socket.assigns.show_current_cycle,
|
||
boolean_filters: socket.assigns.boolean_custom_field_filters || %{},
|
||
fee_type_filters: socket.assigns[:fee_type_filters] || %{},
|
||
date_filters: socket.assigns.date_filters
|
||
}
|
||
|> Map.merge(overrides)
|
||
end
|
||
|
||
defp add_fee_type_filters(params, fee_type_filters) do
|
||
Enum.reduce(fee_type_filters, params, fn {fee_type_id_str, value}, acc ->
|
||
param_value = if value == :in, do: "in", else: "not_in"
|
||
Map.put(acc, "#{@fee_type_filter_prefix}#{fee_type_id_str}", param_value)
|
||
end)
|
||
end
|
||
|
||
defp compute_final_field_selection(true, url_selection, socket) do
|
||
only_url =
|
||
FieldVisibility.selection_from_url_only(url_selection, socket.assigns.all_custom_fields)
|
||
|
||
visible_members = FieldVisibility.get_visible_member_fields(only_url)
|
||
visible_custom = FieldVisibility.get_visible_custom_fields(only_url)
|
||
|
||
if visible_members == [] and visible_custom == [] do
|
||
# URL had only invalid field names; fall back to session + global.
|
||
compute_final_field_selection(false, url_selection, socket)
|
||
else
|
||
only_url
|
||
end
|
||
end
|
||
|
||
defp compute_final_field_selection(false, url_selection, socket) do
|
||
merged =
|
||
FieldSelection.merge_sources(
|
||
url_selection,
|
||
socket.assigns.user_field_selection,
|
||
%{}
|
||
)
|
||
|
||
FieldVisibility.merge_with_global_settings(
|
||
merged,
|
||
socket.assigns.settings,
|
||
socket.assigns.all_custom_fields
|
||
)
|
||
end
|
||
|
||
# On full page load conn.params has no query string; read "fields" from URI so column visibility is restored.
|
||
defp request_url_from_socket(socket) do
|
||
case socket.private[:connect_info] do
|
||
%Plug.Conn{} = conn -> Plug.Conn.request_url(conn)
|
||
_ -> nil
|
||
end
|
||
end
|
||
|
||
# Parses optional "highlight" URL param (member id for selected row styling). Returns nil if missing or invalid.
|
||
defp parse_highlight_param(nil), do: nil
|
||
defp parse_highlight_param(""), do: nil
|
||
|
||
defp parse_highlight_param(id) when is_binary(id) do
|
||
if String.length(id) <= @max_uuid_length and match?({:ok, _}, Ecto.UUID.cast(id)),
|
||
do: id,
|
||
else: nil
|
||
end
|
||
|
||
defp parse_highlight_param(_), do: nil
|
||
|
||
defp merge_fields_param_from_uri(params, nil), do: params
|
||
|
||
defp merge_fields_param_from_uri(params, %URI{query: query}) when is_binary(query) do
|
||
case URI.decode_query(query)["fields"] do
|
||
nil -> params
|
||
value -> Map.put(params, "fields", value)
|
||
end
|
||
end
|
||
|
||
defp merge_fields_param_from_uri(params, %URI{}), do: params
|
||
|
||
defp merge_fields_param_from_uri(params, url) when is_binary(url) do
|
||
case URI.parse(url).query do
|
||
nil ->
|
||
params
|
||
|
||
q ->
|
||
case URI.decode_query(q)["fields"] do
|
||
nil -> params
|
||
value -> Map.put(params, "fields", value)
|
||
end
|
||
end
|
||
end
|
||
|
||
defp merge_fields_param_from_uri(params, _), do: params
|
||
|
||
defp build_base_params(query, sort_field, sort_order) do
|
||
%{
|
||
"query" => query || "",
|
||
"sort_field" => normalize_sort_field(sort_field),
|
||
"sort_order" => normalize_sort_order(sort_order)
|
||
}
|
||
end
|
||
|
||
defp normalize_sort_field(nil), do: ""
|
||
defp normalize_sort_field(field) when is_atom(field), do: Atom.to_string(field)
|
||
defp normalize_sort_field(field) when is_binary(field), do: field
|
||
defp normalize_sort_field(_), do: ""
|
||
|
||
defp normalize_sort_order(nil), do: ""
|
||
defp normalize_sort_order(order) when is_atom(order), do: Atom.to_string(order)
|
||
defp normalize_sort_order(order) when is_binary(order), do: order
|
||
defp normalize_sort_order(_), do: ""
|
||
|
||
defp add_group_filters(params, group_filters) do
|
||
Enum.reduce(group_filters, params, fn {group_id_str, value}, acc ->
|
||
param_value = if value == :in, do: "in", else: "not_in"
|
||
Map.put(acc, "#{@group_filter_prefix}#{group_id_str}", param_value)
|
||
end)
|
||
end
|
||
|
||
defp add_cycle_status_filter(params, nil), do: params
|
||
defp add_cycle_status_filter(params, :paid), do: Map.put(params, "cycle_status_filter", "paid")
|
||
|
||
defp add_cycle_status_filter(params, :unpaid),
|
||
do: Map.put(params, "cycle_status_filter", "unpaid")
|
||
|
||
defp add_cycle_status_filter(params, _), do: params
|
||
|
||
defp add_show_current_cycle(params, true), do: Map.put(params, "show_current_cycle", "true")
|
||
defp add_show_current_cycle(params, _), do: params
|
||
|
||
defp add_boolean_filters(params, boolean_filters) do
|
||
Enum.reduce(boolean_filters, params, &add_boolean_filter/2)
|
||
end
|
||
|
||
defp add_boolean_filter({custom_field_id, filter_value}, acc) do
|
||
param_key = "#{@boolean_filter_prefix}#{custom_field_id}"
|
||
param_value = if filter_value == true, do: "true", else: "false"
|
||
Map.put(acc, param_key, param_value)
|
||
end
|
||
|
||
# -------------------------------------------------------------
|
||
# Loading members
|
||
# -------------------------------------------------------------
|
||
|
||
defp load_members(socket) do
|
||
search_query = socket.assigns.query
|
||
|
||
query =
|
||
Mv.Membership.Member
|
||
|> Ash.Query.new()
|
||
|> Ash.Query.select(@overview_fields)
|
||
|
||
query = load_custom_field_values(query, compute_ids_to_load(socket))
|
||
|
||
query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle)
|
||
|
||
# Load groups for each member (id, name, slug only)
|
||
query =
|
||
Ash.Query.load(query, groups: [:id, :name, :slug])
|
||
|
||
# Load membership_fee_type when the column is visible or when sorting by it
|
||
query =
|
||
if :membership_fee_type in socket.assigns.member_fields_visible or
|
||
socket.assigns.sort_field in [:membership_fee_type, "membership_fee_type"] do
|
||
Ash.Query.load(query, membership_fee_type: [:id, :name])
|
||
else
|
||
query
|
||
end
|
||
|
||
query = apply_search_filter(query, search_query)
|
||
|
||
query = apply_group_filters(query, socket.assigns[:group_filters], socket.assigns[:groups])
|
||
|
||
query =
|
||
apply_fee_type_filters(query, socket.assigns[:fee_type_filters], socket.assigns[:fee_types])
|
||
|
||
# Built-in date filters (join_date, exit_date) are pushed to the DB so
|
||
# excluded rows never reach the BEAM. The active_only default is part of
|
||
# this — fresh load returns only members without an exit_date or with an
|
||
# exit_date strictly in the future.
|
||
query =
|
||
DateFilter.apply_ash_filter(query, socket.assigns.date_filters)
|
||
|
||
# Use ALL custom fields for sorting (not just show_in_overview subset)
|
||
custom_fields_for_sort = socket.assigns.all_custom_fields
|
||
|
||
{query, sort_after_load} =
|
||
maybe_sort(
|
||
query,
|
||
socket.assigns.sort_field,
|
||
socket.assigns.sort_order,
|
||
custom_fields_for_sort
|
||
)
|
||
|
||
# Errors in handle_params are handled by Phoenix LiveView
|
||
actor = current_actor(socket)
|
||
{time_microseconds, members} = :timer.tc(fn -> Ash.read!(query, actor: actor) end)
|
||
Logger.info("Ash.read! in load_members/1 took #{time_microseconds / 1000} ms")
|
||
|
||
# Custom field values are already filtered at the database level in load_custom_field_values/2
|
||
# No need for in-memory filtering anymore
|
||
|
||
members = apply_in_memory_filters(members, socket)
|
||
|
||
# Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked)
|
||
# Note: :groups is in computed_member_fields() but can be sorted in-memory, so we only block :membership_fee_status
|
||
members =
|
||
if sort_after_load and
|
||
socket.assigns.sort_field != :membership_fee_status do
|
||
sort_members_in_memory(
|
||
members,
|
||
socket.assigns.sort_field,
|
||
socket.assigns.sort_order,
|
||
custom_fields_for_sort
|
||
)
|
||
else
|
||
members
|
||
end
|
||
|
||
assign(socket, :members, members)
|
||
end
|
||
|
||
# Collects every custom field UUID whose values must be loaded for a given
|
||
# render — visible columns plus any active boolean or date filter. Kept as a
|
||
# standalone helper so load_members/1 stays under the credo complexity bar.
|
||
defp compute_ids_to_load(socket) do
|
||
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
|
||
|
||
boolean_custom_fields_map =
|
||
socket.assigns.boolean_custom_fields
|
||
|> Map.new(fn cf -> {to_string(cf.id), cf} end)
|
||
|
||
active_boolean_filter_ids =
|
||
socket.assigns.boolean_custom_field_filters
|
||
|> Map.keys()
|
||
|> Enum.filter(fn id_str ->
|
||
String.length(id_str) <= @max_uuid_length &&
|
||
match?({:ok, _}, Ecto.UUID.cast(id_str)) &&
|
||
Map.has_key?(boolean_custom_fields_map, id_str)
|
||
end)
|
||
|
||
date_custom_fields = socket.assigns[:date_custom_fields] || []
|
||
|
||
active_date_filter_ids =
|
||
DateFilter.active_custom_field_ids(
|
||
socket.assigns.date_filters,
|
||
date_custom_fields
|
||
)
|
||
|
||
(visible_custom_field_ids ++ active_boolean_filter_ids ++ active_date_filter_ids)
|
||
|> Enum.uniq()
|
||
end
|
||
|
||
# Post-DB filtering: cycle status, boolean custom fields, and custom date
|
||
# fields. Date custom fields are last so they see the already-narrowed list.
|
||
defp apply_in_memory_filters(members, socket) do
|
||
members
|
||
|> apply_cycle_status_filter(
|
||
socket.assigns.cycle_status_filter,
|
||
socket.assigns.show_current_cycle
|
||
)
|
||
|> apply_boolean_custom_field_filters(
|
||
socket.assigns.boolean_custom_field_filters,
|
||
socket.assigns.all_custom_fields
|
||
)
|
||
|> DateFilter.apply_in_memory(
|
||
socket.assigns.date_filters,
|
||
socket.assigns[:date_custom_fields] || []
|
||
)
|
||
end
|
||
|
||
defp load_custom_field_values(query, []), do: query
|
||
|
||
defp load_custom_field_values(query, custom_field_ids) do
|
||
custom_field_values_query =
|
||
Mv.Membership.CustomFieldValue
|
||
|> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids))
|
||
|> Ash.Query.load(custom_field: [:id, :name, :value_type])
|
||
|
||
query
|
||
|> Ash.Query.load(custom_field_values: custom_field_values_query)
|
||
end
|
||
|
||
# -------------------------------------------------------------
|
||
# Helper Functions
|
||
# -------------------------------------------------------------
|
||
|
||
defp apply_search_filter(query, search_query) do
|
||
if search_query && String.trim(search_query) != "" do
|
||
query
|
||
|> MemberResource.fuzzy_search(%{query: search_query})
|
||
else
|
||
query
|
||
end
|
||
end
|
||
|
||
# Multiple group filters combine with AND: member must match all selected group conditions.
|
||
defp apply_group_filters(query, group_filters, _groups) when group_filters == %{}, do: query
|
||
|
||
defp apply_group_filters(query, group_filters, groups) do
|
||
valid_ids =
|
||
groups
|
||
|> Enum.map(&normalize_uuid_string(to_string(&1.id)))
|
||
|> Enum.reject(&is_nil/1)
|
||
|> MapSet.new()
|
||
|
||
Enum.reduce(group_filters, query, fn {group_id_str, value}, q ->
|
||
member? = MapSet.member?(valid_ids, group_id_str)
|
||
|
||
if member? do
|
||
apply_one_group_filter(q, group_id_str, value)
|
||
else
|
||
q
|
||
end
|
||
end)
|
||
end
|
||
|
||
defp apply_one_group_filter(query, _group_id_str, nil), do: query
|
||
|
||
defp apply_one_group_filter(query, group_id_str, :in) do
|
||
case Ecto.UUID.cast(group_id_str) do
|
||
{:ok, group_uuid} ->
|
||
Ash.Query.filter(query, expr(exists(member_groups, group_id == ^group_uuid)))
|
||
|
||
_ ->
|
||
query
|
||
end
|
||
end
|
||
|
||
defp apply_one_group_filter(query, group_id_str, :not_in) do
|
||
case Ecto.UUID.cast(group_id_str) do
|
||
{:ok, group_uuid} ->
|
||
Ash.Query.filter(query, expr(not exists(member_groups, group_id == ^group_uuid)))
|
||
|
||
_ ->
|
||
query
|
||
end
|
||
end
|
||
|
||
defp apply_one_group_filter(query, _, _), do: query
|
||
|
||
# Fee type filters: :in selections combine with OR (member has any of the selected types);
|
||
# :not_in selections combine with AND (member must not have type A and not have type B).
|
||
defp apply_fee_type_filters(query, fee_type_filters, _fee_types) when fee_type_filters == %{},
|
||
do: query
|
||
|
||
defp apply_fee_type_filters(query, fee_type_filters, fee_types) do
|
||
valid_ids =
|
||
fee_types
|
||
|> Enum.map(&normalize_uuid_string(to_string(&1.id)))
|
||
|> Enum.reject(&is_nil/1)
|
||
|> MapSet.new()
|
||
|
||
{in_id_strs, not_in_filters} =
|
||
fee_type_filters
|
||
|> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end)
|
||
|> Enum.split_with(fn {_, value} -> value == :in end)
|
||
|
||
in_uuids =
|
||
in_id_strs
|
||
|> Enum.map(fn {id_str, _} -> id_str end)
|
||
|> Enum.map(&Ecto.UUID.cast/1)
|
||
|> Enum.filter(&match?({:ok, _}, &1))
|
||
|> Enum.map(fn {:ok, uuid} -> uuid end)
|
||
|
||
query =
|
||
if in_uuids == [] do
|
||
query
|
||
else
|
||
Ash.Query.filter(query, expr(membership_fee_type_id in ^in_uuids))
|
||
end
|
||
|
||
Enum.reduce(not_in_filters, query, fn {fee_type_id_str, _}, q ->
|
||
apply_one_fee_type_filter(q, fee_type_id_str, :not_in)
|
||
end)
|
||
end
|
||
|
||
defp apply_one_fee_type_filter(query, fee_type_id_str, :not_in) do
|
||
case Ecto.UUID.cast(fee_type_id_str) do
|
||
{:ok, fee_type_uuid} ->
|
||
Ash.Query.filter(
|
||
query,
|
||
expr(membership_fee_type_id != ^fee_type_uuid or is_nil(membership_fee_type_id))
|
||
)
|
||
|
||
_ ->
|
||
query
|
||
end
|
||
end
|
||
|
||
defp apply_cycle_status_filter(members, nil, _show_current), do: members
|
||
|
||
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
|
||
|
||
defp toggle_order(:asc), do: :desc
|
||
defp toggle_order(:desc), do: :asc
|
||
defp toggle_order(nil), do: :asc
|
||
|
||
# Function to sort the column if needed.
|
||
# Only DB member fields and custom fields; computed fields (e.g. membership_fee_status) are never passed to Ash.
|
||
# Returns {query, sort_after_load} where sort_after_load is true if we need to sort in memory.
|
||
defp maybe_sort(query, nil, _order, _custom_fields), do: {query, false}
|
||
defp maybe_sort(query, _field, nil, _custom_fields), do: {query, false}
|
||
|
||
defp maybe_sort(query, field, order, _custom_fields) do
|
||
# :groups is in computed_member_fields() but can be sorted in-memory
|
||
# Only :membership_fee_status should be blocked from sorting
|
||
if field == :membership_fee_status or field == "membership_fee_status" do
|
||
{query, false}
|
||
else
|
||
apply_sort_to_query(query, field, order)
|
||
end
|
||
end
|
||
|
||
defp apply_sort_to_query(query, field, order) do
|
||
cond do
|
||
# Groups sort -> after load (in memory)
|
||
field in [:groups, "groups"] ->
|
||
{query, true}
|
||
|
||
# Membership fee type sort -> by related name at DB
|
||
field in [:membership_fee_type, "membership_fee_type"] ->
|
||
{Ash.Query.sort(query, [{"membership_fee_type.name", order}]), false}
|
||
|
||
# Custom field sort -> after load
|
||
custom_field_sort?(field) ->
|
||
{query, true}
|
||
|
||
# DB field sort (atom)
|
||
is_atom(field) ->
|
||
{Ash.Query.sort(query, [{field, order}]), false}
|
||
|
||
# DB field sort (string) -> convert only if allowed
|
||
is_binary(field) ->
|
||
case safe_member_field_atom_only(field) do
|
||
nil -> {query, false}
|
||
atom -> {Ash.Query.sort(query, [{atom, order}]), false}
|
||
end
|
||
|
||
true ->
|
||
{query, false}
|
||
end
|
||
end
|
||
|
||
defp valid_sort_field?(field) when is_atom(field) do
|
||
# :groups is in computed_member_fields() but can be sorted
|
||
# Only :membership_fee_status should be blocked
|
||
if field == :membership_fee_status do
|
||
false
|
||
else
|
||
valid_sort_field_db_or_custom?(field)
|
||
end
|
||
end
|
||
|
||
defp valid_sort_field?(field) when is_binary(field) do
|
||
# "groups" is in computed_member_fields() but can be sorted
|
||
# Only "membership_fee_status" should be blocked
|
||
if field == "membership_fee_status" do
|
||
false
|
||
else
|
||
valid_sort_field_db_or_custom?(field)
|
||
end
|
||
end
|
||
|
||
defp valid_sort_field_db_or_custom?(field) when is_atom(field) do
|
||
non_sortable_fields = [:notes]
|
||
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
|
||
field in valid_fields or custom_field_sort?(field) or field in [:groups, :membership_fee_type]
|
||
end
|
||
|
||
defp valid_sort_field_db_or_custom?(field) when is_binary(field) do
|
||
normalized =
|
||
cond do
|
||
field == "groups" -> :groups
|
||
field == "membership_fee_type" -> :membership_fee_type
|
||
true -> safe_member_field_atom_only(field)
|
||
end
|
||
|
||
(normalized != nil and valid_sort_field_db_or_custom?(normalized)) or
|
||
custom_field_sort?(field)
|
||
end
|
||
|
||
defp safe_member_field_atom_only(str) do
|
||
allowed = MapSet.new(Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1))
|
||
if MapSet.member?(allowed, str), do: String.to_existing_atom(str), else: nil
|
||
end
|
||
|
||
defp custom_field_sort?(field) when is_atom(field) do
|
||
field_str = Atom.to_string(field)
|
||
String.starts_with?(field_str, @custom_field_prefix)
|
||
end
|
||
|
||
defp custom_field_sort?(field) when is_binary(field) do
|
||
String.starts_with?(field, @custom_field_prefix)
|
||
end
|
||
|
||
defp custom_field_sort?(_), do: false
|
||
|
||
defp extract_custom_field_id(field) when is_atom(field) do
|
||
field |> Atom.to_string() |> extract_custom_field_id()
|
||
end
|
||
|
||
defp extract_custom_field_id(field) when is_binary(field) do
|
||
case String.split(field, @custom_field_prefix) do
|
||
["", id_str] -> id_str
|
||
_ -> nil
|
||
end
|
||
end
|
||
|
||
defp extract_custom_field_id(_), do: nil
|
||
|
||
defp extract_custom_field_ids(visible_custom_fields) do
|
||
Enum.map(visible_custom_fields, fn field_string ->
|
||
case String.split(field_string, @custom_field_prefix) do
|
||
["", id] -> id
|
||
_ -> nil
|
||
end
|
||
end)
|
||
|> Enum.filter(&(&1 != nil))
|
||
end
|
||
|
||
defp sort_members_in_memory(members, field, order, custom_fields) do
|
||
if field in [:groups, "groups"] do
|
||
sort_members_by_groups(members, order)
|
||
else
|
||
custom_field_id_str = extract_custom_field_id(field)
|
||
|
||
case custom_field_id_str do
|
||
nil -> members
|
||
id_str -> sort_members_by_custom_field(members, id_str, order, custom_fields)
|
||
end
|
||
end
|
||
end
|
||
|
||
defp sort_members_by_groups(members, order) do
|
||
# Members with groups first, then by first group name alphabetically (min = first by sort order)
|
||
first_group_name = fn member ->
|
||
(member.groups || [])
|
||
|> Enum.map(& &1.name)
|
||
|> Enum.min(fn -> nil end)
|
||
end
|
||
|
||
members
|
||
|> Enum.sort_by(fn member ->
|
||
name = first_group_name.(member)
|
||
# Nil (no groups) sorts last in asc, first in desc
|
||
{name == nil, name || ""}
|
||
end)
|
||
|> then(fn list -> if order == :desc, do: Enum.reverse(list), else: list end)
|
||
end
|
||
|
||
defp sort_members_by_custom_field(members, id_str, order, custom_fields) do
|
||
custom_field = find_custom_field_by_id(custom_fields, id_str)
|
||
|
||
case custom_field do
|
||
nil -> members
|
||
cf -> sort_members_with_custom_field(members, cf, order)
|
||
end
|
||
end
|
||
|
||
defp find_custom_field_by_id(custom_fields, id_str) do
|
||
Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
|
||
end
|
||
|
||
defp sort_members_with_custom_field(members, custom_field, order) do
|
||
{members_with_values, members_without_values} =
|
||
split_members_by_value_presence(members, custom_field)
|
||
|
||
sorted_with_values = sort_members_with_values(members_with_values, custom_field, order)
|
||
sorted_with_values ++ members_without_values
|
||
end
|
||
|
||
defp split_members_by_value_presence(members, custom_field) do
|
||
Enum.split_with(members, fn member -> has_non_empty_value?(member, custom_field) end)
|
||
end
|
||
|
||
defp has_non_empty_value?(member, custom_field) do
|
||
case get_custom_field_value(member, custom_field) do
|
||
nil ->
|
||
false
|
||
|
||
cfv ->
|
||
extracted = extract_sort_value(cfv.value, custom_field.value_type)
|
||
not empty_value?(extracted, custom_field.value_type)
|
||
end
|
||
end
|
||
|
||
defp sort_members_with_values(members_with_values, custom_field, order) do
|
||
sorted =
|
||
Enum.sort_by(members_with_values, fn member ->
|
||
cfv = get_custom_field_value(member, custom_field)
|
||
extracted = extract_sort_value(cfv.value, custom_field.value_type)
|
||
normalize_sort_value(extracted, order)
|
||
end)
|
||
|
||
if order == :desc, do: Enum.reverse(sorted), else: sorted
|
||
end
|
||
|
||
defp extract_sort_value(%Ash.Union{value: value, type: type}, _expected_type),
|
||
do: extract_sort_value(value, type)
|
||
|
||
defp extract_sort_value(value, :string) when is_binary(value), do: value
|
||
defp extract_sort_value(value, :integer) when is_integer(value), do: value
|
||
defp extract_sort_value(value, :boolean) when is_boolean(value), do: value
|
||
defp extract_sort_value(%Date{} = date, :date), do: date
|
||
defp extract_sort_value(value, :email) when is_binary(value), do: value
|
||
defp extract_sort_value(value, _type), do: to_string(value)
|
||
|
||
defp empty_value?(value, :string) when is_binary(value), do: String.trim(value) == ""
|
||
defp empty_value?(value, :email) when is_binary(value), do: String.trim(value) == ""
|
||
defp empty_value?(_value, _type), do: false
|
||
|
||
defp normalize_sort_value(value, _order), do: value
|
||
|
||
defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do
|
||
field = determine_field(socket.assigns.sort_field, sf)
|
||
order = determine_order(socket.assigns.sort_order, so)
|
||
old_field = socket.assigns.sort_field
|
||
|
||
socket
|
||
|> assign(:sort_field, field)
|
||
|> assign(:sort_order, order)
|
||
|> assign(:sort_needs_update, old_field != field or socket.assigns.sort_order != order)
|
||
|> assign(:previous_sort_field, old_field)
|
||
end
|
||
|
||
defp maybe_update_sort(socket, _), do: socket
|
||
|
||
defp determine_field(default, ""), do: default
|
||
defp determine_field(default, nil), do: default
|
||
|
||
defp determine_field(default, sf) when is_binary(sf) do
|
||
# Handle "groups" specially - it's in computed_member_fields() but can be sorted
|
||
if sf == "groups" do
|
||
:groups
|
||
else
|
||
computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1)
|
||
|
||
if sf in computed_strings,
|
||
do: default,
|
||
else: determine_field_after_computed_check(default, sf)
|
||
end
|
||
end
|
||
|
||
defp determine_field(default, sf) when is_atom(sf) do
|
||
# Handle :groups specially - it's in computed_member_fields() but can be sorted
|
||
if sf == :groups do
|
||
:groups
|
||
else
|
||
if sf in FieldVisibility.computed_member_fields(),
|
||
do: default,
|
||
else: determine_field_after_computed_check(default, sf)
|
||
end
|
||
end
|
||
|
||
defp determine_field(default, _), do: default
|
||
|
||
defp determine_field_after_computed_check(default, sf) when is_binary(sf) do
|
||
cond do
|
||
sf == "groups" ->
|
||
:groups
|
||
|
||
custom_field_sort?(sf) ->
|
||
if valid_sort_field?(sf), do: sf, else: default
|
||
|
||
true ->
|
||
atom = safe_member_field_atom_only(sf)
|
||
if atom != nil and valid_sort_field?(atom), do: atom, else: default
|
||
end
|
||
end
|
||
|
||
defp determine_field_after_computed_check(default, sf) when is_atom(sf) do
|
||
if valid_sort_field?(sf), do: sf, else: default
|
||
end
|
||
|
||
defp determine_order(default, so) do
|
||
case so do
|
||
"" -> default
|
||
nil -> default
|
||
so when so in ["asc", "desc"] -> String.to_atom(so)
|
||
_ -> default
|
||
end
|
||
end
|
||
|
||
defp maybe_update_search(socket, %{"query" => query}) when query != "",
|
||
do: assign(socket, :query, query)
|
||
|
||
defp maybe_update_search(socket, _params), do: socket
|
||
|
||
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_cycle_status_filter(socket, _params),
|
||
do: assign(socket, :cycle_status_filter, nil)
|
||
|
||
defp maybe_update_group_filters(socket, params) when is_map(params) do
|
||
prefix = @group_filter_prefix
|
||
prefix_len = String.length(prefix)
|
||
|
||
group_param_entries =
|
||
params
|
||
|> Enum.filter(fn {key, _} ->
|
||
key_str = to_string(key)
|
||
String.starts_with?(key_str, prefix)
|
||
end)
|
||
|
||
filters =
|
||
Enum.reduce(group_param_entries, %{}, fn {key, value_str}, acc ->
|
||
add_group_filter_entry(acc, key, value_str, prefix_len)
|
||
end)
|
||
|
||
valid_group_ids =
|
||
socket.assigns.groups
|
||
|> Enum.map(&normalize_uuid_string(to_string(&1.id)))
|
||
|> Enum.reject(&is_nil/1)
|
||
|> MapSet.new()
|
||
|> MapSet.to_list()
|
||
|
||
assign(socket, :group_filters, Map.take(filters, valid_group_ids))
|
||
end
|
||
|
||
defp maybe_update_fee_type_filters(socket, params) when is_map(params) do
|
||
prefix = @fee_type_filter_prefix
|
||
prefix_len = String.length(prefix)
|
||
|
||
fee_type_param_entries =
|
||
params
|
||
|> Enum.filter(fn {key, _} ->
|
||
key_str = to_string(key)
|
||
String.starts_with?(key_str, prefix)
|
||
end)
|
||
|
||
filters =
|
||
Enum.reduce(fee_type_param_entries, %{}, fn {key, value_str}, acc ->
|
||
add_fee_type_filter_entry(acc, key, value_str, prefix_len)
|
||
end)
|
||
|
||
valid_fee_type_ids =
|
||
socket.assigns.fee_types
|
||
|> Enum.map(&normalize_uuid_string(to_string(&1.id)))
|
||
|> Enum.reject(&is_nil/1)
|
||
|> MapSet.new()
|
||
|> MapSet.to_list()
|
||
|
||
assign(socket, :fee_type_filters, Map.take(filters, valid_fee_type_ids))
|
||
end
|
||
|
||
defp add_fee_type_filter_entry(acc, key, value_str, prefix_len) do
|
||
key_str = to_string(key)
|
||
raw_id = String.slice(key_str, prefix_len, String.length(key_str) - prefix_len)
|
||
fee_type_id_str = normalize_uuid_string(raw_id)
|
||
valid_id? = fee_type_id_str && String.length(fee_type_id_str) <= @max_uuid_length
|
||
|
||
if valid_id? do
|
||
case FilterParams.parse_in_not_in_value(value_str) do
|
||
nil -> acc
|
||
value -> Map.put(acc, fee_type_id_str, value)
|
||
end
|
||
else
|
||
acc
|
||
end
|
||
end
|
||
|
||
defp add_group_filter_entry(acc, key, value_str, prefix_len) do
|
||
key_str = to_string(key)
|
||
raw_id = String.slice(key_str, prefix_len, String.length(key_str) - prefix_len)
|
||
group_id_str = normalize_uuid_string(raw_id)
|
||
valid_id? = group_id_str && String.length(group_id_str) <= @max_uuid_length
|
||
|
||
if valid_id? do
|
||
case FilterParams.parse_in_not_in_value(value_str) do
|
||
nil -> acc
|
||
value -> Map.put(acc, group_id_str, value)
|
||
end
|
||
else
|
||
acc
|
||
end
|
||
end
|
||
|
||
# Normalize UUID string so URL params match valid_ids (lowercase, canonical format)
|
||
defp normalize_uuid_string(raw) when is_binary(raw) do
|
||
case Ecto.UUID.cast(String.trim(raw)) do
|
||
{:ok, uuid} -> to_string(uuid)
|
||
_ -> raw
|
||
end
|
||
end
|
||
|
||
defp normalize_uuid_string(_), do: nil
|
||
|
||
defp determine_cycle_status_filter("paid"), do: :paid
|
||
defp determine_cycle_status_filter("unpaid"), do: :unpaid
|
||
defp determine_cycle_status_filter(_), do: nil
|
||
|
||
defp maybe_update_boolean_filters(socket, params) do
|
||
boolean_custom_fields =
|
||
socket.assigns.all_custom_fields
|
||
|> Enum.filter(&(&1.value_type == :boolean))
|
||
|> Map.new(fn cf -> {to_string(cf.id), cf} end)
|
||
|
||
prefix_length = String.length(@boolean_filter_prefix)
|
||
|
||
{filters, total_processed} =
|
||
params
|
||
|> Enum.filter(fn {key, _value} -> String.starts_with?(key, @boolean_filter_prefix) end)
|
||
|> Enum.reduce_while({%{}, 0}, fn {key, value_str}, {acc, count} ->
|
||
if count >= @max_boolean_filters do
|
||
{:halt, {acc, count}}
|
||
else
|
||
new_acc =
|
||
process_boolean_filter_param(
|
||
key,
|
||
value_str,
|
||
prefix_length,
|
||
boolean_custom_fields,
|
||
acc
|
||
)
|
||
|
||
{:cont, {new_acc, count + 1}}
|
||
end
|
||
end)
|
||
|
||
if total_processed >= @max_boolean_filters do
|
||
Logger.warning(
|
||
"Boolean filter limit reached: processed #{total_processed} parameters, accepted #{map_size(filters)} valid filters (max: #{@max_boolean_filters})"
|
||
)
|
||
end
|
||
|
||
assign(socket, :boolean_custom_field_filters, filters)
|
||
end
|
||
|
||
defp process_boolean_filter_param(key, value_str, prefix_length, boolean_custom_fields, acc) do
|
||
custom_field_id_str = String.slice(key, prefix_length, String.length(key) - prefix_length)
|
||
|
||
if String.length(custom_field_id_str) > @max_uuid_length do
|
||
acc
|
||
else
|
||
validate_and_add_boolean_filter(custom_field_id_str, value_str, boolean_custom_fields, acc)
|
||
end
|
||
end
|
||
|
||
defp validate_and_add_boolean_filter(custom_field_id_str, value_str, boolean_custom_fields, acc) do
|
||
case Ecto.UUID.cast(custom_field_id_str) do
|
||
{:ok, _custom_field_id} ->
|
||
add_boolean_filter_if_valid(custom_field_id_str, value_str, boolean_custom_fields, acc)
|
||
|
||
:error ->
|
||
acc
|
||
end
|
||
end
|
||
|
||
defp add_boolean_filter_if_valid(custom_field_id_str, value_str, boolean_custom_fields, acc) do
|
||
if Map.has_key?(boolean_custom_fields, custom_field_id_str) do
|
||
case determine_boolean_filter(value_str) do
|
||
nil -> acc
|
||
filter_value -> Map.put(acc, custom_field_id_str, filter_value)
|
||
end
|
||
else
|
||
acc
|
||
end
|
||
end
|
||
|
||
defp determine_boolean_filter("true"), do: true
|
||
defp determine_boolean_filter("false"), do: false
|
||
defp determine_boolean_filter(_), do: nil
|
||
|
||
defp maybe_update_show_current_cycle(socket, %{"show_current_cycle" => "true"}),
|
||
do: assign(socket, :show_current_cycle, true)
|
||
|
||
defp maybe_update_show_current_cycle(socket, _params), do: socket
|
||
|
||
# URL params are the source of truth for filter state on every navigation.
|
||
# When no date filter params are present, this falls through to the
|
||
# active_only default — exactly the spec behavior for fresh load (§1.1).
|
||
defp maybe_update_date_filters(socket, params) when is_map(params) do
|
||
date_custom_fields = socket.assigns[:date_custom_fields] || []
|
||
assign(socket, :date_filters, DateFilter.from_params(params, date_custom_fields))
|
||
end
|
||
|
||
# -------------------------------------------------------------
|
||
# Custom Field Value Helpers
|
||
# -------------------------------------------------------------
|
||
|
||
def get_custom_field_value(member, custom_field) do
|
||
CustomFieldValueLookup.find_by_field(member, custom_field)
|
||
end
|
||
|
||
def get_boolean_custom_field_value(member, custom_field) do
|
||
case get_custom_field_value(member, custom_field) do
|
||
nil -> nil
|
||
cfv -> extract_boolean_value(cfv.value)
|
||
end
|
||
end
|
||
|
||
defp extract_boolean_value(%Ash.Union{value: value, type: :boolean}),
|
||
do: extract_boolean_value(value)
|
||
|
||
defp extract_boolean_value(value) when is_map(value) do
|
||
type = Map.get(value, "type") || Map.get(value, "_union_type")
|
||
val = Map.get(value, "value") || Map.get(value, "_union_value")
|
||
|
||
if type == "boolean" or type == :boolean do
|
||
extract_boolean_value(val)
|
||
else
|
||
nil
|
||
end
|
||
end
|
||
|
||
defp extract_boolean_value(value) when is_boolean(value), do: value
|
||
defp extract_boolean_value(nil), do: nil
|
||
defp extract_boolean_value(_), do: nil
|
||
|
||
def apply_boolean_custom_field_filters(members, filters, _all_custom_fields)
|
||
when map_size(filters) == 0 do
|
||
members
|
||
end
|
||
|
||
def apply_boolean_custom_field_filters(members, filters, all_custom_fields) do
|
||
valid_custom_field_ids =
|
||
all_custom_fields
|
||
|> Enum.filter(&(&1.value_type == :boolean))
|
||
|> MapSet.new(fn cf -> to_string(cf.id) end)
|
||
|
||
valid_filters =
|
||
Enum.filter(filters, fn {custom_field_id_str, _value} ->
|
||
MapSet.member?(valid_custom_field_ids, custom_field_id_str)
|
||
end)
|
||
|> Enum.into(%{})
|
||
|
||
if map_size(valid_filters) == 0 do
|
||
members
|
||
else
|
||
Enum.filter(members, fn member -> matches_all_filters?(member, valid_filters) end)
|
||
end
|
||
end
|
||
|
||
defp matches_all_filters?(member, filters) do
|
||
Enum.all?(filters, fn {custom_field_id_str, filter_value} ->
|
||
matches_filter?(member, custom_field_id_str, filter_value)
|
||
end)
|
||
end
|
||
|
||
defp matches_filter?(member, custom_field_id_str, filter_value) do
|
||
case CustomFieldValueLookup.find_by_id(member, custom_field_id_str) do
|
||
nil -> false
|
||
cfv -> extract_boolean_value(cfv.value) == filter_value
|
||
end
|
||
end
|
||
|
||
def format_selected_member_emails(members, selected_members) do
|
||
members
|
||
|> Enum.filter(fn member ->
|
||
MapSet.member?(selected_members, member.id) && member.email && member.email != ""
|
||
end)
|
||
|> Enum.map(&format_member_email/1)
|
||
end
|
||
|
||
def checkbox_column_click(member), do: JS.push("select_member", value: %{id: member.id})
|
||
|
||
def format_member_email(member) do
|
||
first_name = member.first_name || ""
|
||
last_name = member.last_name || ""
|
||
|
||
name =
|
||
[first_name, last_name]
|
||
|> Enum.filter(&(&1 != ""))
|
||
|> Enum.join(" ")
|
||
|
||
if name == "", do: member.email, else: "#{name} <#{member.email}>"
|
||
end
|
||
|
||
def format_date(date), do: DateFormatter.format_date(date)
|
||
|
||
defp update_selection_assigns(socket) do
|
||
members = socket.assigns[:members] || []
|
||
selected_members = socket.assigns.selected_members
|
||
|
||
selected_count = Enum.count(members, &MapSet.member?(selected_members, &1.id))
|
||
any_selected? = Enum.any?(members, &MapSet.member?(selected_members, &1.id))
|
||
|
||
# Scope drives the trigger label: the selection when present, otherwise the
|
||
# whole list (filtered, when a search term or any filter is active).
|
||
scope =
|
||
cond do
|
||
any_selected? -> :selection
|
||
filters_active?(socket.assigns) -> :filtered
|
||
true -> :all
|
||
end
|
||
|
||
# Copy/Mailto recipients: the members in scope that have a usable email.
|
||
# With a selection that is the selected subset (existing behaviour); without
|
||
# a selection it is every member in scope (deliberate behaviour change). In
|
||
# both cases members without an email are excluded, exactly as today's
|
||
# format_selected_member_emails does for the selection case.
|
||
recipient_emails = scope_member_emails(members, selected_members, any_selected?)
|
||
recipient_count = length(recipient_emails)
|
||
|
||
# RFC 6068: mailto URI params must use %20 for spaces, not + (encode_www_form uses +)
|
||
mailto_bcc =
|
||
recipient_emails
|
||
|> Enum.join(", ")
|
||
|> URI.encode_www_form()
|
||
|> String.replace("+", "%20")
|
||
|
||
mailto_disabled? = recipient_count >= Mv.Constants.max_mailto_bulk_recipients()
|
||
|
||
socket
|
||
|> assign(:selected_count, selected_count)
|
||
|> assign(:scope, scope)
|
||
|> assign(:recipient_count, recipient_count)
|
||
|> assign(:mailto_disabled?, mailto_disabled?)
|
||
|> assign(:mailto_bcc, mailto_bcc)
|
||
|> assign_export_payload()
|
||
end
|
||
|
||
# Returns the formatted "Name <email>" recipient list for the current scope:
|
||
# the selected members when any are selected, otherwise every member in the
|
||
# (filtered) list. Members without an email are excluded in both cases.
|
||
defp scope_member_emails(members, selected_members, true = _any_selected?),
|
||
do: format_selected_member_emails(members, selected_members)
|
||
|
||
defp scope_member_emails(members, _selected_members, false = _any_selected?) do
|
||
members
|
||
|> Enum.filter(fn member -> member.email && member.email != "" end)
|
||
|> Enum.map(&format_member_email/1)
|
||
end
|
||
|
||
@doc """
|
||
Returns true when the member list is restricted by a non-empty search term or
|
||
any active filter (cycle status, group, fee type, boolean custom field, or a
|
||
date filter differing from the default). Drives the "(gefiltert)" vs "(alle)"
|
||
trigger label and reads only assigns — no DB access.
|
||
"""
|
||
def filters_active?(assigns) do
|
||
search_active?(assigns) or selection_filters_active?(assigns) or date_filter_active?(assigns)
|
||
end
|
||
|
||
defp search_active?(assigns) do
|
||
query = assigns[:query]
|
||
is_binary(query) and query != ""
|
||
end
|
||
|
||
defp selection_filters_active?(assigns) do
|
||
not is_nil(assigns[:cycle_status_filter]) or
|
||
map_size(assigns[:group_filters] || %{}) > 0 or
|
||
map_size(assigns[:fee_type_filters] || %{}) > 0 or
|
||
map_size(assigns[:boolean_custom_field_filters] || %{}) > 0
|
||
end
|
||
|
||
defp date_filter_active?(assigns) do
|
||
(assigns[:date_filters] || DateFilter.default()) != DateFilter.default()
|
||
end
|
||
|
||
defp assign_export_payload(socket) do
|
||
payload = build_export_payload(socket)
|
||
assign(socket, :export_payload_json, Jason.encode!(payload))
|
||
end
|
||
|
||
defp build_export_payload(socket) do
|
||
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
|
||
|
||
member_fields_db = socket.assigns[:member_fields_visible_db] || []
|
||
member_fields_computed = socket.assigns[:member_fields_visible_computed] || []
|
||
|
||
# Order DB member fields exactly like the table/constants
|
||
ordered_member_fields_db =
|
||
Mv.Constants.member_fields()
|
||
|> Enum.filter(&(&1 in member_fields_db))
|
||
|
||
# Order computed fields in canonical order
|
||
ordered_computed_fields =
|
||
FieldVisibility.computed_member_fields()
|
||
|> Enum.filter(&(&1 in member_fields_computed))
|
||
|
||
member_fields_with_groups =
|
||
build_export_member_fields_list(
|
||
ordered_member_fields_db,
|
||
socket.assigns[:member_fields_visible]
|
||
)
|
||
|
||
# Order custom fields like the table (same as dynamic_cols / all_custom_fields order)
|
||
ordered_custom_field_ids =
|
||
socket.assigns.all_custom_fields
|
||
|> Enum.map(&to_string(&1.id))
|
||
|> Enum.filter(&(&1 in visible_custom_field_ids))
|
||
|
||
%{
|
||
selected_ids: socket.assigns.selected_members |> MapSet.to_list(),
|
||
member_fields:
|
||
Enum.map(member_fields_with_groups, fn
|
||
f when is_atom(f) -> Atom.to_string(f)
|
||
f when is_binary(f) -> f
|
||
end),
|
||
computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1),
|
||
custom_field_ids: ordered_custom_field_ids,
|
||
column_order:
|
||
export_column_order(
|
||
ordered_member_fields_db,
|
||
ordered_computed_fields,
|
||
ordered_custom_field_ids,
|
||
:membership_fee_type in socket.assigns[:member_fields_visible],
|
||
:groups in socket.assigns[:member_fields_visible]
|
||
),
|
||
query: socket.assigns[:query] || nil,
|
||
sort_field: export_sort_field(socket.assigns[:sort_field]),
|
||
sort_order: export_sort_order(socket.assigns[:sort_order]),
|
||
show_current_cycle: socket.assigns[:show_current_cycle] || false,
|
||
cycle_status_filter: export_cycle_status_filter(socket.assigns[:cycle_status_filter]),
|
||
boolean_filters: socket.assigns[:boolean_custom_field_filters] || %{}
|
||
}
|
||
end
|
||
|
||
defp expand_db_string_for_export(f, membership_fee_type_visible, computed_strings) do
|
||
if f == "membership_fee_start_date" do
|
||
extra =
|
||
if(membership_fee_type_visible, do: ["membership_fee_type"], else: []) ++
|
||
if "membership_fee_status" in computed_strings, do: ["membership_fee_status"], else: []
|
||
|
||
[f] ++ extra
|
||
else
|
||
[f]
|
||
end
|
||
end
|
||
|
||
defp build_export_member_fields_list(ordered_db, member_fields_visible) do
|
||
with_extras =
|
||
Enum.flat_map(ordered_db, fn f ->
|
||
if f == :membership_fee_start_date and
|
||
:membership_fee_type in (member_fields_visible || []) do
|
||
[f, :membership_fee_type]
|
||
else
|
||
[f]
|
||
end
|
||
end)
|
||
|
||
# If fee type is visible but start_date was not in the list, append it
|
||
with_extras =
|
||
if :membership_fee_type in (member_fields_visible || []) and
|
||
:membership_fee_type not in with_extras do
|
||
with_extras ++ [:membership_fee_type]
|
||
else
|
||
with_extras
|
||
end
|
||
|
||
if :groups in (member_fields_visible || []), do: with_extras ++ [:groups], else: with_extras
|
||
end
|
||
|
||
defp export_cycle_status_filter(nil), do: nil
|
||
defp export_cycle_status_filter(:paid), do: "paid"
|
||
defp export_cycle_status_filter(:unpaid), do: "unpaid"
|
||
defp export_cycle_status_filter(_), do: nil
|
||
|
||
defp export_sort_field(nil), do: nil
|
||
defp export_sort_field(f) when is_atom(f), do: Atom.to_string(f)
|
||
defp export_sort_field(f) when is_binary(f), do: f
|
||
|
||
defp export_sort_order(nil), do: nil
|
||
defp export_sort_order(:asc), do: "asc"
|
||
defp export_sort_order(:desc), do: "desc"
|
||
defp export_sort_order(o) when is_binary(o), do: o
|
||
# Build a single ordered list that matches the table order:
|
||
# - DB fields in Mv.Constants.member_fields() order (already pre-filtered as ordered_member_fields_db)
|
||
# - membership_fee_type and membership_fee_status inserted after membership_fee_start_date when visible
|
||
# - groups appended before custom fields when visible
|
||
# - custom fields appended in the same order as table (already ordered_custom_field_ids)
|
||
defp export_column_order(
|
||
ordered_member_fields_db,
|
||
ordered_computed_fields,
|
||
ordered_custom_field_ids,
|
||
membership_fee_type_visible,
|
||
groups_visible
|
||
) do
|
||
db_strings = Enum.map(ordered_member_fields_db, &Atom.to_string/1)
|
||
computed_strings = Enum.map(ordered_computed_fields, &Atom.to_string/1)
|
||
|
||
# Place membership_fee_type and membership_fee_status after membership_fee_start_date when present
|
||
db_with_extras =
|
||
Enum.flat_map(
|
||
db_strings,
|
||
&expand_db_string_for_export(&1, membership_fee_type_visible, computed_strings)
|
||
)
|
||
|
||
# If fee type is visible but start_date was not in the list, append it before computed/groups
|
||
db_with_extras =
|
||
if membership_fee_type_visible and "membership_fee_type" not in db_with_extras do
|
||
db_with_extras ++ ["membership_fee_type"]
|
||
else
|
||
db_with_extras
|
||
end
|
||
|
||
# Any remaining computed fields not inserted above (future-proof)
|
||
remaining_computed =
|
||
computed_strings
|
||
|> Enum.reject(&(&1 in db_with_extras))
|
||
|
||
result = db_with_extras ++ remaining_computed
|
||
result = if groups_visible, do: result ++ ["groups"], else: result
|
||
result ++ ordered_custom_field_ids
|
||
end
|
||
end
|