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:
Simon 2026-07-10 16:27:15 +02:00
parent 7d1a71b1fd
commit c547ca19af
35 changed files with 2817 additions and 3149 deletions

View file

@ -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

View file

@ -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

View file

@ -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})

View file

@ -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 || []

View file

@ -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 """

View file

@ -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)
#

View file

@ -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.yyyydd.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

View file

@ -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)}