feat(overview): add period-scoped payment filter and field-picker descriptor

This commit is contained in:
Simon 2026-07-10 16:27:15 +02:00
parent 95ef3177ac
commit 7d1a71b1fd
12 changed files with 654 additions and 0 deletions

View file

@ -67,6 +67,45 @@ defmodule MvWeb.MemberLive.Index.DateFilter do
}
end
@typedoc """
The three-state active/former quick filter. It is not a stored field but a
view onto `exit_date.mode`: `:active` `:active_only`, `:former`
`:inactive_only`, `:all` `:all`. A detailed `:custom` exit-date selection
has no quick equivalent and surfaces as `:custom` (no quick chip selected).
"""
@type quick_state :: :active | :former | :all | :custom
@quick_to_mode %{active: :active_only, former: :inactive_only, all: :all}
@doc """
Derives the active/former quick-filter state from the single `exit_date`
source. Total function; an absent or malformed `exit_date` reads as the
default `:active`. This is the only reader there is no second state to keep
in sync (§2.4).
"""
@spec quick_state(map()) :: quick_state()
def quick_state(filters) when is_map(filters) do
case Map.get(filters, :exit_date, %{}) do
%{mode: :active_only} -> :active
%{mode: :inactive_only} -> :former
%{mode: :all} -> :all
%{mode: :custom} -> :custom
_ -> :active
end
end
@doc """
Applies an active/former quick-filter choice onto the date filter state by
writing the corresponding `exit_date` mode and clearing its bounds. Only the
`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()
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})
end
@doc """
Decodes URL params into a date filter state map.

View file

@ -0,0 +1,140 @@
defmodule MvWeb.MemberLive.Index.FilterDescriptor do
@moduledoc """
Data-driven catalog of the filter fields offered by the member-overview
add-filter builder.
Each descriptor is a plain, serializable map describing one pickable field:
%{
key: atom() | String.t(), # stable identifier (custom fields use their UUID string)
group: :quick | :membership | :custom_fields,
label: String.t(), # human-readable, gettext-translated
control: atom() # which type-aware value control the builder renders
}
The catalog is the persistence basis the saved-views work (#549) docks onto:
it is derived purely from the field context (groups, fee types, custom fields)
and carries no UI or query state.
Grouping (confirmed taxonomy):
* `:quick` Payment, Active/former (common shortcuts)
* `:membership` Group, Fee type, Join date, Exit date
* `:custom_fields` one entry per filterable club-defined custom field
(boolean and date fields; other value types are not filtered on the
overview)
A group with no descriptors is omitted entirely (no header) see
`visible_groups/1`.
"""
use Gettext, backend: MvWeb.Gettext
@type group :: :quick | :membership | :custom_fields
@type t :: %{
key: atom() | String.t(),
group: group(),
label: String.t(),
control: atom()
}
# Fixed display order of the groups in the picker.
@group_order [:quick, :membership, :custom_fields]
# Custom-field value types the overview can filter on.
@filterable_custom_field_types [:boolean, :date]
@doc """
Returns the ordered list of all descriptors available for the given field
context. Recognised context keys (all optional, default empty):
* `:groups` list of `%{id: _, name: _}` group structs
* `:fee_types` list of fee-type structs
* `:custom_fields` list of custom-field structs (`:value_type`, `:name`, `:id`)
"""
@spec all(map()) :: [t()]
def all(context) when is_map(context) do
quick() ++
membership(Map.get(context, :groups, []), Map.get(context, :fee_types, [])) ++
custom_fields(Map.get(context, :custom_fields, []))
end
@doc "The group an individual descriptor belongs to."
@spec group_for(t()) :: group()
def group_for(%{group: group}), do: group
@doc "The fixed display order of the picker groups."
@spec group_order() :: [group()]
def group_order, do: @group_order
@doc """
Returns `[{group, [descriptor]}]` in display order, including only groups that
have at least one descriptor. Groups with no available fields are omitted so
the picker renders no empty header (§1.10).
"""
@spec visible_groups([t()]) :: [{group(), [t()]}]
def visible_groups(descriptors) when is_list(descriptors) do
grouped = Enum.group_by(descriptors, & &1.group)
@group_order
|> Enum.map(fn group -> {group, Map.get(grouped, group, [])} end)
|> Enum.reject(fn {_group, ds} -> ds == [] end)
end
# --- static groups --------------------------------------------------------
defp quick do
[
%{key: :payment, group: :quick, label: gettext("Payment"), control: :payment_count},
%{
key: :active_former,
group: :quick,
label: gettext("Active / former"),
control: :active_former
}
]
end
defp membership(groups, fee_types) do
maybe_group(groups) ++
maybe_fee_type(fee_types) ++
[
%{key: :join_date, group: :membership, label: gettext("Join date"), control: :date_range},
%{key: :exit_date, group: :membership, label: gettext("Exit date"), control: :exit_date}
]
end
defp maybe_group([]), do: []
defp maybe_group(_groups),
do: [%{key: :group, group: :membership, label: gettext("Group"), control: :group_membership}]
defp maybe_fee_type([]), do: []
defp maybe_fee_type(_fee_types),
do: [
%{
key: :fee_type,
group: :membership,
label: gettext("Fee type"),
control: :fee_type_membership
}
]
defp custom_fields(custom_fields) do
custom_fields
|> Enum.filter(&(Map.get(&1, :value_type) in @filterable_custom_field_types))
|> Enum.map(fn cf ->
%{
key: to_string(cf.id),
group: :custom_fields,
label: cf.name,
control: control_for_custom_field(cf.value_type)
}
end)
end
defp control_for_custom_field(:boolean), do: :boolean
defp control_for_custom_field(:date), do: :date_range
end

View file

@ -52,6 +52,7 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
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
@ -232,6 +233,40 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
)
end
# ---------------------------------------------------------------------------
# Payment aging filter (period-scoped unpaid-cycle count)
#
# `fully_paid` keeps members with zero unpaid cycles whose cycle_end lies in
# the active period; `{:has_unpaid, n}` keeps members with at least n such
# cycles. Both push to SQL via the `unpaid_cycle_count` calculation (a
# correlated aggregate over `membership_fee_cycles`), so no member set is
# loaded into memory. Suspended/paid cycles are excluded by the calculation.
# ---------------------------------------------------------------------------
defp payment_period(opts) do
case opts[:payment_period] do
%{from: _, to: _} = period -> period
_ -> %{from: nil, to: nil}
end
end
defp apply_payment_filter(query, :fully_paid, %{from: from, to: to}) do
Ash.Query.filter(
query,
expr(unpaid_cycle_count(period_from: ^from, period_to: ^to) == 0)
)
end
defp apply_payment_filter(query, {:has_unpaid, n}, %{from: from, to: to})
when is_integer(n) and n > 0 do
Ash.Query.filter(
query,
expr(unpaid_cycle_count(period_from: ^from, period_to: ^to) >= ^n)
)
end
defp apply_payment_filter(query, _filter, _period), do: query
# ---------------------------------------------------------------------------
# Built-in date filters (join/exit)
# ---------------------------------------------------------------------------

View file

@ -28,8 +28,11 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
@type period :: %{from: Date.t() | nil, to: Date.t() | nil}
@type filter :: nil | :fully_paid | {:has_unpaid, 1..3}
@payment_period_from_param Constants.payment_period_from_param()
@payment_period_to_param Constants.payment_period_to_param()
@payment_filter_param Constants.payment_filter_param()
@doc """
The default period: all outstanding cycles, all time (both bounds nil).
@ -62,6 +65,39 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
def to_params(_), do: %{}
@doc """
Decodes the payment-count filter param.
* `"fully_paid"` `:fully_paid` (exactly 0 unpaid cycles in the period)
* `"unpaid_1"` / `"unpaid_2"` / `"unpaid_3"` `{:has_unpaid, N}`
(at least N unpaid cycles in the period)
* anything else `nil` (no payment-count filter)
"""
@spec parse_filter(term()) :: filter()
def parse_filter("fully_paid"), do: :fully_paid
def parse_filter("unpaid_1"), do: {:has_unpaid, 1}
def parse_filter("unpaid_2"), do: {:has_unpaid, 2}
def parse_filter("unpaid_3"), do: {:has_unpaid, 3}
def parse_filter(_), do: nil
@doc """
Encodes a payment-count filter into URL params. `nil` yields the empty map.
"""
@spec filter_to_params(filter()) :: %{optional(String.t()) => String.t()}
def filter_to_params(:fully_paid), do: %{@payment_filter_param => "fully_paid"}
def filter_to_params({:has_unpaid, n}) when n in 1..3,
do: %{@payment_filter_param => "unpaid_#{n}"}
def filter_to_params(_), do: %{}
@doc """
Decodes the payment-count filter from a full params map.
"""
@spec parse_filter_params(map()) :: filter()
def parse_filter_params(params) when is_map(params),
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).