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
|
||||
|
|
|
|||
|
|
@ -21,17 +21,38 @@
|
|||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div class="flex flex-wrap gap-4 items-center">
|
||||
<%!-- Two-row overview toolbar (Option C): row 1 is search + column/view
|
||||
controls; row 2 is the dedicated filter row (add-filter + applied chips). --%>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-wrap gap-4 items-center">
|
||||
<.live_component
|
||||
module={MvWeb.Components.SearchBarComponent}
|
||||
id="search-bar"
|
||||
query={@query}
|
||||
placeholder={gettext("Search...")}
|
||||
/>
|
||||
<div class="ml-auto flex items-center gap-4">
|
||||
<.live_component
|
||||
module={MvWeb.Components.FieldVisibilityDropdownComponent}
|
||||
id="field-visibility-dropdown"
|
||||
all_fields={@all_available_fields}
|
||||
custom_fields={@all_custom_fields}
|
||||
selected_fields={@user_field_selection}
|
||||
/>
|
||||
<.live_component
|
||||
module={MvWeb.Components.ViewSettingsDropdownComponent}
|
||||
id="view-settings-dropdown"
|
||||
density={@density}
|
||||
compact_member={@compact_member}
|
||||
member_include_email={@member_include_email}
|
||||
compact_address={@compact_address}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<.live_component
|
||||
module={MvWeb.Components.SearchBarComponent}
|
||||
id="search-bar"
|
||||
query={@query}
|
||||
placeholder={gettext("Search...")}
|
||||
/>
|
||||
<.live_component
|
||||
module={MvWeb.Components.MemberFilterComponent}
|
||||
module={MvWeb.Components.AddFilterBuilderComponent}
|
||||
id="member-filter"
|
||||
cycle_status_filter={@cycle_status_filter}
|
||||
groups={@groups}
|
||||
group_filters={@group_filters}
|
||||
fee_types={@fee_types}
|
||||
|
|
@ -40,57 +61,9 @@
|
|||
boolean_filters={@boolean_custom_field_filters}
|
||||
date_custom_fields={@date_custom_fields}
|
||||
date_filters={@date_filters}
|
||||
member_count={@total_count}
|
||||
payment_filter={@payment_filter}
|
||||
payment_period={@payment_period}
|
||||
/>
|
||||
<.tooltip
|
||||
content={
|
||||
gettext(
|
||||
"Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle."
|
||||
)
|
||||
}
|
||||
position="top"
|
||||
>
|
||||
<.button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
class="gap-2"
|
||||
active={@show_current_cycle}
|
||||
aria-pressed={to_string(@show_current_cycle)}
|
||||
phx-click="toggle_cycle_view"
|
||||
data-testid="toggle-cycle-view"
|
||||
aria-label={
|
||||
if(@show_current_cycle,
|
||||
do: gettext("Current payment cycle"),
|
||||
else: gettext("Last payment cycle")
|
||||
)
|
||||
}
|
||||
>
|
||||
<.icon name="hero-arrow-path" class="h-5 w-5" />
|
||||
<span class="hidden sm:inline">
|
||||
{if(@show_current_cycle,
|
||||
do: gettext("Current payment cycle"),
|
||||
else: gettext("Last payment cycle")
|
||||
)}
|
||||
</span>
|
||||
</.button>
|
||||
</.tooltip>
|
||||
<div class="ml-auto flex items-center gap-4">
|
||||
<.live_component
|
||||
module={MvWeb.Components.FieldVisibilityDropdownComponent}
|
||||
id="field-visibility-dropdown"
|
||||
all_fields={@all_available_fields}
|
||||
custom_fields={@all_custom_fields}
|
||||
selected_fields={@user_field_selection}
|
||||
/>
|
||||
<.live_component
|
||||
module={MvWeb.Components.ViewSettingsDropdownComponent}
|
||||
id="view-settings-dropdown"
|
||||
density={@density}
|
||||
compact_member={@compact_member}
|
||||
member_include_email={@member_include_email}
|
||||
compact_address={@compact_address}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Polite live region: present on first render (before it is filled) so
|
||||
|
|
@ -131,7 +104,6 @@
|
|||
JS.push("select_row_and_navigate", value: %{id: member.id})
|
||||
end
|
||||
}
|
||||
row_tooltip={gettext("Click for member details")}
|
||||
row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end}
|
||||
dynamic_cols={@dynamic_cols}
|
||||
sort_field={@sort_field}
|
||||
|
|
@ -484,17 +456,52 @@
|
|||
<:col
|
||||
:let={member}
|
||||
:if={:membership_fee_status in @member_fields_visible}
|
||||
label={gettext("Membership Fee Status")}
|
||||
label={MvWeb.MemberLive.Index.payment_column_label(@payment_period)}
|
||||
>
|
||||
<%= if badge = MembershipFeeStatus.format_cycle_status_badge(
|
||||
MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle)
|
||||
) do %>
|
||||
<.badge variant={badge.variant}>
|
||||
<.icon name={badge.icon} class="size-4" />
|
||||
{badge.label}
|
||||
</.badge>
|
||||
<% else %>
|
||||
<.empty_cell sr_text={gettext("No cycle")} />
|
||||
<% count = member.unpaid_cycle_count || 0 %>
|
||||
<% badge = PaymentAging.badge(count) %>
|
||||
<% tip_id = "payment-tip-#{member.id}" %>
|
||||
<button
|
||||
type="button"
|
||||
class={["badge badge-soft badge-md cursor-pointer", badge.color_class]}
|
||||
data-variant={badge.variant}
|
||||
phx-hook={count > 0 && "SortTooltip"}
|
||||
id={"payment-badge-#{member.id}"}
|
||||
data-tooltip-id={count > 0 && tip_id}
|
||||
phx-click={JS.navigate(~p"/members/#{member.id}?tab=membership_fees")}
|
||||
aria-label={gettext("%{label} — open payment history", label: badge.label)}
|
||||
data-testid="payment-badge"
|
||||
>
|
||||
<.icon name={badge.icon} class="size-4" />
|
||||
{badge.label}
|
||||
</button>
|
||||
<%= if count > 0 do %>
|
||||
<% cycles = PaymentAging.open_cycles(member, @payment_period) %>
|
||||
<% {shown, overflow} = PaymentAging.tooltip_cycles(cycles) %>
|
||||
<div
|
||||
id={tip_id}
|
||||
popover="manual"
|
||||
role="tooltip"
|
||||
class="m-0 w-fit rounded-box border border-base-300 bg-base-100 p-2 text-sm shadow-xl"
|
||||
data-testid="payment-tooltip"
|
||||
>
|
||||
<ul class="m-0 flex list-none flex-col items-stretch gap-1 p-0">
|
||||
<li :for={cycle <- shown} class="flex">
|
||||
<span class={[
|
||||
"badge badge-soft badge-sm w-full justify-start",
|
||||
PaymentAging.cycle_color_class(cycle.status)
|
||||
]}>
|
||||
<.icon name={PaymentAging.cycle_icon(cycle.status)} class="size-3.5" />
|
||||
{PaymentAging.short_period(cycle)}
|
||||
</span>
|
||||
</li>
|
||||
<li :if={overflow > 0} class="flex">
|
||||
<span class="badge badge-soft badge-sm badge-ghost w-full justify-start">
|
||||
{gettext("+%{count} more", count: overflow)}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
</:col>
|
||||
<:col
|
||||
|
|
|
|||
|
|
@ -100,7 +100,14 @@ defmodule MvWeb.MemberLive.Index.DateFilter do
|
|||
`exit_date` slice is touched; `join_date` and custom-date entries are left
|
||||
intact, so the quick filter and the detailed control share one source.
|
||||
"""
|
||||
@spec set_quick_state(map(), :active | :former | :all) :: map()
|
||||
@spec set_quick_state(map(), :active | :former | :all) :: %{
|
||||
:exit_date => %{
|
||||
from: nil,
|
||||
mode: :active_only | :inactive_only | :all,
|
||||
to: nil
|
||||
},
|
||||
optional(any()) => any()
|
||||
}
|
||||
def set_quick_state(filters, state)
|
||||
when is_map(filters) and state in [:active, :former, :all] do
|
||||
Map.put(filters, :exit_date, %{mode: Map.fetch!(@quick_to_mode, state), from: nil, to: nil})
|
||||
|
|
|
|||
|
|
@ -68,8 +68,12 @@ defmodule MvWeb.MemberLive.Index.ExportPayload do
|
|||
query: assigns[:query] || nil,
|
||||
sort_field: sort_field(assigns[:sort_field]),
|
||||
sort_order: sort_order(assigns[:sort_order]),
|
||||
show_current_cycle: assigns[:show_current_cycle] || false,
|
||||
cycle_status_filter: cycle_status_filter(assigns[:cycle_status_filter]),
|
||||
# Decoupled from the removed overview current/last cycle toggle (§3.3): the
|
||||
# export's fee-status column always uses the current cycle as its basis and
|
||||
# applies no cycle-status row filter (the overview now filters payment via
|
||||
# the period-scoped aging model, which the export does not mirror).
|
||||
show_current_cycle: true,
|
||||
cycle_status_filter: nil,
|
||||
boolean_filters: assigns[:boolean_custom_field_filters] || %{}
|
||||
}
|
||||
end
|
||||
|
|
@ -87,11 +91,6 @@ defmodule MvWeb.MemberLive.Index.ExportPayload do
|
|||
def sort_order(:desc), do: "desc"
|
||||
def sort_order(o) when is_binary(o), do: o
|
||||
|
||||
defp cycle_status_filter(nil), do: nil
|
||||
defp cycle_status_filter(:paid), do: "paid"
|
||||
defp cycle_status_filter(:unpaid), do: "unpaid"
|
||||
defp cycle_status_filter(_), do: nil
|
||||
|
||||
defp build_member_fields_list(ordered_db, member_fields_visible) do
|
||||
member_fields_visible = member_fields_visible || []
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ defmodule MvWeb.MemberLive.Index.FilterDescriptor do
|
|||
def group_for(%{group: group}), do: group
|
||||
|
||||
@doc "The fixed display order of the picker groups."
|
||||
@spec group_order() :: [group()]
|
||||
@spec group_order() :: [group(), ...]
|
||||
def group_order, do: @group_order
|
||||
|
||||
@doc """
|
||||
|
|
|
|||
|
|
@ -47,17 +47,10 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
|
|||
)
|
||||
|> apply_date_filters(opts[:date_filters])
|
||||
|> apply_custom_date_filters(opts[:date_filters], opts[:date_custom_fields])
|
||||
|> apply_cycle_status_filter(
|
||||
opts[:cycle_status_filter],
|
||||
opts[:show_current_cycle],
|
||||
today(opts)
|
||||
)
|
||||
|> apply_payment_filter(opts[:payment_filter], payment_period(opts))
|
||||
|> apply_sort(opts[:sort_field], opts[:sort_order], opts[:custom_fields] || [])
|
||||
end
|
||||
|
||||
defp today(opts), do: opts[:today] || Date.utc_today()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -184,55 +177,6 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
|
|||
|
||||
defp apply_one_boolean_filter(query, _uuid, _bool), do: query
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cycle-status filter (paid/unpaid, current or last-completed cycle)
|
||||
#
|
||||
# Backed by the DB cycle-status aggregates (denormalized `cycle_end`). A member
|
||||
# matches only when its selected-cycle status equals the requested status;
|
||||
# members with no matching cycle (nil aggregate) are excluded — exactly as the
|
||||
# previous in-memory classifier behaved.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
defp apply_cycle_status_filter(query, status, _show_current, _today)
|
||||
when status not in [:paid, :unpaid],
|
||||
do: query
|
||||
|
||||
defp apply_cycle_status_filter(query, status, true = _show_current, today) do
|
||||
# Current cycle: contains today; when several would, the one with the latest
|
||||
# cycle_start wins. Keep members whose winning current cycle has `status`.
|
||||
Ash.Query.filter(
|
||||
query,
|
||||
expr(
|
||||
exists(
|
||||
membership_fee_cycles,
|
||||
cycle_start <= ^today and cycle_end >= ^today and status == ^status and
|
||||
not exists(
|
||||
member.membership_fee_cycles,
|
||||
cycle_start <= ^today and cycle_end >= ^today and
|
||||
cycle_start > parent(cycle_start)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
defp apply_cycle_status_filter(query, status, _show_current, today) do
|
||||
# Last completed cycle: most recent cycle that has ended (cycle_end < today).
|
||||
Ash.Query.filter(
|
||||
query,
|
||||
expr(
|
||||
exists(
|
||||
membership_fee_cycles,
|
||||
cycle_end < ^today and status == ^status and
|
||||
not exists(
|
||||
member.membership_fee_cycles,
|
||||
cycle_end < ^today and cycle_start > parent(cycle_start)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Payment aging filter (period-scoped unpaid-cycle count)
|
||||
#
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
|
|||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Mv.Constants
|
||||
alias MvWeb.Helpers.DateFormatter
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
||||
@type period :: %{from: Date.t() | nil, to: Date.t() | nil}
|
||||
|
|
@ -99,40 +100,111 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
|
|||
do: parse_filter(Map.get(params, @payment_filter_param))
|
||||
|
||||
@doc """
|
||||
Badge descriptor for an unpaid-cycle count. A count of 0 reads "Paid"
|
||||
(success); a positive count reads "N unpaid" (error).
|
||||
Badge descriptor for an unpaid-cycle count. A count of 0 reads "Alle bezahlt"
|
||||
(success/green); a positive count reads "N offen" (error/red). Reuses the
|
||||
established cycle-status color and hero-icon codes so the overview badge reads
|
||||
consistently with the member show page.
|
||||
"""
|
||||
@spec badge(non_neg_integer()) :: %{
|
||||
variant: atom(),
|
||||
color_class: String.t(),
|
||||
icon: String.t(),
|
||||
label: String.t(),
|
||||
count: non_neg_integer()
|
||||
}
|
||||
def badge(0), do: %{variant: :success, label: gettext("Paid"), count: 0}
|
||||
def badge(0) do
|
||||
%{
|
||||
variant: :success,
|
||||
color_class: cycle_color_class(:paid),
|
||||
icon: MembershipFeeHelpers.status_icon(:paid),
|
||||
label: gettext("All paid"),
|
||||
count: 0
|
||||
}
|
||||
end
|
||||
|
||||
def badge(count) when is_integer(count) and count > 0 do
|
||||
%{
|
||||
variant: MembershipFeeHelpers.status_variant(:unpaid),
|
||||
label: gettext("%{count} unpaid", count: count),
|
||||
color_class: cycle_color_class(:unpaid),
|
||||
icon: MembershipFeeHelpers.status_icon(:unpaid),
|
||||
label: gettext("%{count} open", count: count),
|
||||
count: count
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Splits a member's loaded cycles into the open (unpaid) and suspended cycles
|
||||
whose `cycle_end` falls inside `period`, both sorted by `cycle_end`. Paid
|
||||
cycles and cycles outside the period are dropped. Used to render the badge
|
||||
tooltip. Expects `membership_fee_cycles` to be loaded on the member.
|
||||
A member's open cycles for the badge tooltip: the unpaid and suspended cycles
|
||||
whose `cycle_end` falls inside `period`, merged into a single list sorted
|
||||
chronologically by `cycle_start`. Paid cycles and cycles outside the period
|
||||
are dropped. Expects `membership_fee_cycles` to be loaded on the member.
|
||||
"""
|
||||
@spec open_cycles(map(), period()) :: %{unpaid: [map()], suspended: [map()]}
|
||||
@spec open_cycles(map(), period()) :: [map()]
|
||||
def open_cycles(member, %{from: from, to: to}) do
|
||||
cycles = in_period_cycles(member, from, to)
|
||||
|
||||
%{
|
||||
unpaid: cycles |> Enum.filter(&(&1.status == :unpaid)) |> sort_by_end(),
|
||||
suspended: cycles |> Enum.filter(&(&1.status == :suspended)) |> sort_by_end()
|
||||
}
|
||||
member
|
||||
|> in_period_cycles(from, to)
|
||||
|> Enum.filter(&(&1.status in [:unpaid, :suspended]))
|
||||
|> Enum.sort_by(& &1.cycle_start, Date)
|
||||
end
|
||||
|
||||
@tooltip_slots 5
|
||||
|
||||
@doc """
|
||||
Partitions open cycles into the tooltip's fixed five-slot budget: at most
|
||||
five badges total. Five or fewer cycles show in full with no overflow. More
|
||||
than five collapse to the first four chronological cycles plus a single
|
||||
overflow count (total − 4), so the tooltip renders four cycle badges and one
|
||||
grey overflow badge — five items.
|
||||
"""
|
||||
@spec tooltip_cycles([map()]) :: {[map()], non_neg_integer()}
|
||||
def tooltip_cycles(cycles) when is_list(cycles) do
|
||||
if length(cycles) > @tooltip_slots do
|
||||
{Enum.take(cycles, @tooltip_slots - 1), length(cycles) - (@tooltip_slots - 1)}
|
||||
else
|
||||
{cycles, 0}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Static daisyUI badge color token for a cycle status, matching the codes used
|
||||
on the member show page. Literal strings so Tailwind's content scanner keeps
|
||||
them in the bundle (a runtime `badge-\#{status}` would be tree-shaken).
|
||||
"""
|
||||
@spec cycle_color_class(:paid | :unpaid | :suspended) :: String.t()
|
||||
def cycle_color_class(:paid), do: "badge-success"
|
||||
def cycle_color_class(:unpaid), do: "badge-error"
|
||||
def cycle_color_class(:suspended), do: "badge-warning"
|
||||
|
||||
@doc """
|
||||
Hero-icon name for a cycle status (reuses the established status icons).
|
||||
"""
|
||||
@spec cycle_icon(:paid | :unpaid | :suspended) :: String.t()
|
||||
def cycle_icon(status), do: MembershipFeeHelpers.status_icon(status)
|
||||
|
||||
@doc """
|
||||
Short, human-readable period label for a cycle, derived from its `cycle_start`
|
||||
and the fee-type interval:
|
||||
|
||||
* yearly → `"2025"`
|
||||
* quarterly → `"Q1 2026"`
|
||||
* half-yearly → `"H1 2026"`
|
||||
* monthly → `"März 2026"`
|
||||
|
||||
Falls back to a `dd.MM.yyyy–dd.MM.yyyy` date range when the interval is not
|
||||
loaded/known.
|
||||
"""
|
||||
@spec short_period(map()) :: String.t()
|
||||
def short_period(%{cycle_start: %Date{} = start} = cycle) do
|
||||
case cycle_interval(cycle) do
|
||||
:yearly -> Integer.to_string(start.year)
|
||||
:quarterly -> "Q#{quarter(start.month)} #{start.year}"
|
||||
:half_yearly -> "H#{half(start.month)} #{start.year}"
|
||||
:monthly -> "#{month_name(start.month)} #{start.year}"
|
||||
_ -> cycle_date_range(cycle)
|
||||
end
|
||||
end
|
||||
|
||||
def short_period(cycle), do: cycle_date_range(cycle)
|
||||
|
||||
defp in_period_cycles(member, from, to) do
|
||||
case Map.get(member, :membership_fee_cycles) do
|
||||
cycles when is_list(cycles) -> Enum.filter(cycles, &in_period?(&1.cycle_end, from, to))
|
||||
|
|
@ -147,7 +219,35 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
|
|||
|
||||
defp in_period?(_, _, _), do: false
|
||||
|
||||
defp sort_by_end(cycles), do: Enum.sort_by(cycles, & &1.cycle_end, Date)
|
||||
defp cycle_interval(%{membership_fee_type: %{interval: interval}})
|
||||
when interval in [:monthly, :quarterly, :half_yearly, :yearly],
|
||||
do: interval
|
||||
|
||||
defp cycle_interval(_), do: nil
|
||||
|
||||
defp cycle_date_range(%{cycle_start: %Date{} = s, cycle_end: %Date{} = e}),
|
||||
do: "#{DateFormatter.format_date(s)}–#{DateFormatter.format_date(e)}"
|
||||
|
||||
defp cycle_date_range(%{cycle_start: %Date{} = s}), do: DateFormatter.format_date(s)
|
||||
defp cycle_date_range(_), do: ""
|
||||
|
||||
defp quarter(month), do: div(month - 1, 3) + 1
|
||||
|
||||
defp half(month) when month <= 6, do: 1
|
||||
defp half(_month), do: 2
|
||||
|
||||
defp month_name(1), do: gettext("January")
|
||||
defp month_name(2), do: gettext("February")
|
||||
defp month_name(3), do: gettext("March")
|
||||
defp month_name(4), do: gettext("April")
|
||||
defp month_name(5), do: gettext("May")
|
||||
defp month_name(6), do: gettext("June")
|
||||
defp month_name(7), do: gettext("July")
|
||||
defp month_name(8), do: gettext("August")
|
||||
defp month_name(9), do: gettext("September")
|
||||
defp month_name(10), do: gettext("October")
|
||||
defp month_name(11), do: gettext("November")
|
||||
defp month_name(12), do: gettext("December")
|
||||
|
||||
defp maybe_put_date(params, _key, nil), do: params
|
||||
|
||||
|
|
|
|||
|
|
@ -424,7 +424,7 @@ defmodule MvWeb.MemberLive.Show do
|
|||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(%{"id" => id}, _, socket) do
|
||||
def handle_params(%{"id" => id} = params, _, socket) do
|
||||
actor = current_actor(socket)
|
||||
|
||||
# Load custom fields once using assign_new to avoid repeated queries
|
||||
|
|
@ -463,9 +463,17 @@ defmodule MvWeb.MemberLive.Show do
|
|||
{:noreply,
|
||||
socket
|
||||
|> Layouts.assign_page_title(content_title)
|
||||
|> assign(:member, member)}
|
||||
|> assign(:member, member)
|
||||
|> maybe_assign_tab(params)}
|
||||
end
|
||||
|
||||
# Deep-link support: the overview payment badge drills into the fees history
|
||||
# via `?tab=membership_fees` (§1.15).
|
||||
defp maybe_assign_tab(socket, %{"tab" => "membership_fees"}),
|
||||
do: assign(socket, :active_tab, :membership_fees)
|
||||
|
||||
defp maybe_assign_tab(socket, _params), do: socket
|
||||
|
||||
@impl true
|
||||
def handle_event("switch_tab", %{"tab" => "contact"}, socket) do
|
||||
{:noreply, assign(socket, :active_tab, :contact)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue