feat(overview): replace filter panel with add-filter builder and aging column
Swaps the unbounded vertical filter panel for a Polaris-style add-filter builder (searchable grouped field picker, type-aware value control, applied chips) and colour-codes the fees column with each member's period-scoped unpaid-cycle count.
This commit is contained in:
parent
7d1a71b1fd
commit
c547ca19af
35 changed files with 2817 additions and 3149 deletions
|
|
@ -24,14 +24,14 @@ defmodule MvWeb.MemberLive.Index do
|
|||
- `select_row_and_navigate` - open a member's show page
|
||||
- `load_more` - fetch and append the next keyset page (infinite scroll)
|
||||
- `sort` - change the active sort column/direction
|
||||
- `toggle_cycle_view` - switch the payment-status view between current and last cycle
|
||||
- `copy_emails` - copy emails of the selected members, or of the whole filtered set when none are selected
|
||||
|
||||
## Messages (`handle_info`, from the filter/search/view-settings components)
|
||||
- `search_changed`, `view_setting_toggled`, `field_toggled`, `fields_selected`,
|
||||
`fields_reset`, `reset_all_filters`, and the per-filter change messages
|
||||
(`group_filter_changed`, `fee_type_filter_changed`, `boolean_filter_changed`,
|
||||
`date_filters_changed`, `payment_filter_changed`)
|
||||
`fields_reset`, `reset_all_filters`, `clear_all_filters`, and the per-filter
|
||||
change messages (`group_filter_changed`, `fee_type_filter_changed`,
|
||||
`boolean_filter_changed`, `date_filters_changed`, `payment_filter_changed`,
|
||||
`payment_period_changed`)
|
||||
|
||||
## Implementation Notes
|
||||
- Filtering/sorting/pagination run in PostgreSQL via Ash; the socket holds only
|
||||
|
|
@ -58,6 +58,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
alias MvWeb.MemberLive.Index.Formatter
|
||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||
alias MvWeb.MemberLive.Index.OverviewQuery
|
||||
alias MvWeb.MemberLive.Index.PaymentAging
|
||||
alias MvWeb.MemberLive.Index.ViewSettings
|
||||
|
||||
require Ash.Query
|
||||
|
|
@ -174,7 +175,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> assign(:query, "")
|
||||
|> assign_new(:sort_field, fn -> :first_name end)
|
||||
|> assign_new(:sort_order, fn -> :asc end)
|
||||
|> assign(:cycle_status_filter, nil)
|
||||
|> assign(:payment_filter, nil)
|
||||
|> assign(:payment_period, PaymentAging.default_period())
|
||||
|> assign(:group_filters, %{})
|
||||
|> assign(:groups, groups)
|
||||
|> assign(:fee_type_filters, %{})
|
||||
|
|
@ -203,8 +205,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
:member_fields_visible_computed,
|
||||
FieldVisibility.get_visible_member_fields_computed(initial_selection)
|
||||
)
|
||||
|> assign(:show_current_cycle, false)
|
||||
|> assign(:membership_fee_status_filter, nil)
|
||||
|> assign(:page, nil)
|
||||
|> assign(:after_cursor, nil)
|
||||
|> assign(:more?, false)
|
||||
|
|
@ -309,29 +309,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> update_selection_assigns()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_cycle_view", _params, socket) do
|
||||
new_show_current = !socket.assigns.show_current_cycle
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:show_current_cycle, new_show_current)
|
||||
|> load_members()
|
||||
|> scroll_list_to_top()
|
||||
|> 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_reload(socket, new_path)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("copy_emails", _params, socket) do
|
||||
# Recipients follow the current scope, re-queried from the DB so a no-selection
|
||||
|
|
@ -449,13 +426,13 @@ defmodule MvWeb.MemberLive.Index do
|
|||
def handle_info({:payment_filter_changed, filter}, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:cycle_status_filter, filter)
|
||||
|> assign(:payment_filter, filter)
|
||||
|> load_members()
|
||||
|> scroll_list_to_top()
|
||||
|> update_selection_assigns()
|
||||
|
||||
query_params =
|
||||
build_query_params(opts_for_query_params(socket, %{cycle_status_filter: filter}))
|
||||
build_query_params(opts_for_query_params(socket, %{payment_filter: filter}))
|
||||
|> maybe_add_field_selection(
|
||||
socket.assigns[:user_field_selection],
|
||||
socket.assigns[:fields_in_url?] || false
|
||||
|
|
@ -465,6 +442,42 @@ defmodule MvWeb.MemberLive.Index do
|
|||
{:noreply, push_reload(socket, new_path)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:payment_period_changed, period}, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:payment_period, period)
|
||||
|> load_members()
|
||||
|> scroll_list_to_top()
|
||||
|> update_selection_assigns()
|
||||
|
||||
query_params =
|
||||
build_query_params(opts_for_query_params(socket, %{payment_period: period}))
|
||||
|> maybe_add_field_selection(
|
||||
socket.assigns[:user_field_selection],
|
||||
socket.assigns[:fields_in_url?] || false
|
||||
)
|
||||
|
||||
new_path = ~p"/members?#{query_params}"
|
||||
{:noreply, push_reload(socket, new_path)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:clear_all_filters}, socket) do
|
||||
handle_info(
|
||||
{:reset_all_filters,
|
||||
%{
|
||||
payment_filter: nil,
|
||||
payment_period: PaymentAging.default_period(),
|
||||
group_filters: %{},
|
||||
fee_type_filters: %{},
|
||||
boolean_filters: %{},
|
||||
date_filters: DateFilter.default()
|
||||
}},
|
||||
socket
|
||||
)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:boolean_filter_changed, custom_field_id_str, filter_value}, socket) do
|
||||
updated_filters =
|
||||
|
|
@ -573,7 +586,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
def handle_info({:reset_all_filters, %{} = opts}, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:cycle_status_filter, Map.get(opts, :cycle_status_filter))
|
||||
|> assign(:payment_filter, Map.get(opts, :payment_filter))
|
||||
|> assign(:payment_period, Map.get(opts, :payment_period, PaymentAging.default_period()))
|
||||
|> 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, %{}))
|
||||
|
|
@ -663,12 +677,12 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket
|
||||
|> maybe_update_search(params)
|
||||
|> maybe_update_sort(params)
|
||||
|> maybe_update_cycle_status_filter(params)
|
||||
|> maybe_update_payment_filter(params)
|
||||
|> maybe_update_payment_period(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_visibility_derivations(final_selection)
|
||||
|
|
@ -712,10 +726,10 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket.assigns.query,
|
||||
socket.assigns.sort_field,
|
||||
socket.assigns.sort_order,
|
||||
socket.assigns.cycle_status_filter,
|
||||
socket.assigns.payment_filter,
|
||||
socket.assigns.payment_period,
|
||||
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] || [],
|
||||
|
|
@ -866,14 +880,21 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
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_payment_params(base_params, opts.payment_filter, opts.payment_period)
|
||||
base_params = add_boolean_filters(base_params, opts.boolean_filters || %{})
|
||||
add_date_filters(base_params, opts.date_filters)
|
||||
end
|
||||
|
||||
# Period-scoped payment model (§3.3): the payment-count filter and the active
|
||||
# period both serialize into the flat URL contract (pay_filter/pay_from/pay_to).
|
||||
defp add_payment_params(params, payment_filter, payment_period) do
|
||||
params
|
||||
|> Map.merge(PaymentAging.filter_to_params(payment_filter))
|
||||
|> Map.merge(PaymentAging.to_params(payment_period || PaymentAging.default_period()))
|
||||
end
|
||||
|
||||
defp add_date_filters(params, date_filters) do
|
||||
Map.merge(params, DateFilter.to_params(date_filters))
|
||||
end
|
||||
|
|
@ -883,9 +904,9 @@ defmodule MvWeb.MemberLive.Index 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,
|
||||
payment_filter: socket.assigns.payment_filter,
|
||||
payment_period: socket.assigns.payment_period,
|
||||
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
|
||||
|
|
@ -1187,17 +1208,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
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
|
||||
|
|
@ -1319,10 +1329,19 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> Ash.Query.select(@overview_fields)
|
||||
|> load_custom_field_values(compute_ids_to_load(socket))
|
||||
|> MembershipFeeStatus.load_cycles_for_members()
|
||||
|> load_unpaid_cycle_count(socket)
|
||||
|> Ash.Query.load(groups: [:id, :name, :slug])
|
||||
|> maybe_load_fee_type(socket)
|
||||
end
|
||||
|
||||
# Loads the period-scoped unpaid-cycle count for the overview payment column
|
||||
# (§1.13, §3.3). The count is a DB aggregate over `membership_fee_cycles`
|
||||
# (denormalized `cycle_end`), scoped to the active period.
|
||||
defp load_unpaid_cycle_count(query, socket) do
|
||||
%{from: from, to: to} = socket.assigns.payment_period || PaymentAging.default_period()
|
||||
Ash.Query.load(query, unpaid_cycle_count: %{period_from: from, period_to: to})
|
||||
end
|
||||
|
||||
defp maybe_load_fee_type(query, socket) do
|
||||
if :membership_fee_type in socket.assigns.member_fields_visible or
|
||||
socket.assigns.sort_field in [:membership_fee_type, "membership_fee_type"] do
|
||||
|
|
@ -1345,8 +1364,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
boolean_custom_fields: socket.assigns.boolean_custom_fields,
|
||||
date_filters: socket.assigns.date_filters,
|
||||
date_custom_fields: socket.assigns[:date_custom_fields],
|
||||
cycle_status_filter: socket.assigns.cycle_status_filter,
|
||||
show_current_cycle: socket.assigns.show_current_cycle,
|
||||
payment_filter: socket.assigns.payment_filter,
|
||||
payment_period: socket.assigns.payment_period,
|
||||
sort_field: socket.assigns.sort_field,
|
||||
sort_order: socket.assigns.sort_order,
|
||||
custom_fields: socket.assigns.all_custom_fields
|
||||
|
|
@ -1550,13 +1569,14 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
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
|
||||
# Period-scoped payment model (§3.3): a payment-count filter plus the active
|
||||
# period, both driven purely from the URL so `handle_params` stays the source
|
||||
# of truth. Absent params fall back to no filter / the all-outstanding period.
|
||||
defp maybe_update_payment_filter(socket, params),
|
||||
do: assign(socket, :payment_filter, PaymentAging.parse_filter_params(params))
|
||||
|
||||
defp maybe_update_cycle_status_filter(socket, _params),
|
||||
do: assign(socket, :cycle_status_filter, nil)
|
||||
defp maybe_update_payment_period(socket, params),
|
||||
do: assign(socket, :payment_period, PaymentAging.parse_period(params))
|
||||
|
||||
defp maybe_update_group_filters(socket, params) when is_map(params) do
|
||||
prefix = @group_filter_prefix
|
||||
|
|
@ -1652,10 +1672,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
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
|
||||
|
|
@ -1728,11 +1744,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
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).
|
||||
|
|
@ -1820,6 +1831,23 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
def format_date(date), do: DateFormatter.format_date(date)
|
||||
|
||||
@doc """
|
||||
The payment column header, naming the active aging period (§1.17). The
|
||||
all-outstanding default (both bounds nil) reads just "Payment"; a bounded
|
||||
period appends the range so the scope is always legible.
|
||||
"""
|
||||
def payment_column_label(%{from: nil, to: nil}), do: gettext("Fees")
|
||||
|
||||
def payment_column_label(%{from: from, to: to}) do
|
||||
range = "#{payment_bound(from)}–#{payment_bound(to)}"
|
||||
gettext("Fees · %{range}", range: range)
|
||||
end
|
||||
|
||||
def payment_column_label(_), do: gettext("Fees")
|
||||
|
||||
defp payment_bound(%Date{} = d), do: Date.to_iso8601(d)
|
||||
defp payment_bound(_), do: "…"
|
||||
|
||||
defp update_selection_assigns(socket) do
|
||||
selected_members = socket.assigns.selected_members
|
||||
# The selection may span members beyond the loaded page (after select-all),
|
||||
|
|
@ -1939,7 +1967,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
|
||||
defp selection_filters_active?(assigns) do
|
||||
not is_nil(assigns[:cycle_status_filter]) or
|
||||
not is_nil(assigns[:payment_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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue