feat(overview): add point-in-time, suspended, OR-group and count-range filters

This commit is contained in:
Simon 2026-07-10 16:27:15 +02:00
parent c547ca19af
commit c0d1550401
23 changed files with 1116 additions and 117 deletions

View file

@ -59,6 +59,7 @@ defmodule MvWeb.MemberLive.Index do
alias MvWeb.MemberLive.Index.MembershipFeeStatus
alias MvWeb.MemberLive.Index.OverviewQuery
alias MvWeb.MemberLive.Index.PaymentAging
alias MvWeb.MemberLive.Index.Stichtag
alias MvWeb.MemberLive.Index.ViewSettings
require Ash.Query
@ -177,6 +178,8 @@ defmodule MvWeb.MemberLive.Index do
|> assign_new(:sort_order, fn -> :asc end)
|> assign(:payment_filter, nil)
|> assign(:payment_period, PaymentAging.default_period())
|> assign(:suspended, false)
|> assign(:stichtag, nil)
|> assign(:group_filters, %{})
|> assign(:groups, groups)
|> assign(:fee_type_filters, %{})
@ -679,6 +682,8 @@ defmodule MvWeb.MemberLive.Index do
|> maybe_update_sort(params)
|> maybe_update_payment_filter(params)
|> maybe_update_payment_period(params)
|> maybe_update_suspended(params)
|> maybe_update_stichtag(params)
|> maybe_update_group_filters(params)
|> maybe_update_fee_type_filters(params)
|> maybe_update_boolean_filters(params)
@ -728,6 +733,8 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_order,
socket.assigns.payment_filter,
socket.assigns.payment_period,
socket.assigns[:suspended],
socket.assigns[:stichtag],
socket.assigns[:group_filters],
socket.assigns[:fee_type_filters],
socket.assigns.boolean_custom_field_filters,
@ -883,10 +890,19 @@ defmodule MvWeb.MemberLive.Index do
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_payment_params(base_params, opts.payment_filter, opts.payment_period)
base_params = add_status_params(base_params, opts[:suspended], opts[:stichtag])
base_params = add_boolean_filters(base_params, opts.boolean_filters || %{})
add_date_filters(base_params, opts.date_filters)
end
# Suspended-status flag and Stichtag date serialize additively into the flat
# URL contract (suspended=1 / stichtag=<iso>), omitted when inactive (§2.1).
defp add_status_params(params, suspended, stichtag) do
params
|> Map.merge(PaymentAging.suspended_to_params(suspended))
|> Map.merge(Stichtag.to_params(stichtag))
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
@ -906,6 +922,8 @@ defmodule MvWeb.MemberLive.Index do
sort_order: socket.assigns.sort_order,
payment_filter: socket.assigns.payment_filter,
payment_period: socket.assigns.payment_period,
suspended: socket.assigns[:suspended] || false,
stichtag: socket.assigns[:stichtag],
group_filters: socket.assigns[:group_filters] || %{},
boolean_filters: socket.assigns.boolean_custom_field_filters || %{},
fee_type_filters: socket.assigns[:fee_type_filters] || %{},
@ -1366,6 +1384,8 @@ defmodule MvWeb.MemberLive.Index do
date_custom_fields: socket.assigns[:date_custom_fields],
payment_filter: socket.assigns.payment_filter,
payment_period: socket.assigns.payment_period,
suspended: socket.assigns[:suspended] || false,
stichtag: socket.assigns[:stichtag],
sort_field: socket.assigns.sort_field,
sort_order: socket.assigns.sort_order,
custom_fields: socket.assigns.all_custom_fields
@ -1448,7 +1468,7 @@ defmodule MvWeb.MemberLive.Index do
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, :name, :address]
field in [:groups, :membership_fee_type, :name, :address, :payment]
end
defp valid_sort_field_db_or_custom?(field) when is_binary(field) do
@ -1458,6 +1478,7 @@ defmodule MvWeb.MemberLive.Index do
field == "membership_fee_type" -> :membership_fee_type
field == "name" -> :name
field == "address" -> :address
field == "payment" -> :payment
true -> safe_member_field_atom_only(field)
end
@ -1507,9 +1528,10 @@ defmodule MvWeb.MemberLive.Index do
defp determine_field(default, nil), do: default
# Computed/pseudo fields that are nonetheless sortable (they resolve to DB
# sort keys in OverviewQuery): groups aggregate and the composite Name/Address
# columns. Other computed fields (e.g. membership_fee_status) are not sortable.
@sortable_computed_fields [:groups, :name, :address]
# sort keys in OverviewQuery): groups aggregate, the composite Name/Address
# columns, and the period-scoped payment (unpaid-cycle-count) column. Other
# computed fields (e.g. membership_fee_status) are not sortable.
@sortable_computed_fields [:groups, :name, :address, :payment]
defp determine_field(default, sf) when is_binary(sf) do
sortable_strings = Enum.map(@sortable_computed_fields, &Atom.to_string/1)
@ -1578,6 +1600,15 @@ defmodule MvWeb.MemberLive.Index do
defp maybe_update_payment_period(socket, params),
do: assign(socket, :payment_period, PaymentAging.parse_period(params))
# Suspended-status filter (§1.23) and point-in-time Stichtag (§1.24/§2.6):
# both decoded straight from the URL so `handle_params` remains the single
# source of truth. Absent params fall back to no filter.
defp maybe_update_suspended(socket, params),
do: assign(socket, :suspended, PaymentAging.parse_suspended(params))
defp maybe_update_stichtag(socket, params),
do: assign(socket, :stichtag, Stichtag.parse(params))
defp maybe_update_group_filters(socket, params) when is_map(params) do
prefix = @group_filter_prefix
prefix_len = String.length(prefix)
@ -1836,17 +1867,16 @@ defmodule MvWeb.MemberLive.Index do
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(_period), 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: ""
@doc """
Whether a payment period is actively scoped (at least one bound set), used to
decide whether the compact period indicator badge renders (§1.28). The
all-outstanding default (both bounds nil) shows no badge.
"""
def payment_period_active?(%{from: nil, to: nil}), do: false
def payment_period_active?(%{from: _, to: _}), do: true
def payment_period_active?(_), do: false
defp update_selection_assigns(socket) do
selected_members = socket.assigns.selected_members