diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 449d1868..f60f77ad 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -44,6 +44,14 @@ defmodule Mv.Constants do @payment_filter_param "pay_filter" + @payment_count_min_param "pay_min" + + @payment_count_max_param "pay_max" + + @suspended_param "suspended" + + @stichtag_param "stichtag" + @max_boolean_filters 50 @max_mailto_bulk_recipients 50 @@ -200,6 +208,49 @@ defmodule Mv.Constants do """ def payment_filter_param, do: @payment_filter_param + @doc """ + Returns the URL parameter name for the payment open-cycle count lower bound + (used with `pay_filter=has_unpaid` for the two-sided count range). + + ## Examples + + iex> Mv.Constants.payment_count_min_param() + "pay_min" + """ + def payment_count_min_param, do: @payment_count_min_param + + @doc """ + Returns the URL parameter name for the payment open-cycle count upper bound. + + ## Examples + + iex> Mv.Constants.payment_count_max_param() + "pay_max" + """ + def payment_count_max_param, do: @payment_count_max_param + + @doc """ + Returns the URL parameter name for the suspended-status filter flag. + + ## Examples + + iex> Mv.Constants.suspended_param() + "suspended" + """ + def suspended_param, do: @suspended_param + + @doc """ + Returns the URL parameter name for the point-in-time membership ("Stichtag") + filter: keeps members who were active on the given ISO-8601 date X, i.e. + `join_date <= X and (exit_date is nil or exit_date > X)`. + + ## Examples + + iex> Mv.Constants.stichtag_param() + "stichtag" + """ + def stichtag_param, do: @stichtag_param + @doc """ Returns the maximum number of boolean custom field filters allowed per request. diff --git a/lib/mv_web/live/components/add_filter_builder_component.ex b/lib/mv_web/live/components/add_filter_builder_component.ex index f4d2dc15..35fb3dbb 100644 --- a/lib/mv_web/live/components/add_filter_builder_component.ex +++ b/lib/mv_web/live/components/add_filter_builder_component.ex @@ -35,6 +35,7 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do alias Mv.Constants alias MvWeb.MemberLive.Index.DateFilter + alias MvWeb.MemberLive.Index.DatePresets alias MvWeb.MemberLive.Index.FilterDescriptor alias MvWeb.MemberLive.Index.FilterParams alias MvWeb.MemberLive.Index.PaymentAging @@ -968,32 +969,30 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do # --- date presets --------------------------------------------------------- defp date_presets do - [ - {"this_year", gettext("This year")}, - {"last_year", gettext("Last year")}, - {"this_month", gettext("This month")}, - {"last_30_days", gettext("Last 30 days")} - ] + Enum.map(DatePresets.all(), fn preset -> {Atom.to_string(preset), preset_label(preset)} end) end - defp preset_range("this_year") do - y = Date.utc_today().year - {Date.new!(y, 1, 1), Date.new!(y, 12, 31)} - end + defp preset_label(:last_7_days), do: gettext("Last 7 days") + defp preset_label(:last_30_days), do: gettext("Last 30 days") + defp preset_label(:last_3_months), do: gettext("Last 3 months") + defp preset_label(:last_12_months), do: gettext("Last 12 months") + defp preset_label(:this_month), do: gettext("This month") + defp preset_label(:this_quarter), do: gettext("This quarter") + defp preset_label(:this_year), do: gettext("This year") - defp preset_range("last_year") do - y = Date.utc_today().year - 1 - {Date.new!(y, 1, 1), Date.new!(y, 12, 31)} - end + # Resolves a preset string (from `phx-value-preset`) to a concrete `{from, to}` + # tuple via the shared, clock-free `DatePresets` resolver, or nil for an + # unknown preset. The resolver defines "this year" as period-to-date + # ([Jan 1, today]) per §2.8, superseding the prior full-year range. + defp preset_range(preset) when is_binary(preset) do + case Enum.find(DatePresets.all(), &(Atom.to_string(&1) == preset)) do + nil -> + nil - defp preset_range("this_month") do - today = Date.utc_today() - {Date.beginning_of_month(today), Date.end_of_month(today)} - end - - defp preset_range("last_30_days") do - today = Date.utc_today() - {Date.add(today, -30), today} + key -> + %{from: from, to: to} = DatePresets.resolve(key) + {from, to} + end end defp preset_range(_), do: nil diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index b519aa2d..b39ee175 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -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=), 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 diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 5a56e922..fc4aa559 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -456,7 +456,31 @@ <:col :let={member} :if={:membership_fee_status in @member_fields_visible} - label={MvWeb.MemberLive.Index.payment_column_label(@payment_period)} + sort_field={:payment} + label={ + ~H""" +
+ <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_payment} + field={:payment} + label={MvWeb.MemberLive.Index.payment_column_label(@payment_period)} + sort_hint={gettext("Click to sort by open payment cycles")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + + {PaymentAging.period_short_code(@payment_period) || gettext("filtered")} + +
+ """ + } > <% count = member.unpaid_cycle_count || 0 %> <% badge = PaymentAging.badge(count) %> diff --git a/lib/mv_web/live/member_live/index/date_presets.ex b/lib/mv_web/live/member_live/index/date_presets.ex new file mode 100644 index 00000000..ca671f73 --- /dev/null +++ b/lib/mv_web/live/member_live/index/date_presets.ex @@ -0,0 +1,70 @@ +defmodule MvWeb.MemberLive.Index.DatePresets do + @moduledoc """ + Relative date-range presets for the date value controls (§1.25/§2.8). + + Each preset resolves against a reference date "today" into a concrete + `%{from: Date.t(), to: Date.t()}` range. Rolling "last N days/months" presets + end on today; the "this month/quarter/year" presets run period-to-date from + the respective period start up to today. Resolving is pure and clock-free — + the caller passes `today` (defaulting to `Date.utc_today/0`), so the resolved + bounds are testable and reproducible. + + The resolved bounds feed the same `jd_*` / `ed_*` / `cdf_*` URL params as an + absolute custom range; presets are purely an input convenience and add no new + persistence dimension. + """ + + @type preset :: + :last_7_days + | :last_30_days + | :last_3_months + | :last_12_months + | :this_month + | :this_quarter + | :this_year + + @type range :: %{from: Date.t(), to: Date.t()} + + @presets [ + :last_7_days, + :last_30_days, + :last_3_months, + :last_12_months, + :this_month, + :this_quarter, + :this_year + ] + + @doc """ + Returns every preset key in display order (rolling windows first, then the + period-to-date presets), for rendering the preset radio list. + """ + @spec all() :: [preset()] + def all, do: @presets + + @doc """ + Whether `key` names a known preset. + """ + @spec preset?(term()) :: boolean() + def preset?(key), do: key in @presets + + @doc """ + Resolves a preset to a concrete `{from, to}` range relative to `today` + (default `Date.utc_today/0`). + """ + @spec resolve(preset(), Date.t()) :: range() + def resolve(preset, today \\ Date.utc_today()) + + def resolve(:last_7_days, today), do: %{from: Date.add(today, -6), to: today} + def resolve(:last_30_days, today), do: %{from: Date.add(today, -29), to: today} + def resolve(:last_3_months, today), do: %{from: Date.shift(today, month: -3), to: today} + def resolve(:last_12_months, today), do: %{from: Date.shift(today, month: -12), to: today} + def resolve(:this_month, today), do: %{from: %{today | day: 1}, to: today} + + def resolve(:this_quarter, today) do + first_month = div(today.month - 1, 3) * 3 + 1 + %{from: Date.new!(today.year, first_month, 1), to: today} + end + + def resolve(:this_year, today), do: %{from: Date.new!(today.year, 1, 1), to: today} +end diff --git a/lib/mv_web/live/member_live/index/overview_query.ex b/lib/mv_web/live/member_live/index/overview_query.ex index a45dd077..dc125d65 100644 --- a/lib/mv_web/live/member_live/index/overview_query.ex +++ b/lib/mv_web/live/member_live/index/overview_query.ex @@ -46,9 +46,16 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do opts[:boolean_custom_fields] ) |> apply_date_filters(opts[:date_filters]) + |> apply_stichtag_filter(opts[:stichtag]) |> apply_custom_date_filters(opts[:date_filters], opts[:date_custom_fields]) |> apply_payment_filter(opts[:payment_filter], payment_period(opts)) - |> apply_sort(opts[:sort_field], opts[:sort_order], opts[:custom_fields] || []) + |> apply_suspended_filter(opts[:suspended], payment_period(opts)) + |> apply_sort( + opts[:sort_field], + opts[:sort_order], + opts[:custom_fields] || [], + payment_period(opts) + ) end # --------------------------------------------------------------------------- @@ -61,7 +68,12 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do defp apply_search(query, _), do: query # --------------------------------------------------------------------------- - # Group filters (AND across selected groups) + # Group filters (:in OR across selected groups; :not_in AND-of-not-exists) + # + # Multiple "is" selections bundle into a single membership `exists` over the + # union of group ids, so a member matching *any* selected group is kept (OR, + # §1.29/§2.7). Each "is not" selection stays an independent not-exists, so a + # member is excluded when it belongs to *any* of them. # --------------------------------------------------------------------------- defp apply_group_filters(query, group_filters, _groups) @@ -71,31 +83,32 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do defp apply_group_filters(query, group_filters, groups) do valid_ids = valid_id_set(groups) - Enum.reduce(group_filters, query, fn {group_id_str, value}, q -> - if MapSet.member?(valid_ids, group_id_str) do - apply_one_group_filter(q, group_id_str, value) - else - q + {in_filters, not_in_filters} = + group_filters + |> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end) + |> Enum.split_with(fn {_, value} -> value == :in end) + + query + |> apply_group_in_filter(Enum.map(in_filters, fn {id_str, _} -> id_str end)) + |> apply_group_not_in_filters(Enum.map(not_in_filters, fn {id_str, _} -> id_str end)) + end + + defp apply_group_in_filter(query, id_strs) do + case cast_uuids(id_strs) do + [] -> query + uuids -> Ash.Query.filter(query, expr(exists(member_groups, group_id in ^uuids))) + end + end + + defp apply_group_not_in_filters(query, id_strs) do + Enum.reduce(id_strs, query, fn id_str, q -> + case Ecto.UUID.cast(id_str) do + {:ok, uuid} -> Ash.Query.filter(q, expr(not exists(member_groups, group_id == ^uuid))) + _ -> q end end) end - defp apply_one_group_filter(query, group_id_str, :in) do - case Ecto.UUID.cast(group_id_str) do - {:ok, uuid} -> Ash.Query.filter(query, expr(exists(member_groups, group_id == ^uuid))) - _ -> query - end - end - - defp apply_one_group_filter(query, group_id_str, :not_in) do - case Ecto.UUID.cast(group_id_str) do - {:ok, uuid} -> Ash.Query.filter(query, expr(not exists(member_groups, group_id == ^uuid))) - _ -> query - end - end - - defp apply_one_group_filter(query, _id, _value), do: query - # --------------------------------------------------------------------------- # Fee-type filters (:in OR; :not_in AND) # --------------------------------------------------------------------------- @@ -209,8 +222,53 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do ) end + # Two-sided open-cycle range (§1.22/§3.14): keeps members whose in-period + # unpaid count is within [min, max]. `max` nil means unbounded above, so + # `{:unpaid_range, 1, nil}` is the default "has open" (>= 1). Both bounds push + # to SQL through the same `unpaid_cycle_count` calculation. + defp apply_payment_filter(query, {:unpaid_range, min, max}, %{from: from, to: to}) + when is_integer(min) and min > 0 do + query + |> Ash.Query.filter(unpaid_cycle_count(period_from: ^from, period_to: ^to) >= ^min) + |> apply_unpaid_upper_bound(max, from, to) + end + defp apply_payment_filter(query, _filter, _period), do: query + defp apply_unpaid_upper_bound(query, max, from, to) when is_integer(max) and max > 0 do + Ash.Query.filter( + query, + expr(unpaid_cycle_count(period_from: ^from, period_to: ^to) <= ^max) + ) + end + + defp apply_unpaid_upper_bound(query, _max, _from, _to), do: query + + # Suspended-status filter (§1.23): keeps members with at least one suspended + # cycle whose cycle_end falls in the active period. Independent of the unpaid + # count, which excludes suspended (§2.5). A correlated `exists` pushes to SQL; + # the period bounds are composed conditionally so an unbounded (nil) side adds + # no `cycle_end` comparison rather than comparing against a literal nil. + defp apply_suspended_filter(query, true, %{from: from, to: to}) do + Ash.Query.filter( + query, + expr(exists(membership_fee_cycles, ^suspended_period_predicate(from, to))) + ) + end + + defp apply_suspended_filter(query, _suspended, _period), do: query + + defp suspended_period_predicate(nil, nil), do: expr(status == :suspended) + + defp suspended_period_predicate(from, nil), + do: expr(status == :suspended and cycle_end >= ^from) + + defp suspended_period_predicate(nil, to), + do: expr(status == :suspended and cycle_end <= ^to) + + defp suspended_period_predicate(from, to), + do: expr(status == :suspended and cycle_end >= ^from and cycle_end <= ^to) + # --------------------------------------------------------------------------- # Built-in date filters (join/exit) # --------------------------------------------------------------------------- @@ -226,21 +284,41 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do defp apply_custom_date_filters(query, _filters, _custom_fields), do: query + # --------------------------------------------------------------------------- + # Point-in-time membership ("Stichtag"): a member counts as a member on the + # reference date X iff it had joined by X and had not yet left as of X + # (§1.24/§2.6). "Left as of X" means a non-nil exit_date at or before X, so an + # exit_date strictly after X (or nil) still counts as a member on X. + # --------------------------------------------------------------------------- + + defp apply_stichtag_filter(query, %Date{} = x) do + Ash.Query.filter( + query, + expr(join_date <= ^x and (is_nil(exit_date) or exit_date > ^x)) + ) + end + + defp apply_stichtag_filter(query, _), do: query + # --------------------------------------------------------------------------- # Sort (always append the unique id tie-breaker for keyset stability) # --------------------------------------------------------------------------- - defp apply_sort(query, nil, _order, _custom_fields), do: Ash.Query.sort(query, id: :asc) - defp apply_sort(query, _field, nil, _custom_fields), do: Ash.Query.sort(query, id: :asc) + defp apply_sort(query, nil, _order, _custom_fields, _period), + do: Ash.Query.sort(query, id: :asc) + + defp apply_sort(query, _field, nil, _custom_fields, _period), + do: Ash.Query.sort(query, id: :asc) # Composite "Member" column: sort by last name then first name. The email that # shares the cell is display-only and does not affect the sort key (§1.21). - defp apply_sort(query, field, order, _custom_fields) when field in [:name, "name"] do + defp apply_sort(query, field, order, _custom_fields, _period) when field in [:name, "name"] do Ash.Query.sort(query, [{:last_name, order}, {:first_name, order}, {:id, :asc}]) end # Composite "Address" column: sort by city, then postal code, then street. - defp apply_sort(query, field, order, _custom_fields) when field in [:address, "address"] do + defp apply_sort(query, field, order, _custom_fields, _period) + when field in [:address, "address"] do Ash.Query.sort(query, [ {:city, order}, {:postal_code, order}, @@ -249,7 +327,20 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do ]) end - defp apply_sort(query, field, order, custom_fields) do + # "Beiträge" (payment) column: sort DB-side by the period-scoped unpaid-cycle + # count (§1.27). The count calculation takes the active period as arguments so + # the sort key matches the displayed value; the id tie-breaker keeps keyset + # pages stable across equal counts. + defp apply_sort(query, field, order, _custom_fields, %{from: from, to: to}) + when field in [:payment, "payment"] do + Ash.Query.sort(query, [ + {Ash.Sort.expr_sort(unpaid_cycle_count(period_from: ^from, period_to: ^to), :integer), + order}, + {:id, :asc} + ]) + end + + defp apply_sort(query, field, order, custom_fields, _period) do case sort_key(field) do nil -> apply_custom_field_sort(query, field, order, custom_fields) key -> Ash.Query.sort(query, [{key, order}, {:id, :asc}]) diff --git a/lib/mv_web/live/member_live/index/payment_aging.ex b/lib/mv_web/live/member_live/index/payment_aging.ex index e6154aa7..9fc4cd65 100644 --- a/lib/mv_web/live/member_live/index/payment_aging.ex +++ b/lib/mv_web/live/member_live/index/payment_aging.ex @@ -29,11 +29,18 @@ 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} + @type filter :: + nil + | :fully_paid + | {:has_unpaid, 1..3} + | {:unpaid_range, pos_integer(), pos_integer() | nil} @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() + @payment_count_min_param Constants.payment_count_min_param() + @payment_count_max_param Constants.payment_count_max_param() + @suspended_param Constants.suspended_param() @doc """ The default period: all outstanding cycles, all time (both bounds nil). @@ -83,6 +90,10 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do @doc """ Encodes a payment-count filter into URL params. `nil` yields the empty map. + + The two-sided `{:unpaid_range, min, max}` form encodes as + `pay_filter=has_unpaid` plus `pay_min` (and `pay_max` when bounded above), + so a fresh "has open" default (`min=1`, `max=nil`) round-trips canonically. """ @spec filter_to_params(filter()) :: %{optional(String.t()) => String.t()} def filter_to_params(:fully_paid), do: %{@payment_filter_param => "fully_paid"} @@ -90,14 +101,61 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do def filter_to_params({:has_unpaid, n}) when n in 1..3, do: %{@payment_filter_param => "unpaid_#{n}"} + def filter_to_params({:unpaid_range, min, max}) when is_integer(min) and min > 0 do + %{@payment_filter_param => "has_unpaid", @payment_count_min_param => Integer.to_string(min)} + |> maybe_put_max(max) + end + def filter_to_params(_), do: %{} + defp maybe_put_max(params, max) when is_integer(max) and max > 0, + do: Map.put(params, @payment_count_max_param, Integer.to_string(max)) + + defp maybe_put_max(params, _max), do: params + @doc """ Decodes the payment-count filter from a full params map. + + A `pay_filter=has_unpaid` value reads the `pay_min`/`pay_max` bounds into a + `{:unpaid_range, min, max}`; an absent/invalid `pay_min` defaults to 1 and an + absent/invalid `pay_max` to nil (unbounded). Other `pay_filter` values fall + back to the single-token decode (`fully_paid` / `unpaid_N`). """ @spec parse_filter_params(map()) :: filter() - def parse_filter_params(params) when is_map(params), - do: parse_filter(Map.get(params, @payment_filter_param)) + def parse_filter_params(params) when is_map(params) do + case Map.get(params, @payment_filter_param) do + "has_unpaid" -> + {:unpaid_range, parse_count(Map.get(params, @payment_count_min_param), 1), + parse_count(Map.get(params, @payment_count_max_param), nil)} + + other -> + parse_filter(other) + end + end + + defp parse_count(value, default) when is_binary(value) do + case Integer.parse(String.trim(value)) do + {n, ""} when n > 0 -> n + _ -> default + end + end + + defp parse_count(_value, default), do: default + + @doc """ + Decodes the suspended-status filter flag (§1.23) from a params map. The flag + is present (`suspended=1`) only when active; any other value reads as false. + """ + @spec parse_suspended(map()) :: boolean() + def parse_suspended(params) when is_map(params), do: Map.get(params, @suspended_param) == "1" + + @doc """ + Encodes the suspended-status filter flag into URL params. Only `true` emits a + param (`suspended=1`); false/nil yield the empty map so a fresh URL is canonical. + """ + @spec suspended_to_params(boolean() | nil) :: %{optional(String.t()) => String.t()} + def suspended_to_params(true), do: %{@suspended_param => "1"} + def suspended_to_params(_), do: %{} @doc """ Badge descriptor for an unpaid-cycle count. A count of 0 reads "Alle bezahlt" @@ -132,6 +190,53 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do } end + @doc """ + Compact short code for a payment period (§1.28), or nil when the period is not + compactly codeable (the header then falls back to a plain "filtered" badge). + + * `{nil, nil}` (all-outstanding default) → nil (no badge) + * a full calendar year (Jan 1 – Dec 31) → `"2026"` + * a full calendar quarter → `"Q1 2026"` + * anything else (arbitrary or open-ended range) → nil + """ + @spec period_short_code(period()) :: String.t() | nil + def period_short_code(%{from: %Date{} = from, to: %Date{} = to}) do + cond do + full_year?(from, to) -> Integer.to_string(from.year) + full_quarter?(from, to) -> "Q#{quarter(from.month)} #{from.year}" + true -> nil + end + end + + def period_short_code(_), do: nil + + @doc """ + Human-readable tooltip naming the active payment period with locally formatted + (`dd.MM.yyyy`) bounds (§1.28). The all-outstanding default names all + outstanding cycles; open-ended periods render an open bound as an ellipsis. + """ + @spec period_tooltip(period()) :: String.t() + def period_tooltip(%{from: nil, to: nil}), do: gettext("Fees across all outstanding cycles") + + def period_tooltip(%{from: from, to: to}) do + gettext("Fees in period %{from}–%{to}", from: period_bound(from), to: period_bound(to)) + end + + def period_tooltip(_), do: gettext("Fees across all outstanding cycles") + + defp period_bound(%Date{} = d), do: DateFormatter.format_date(d) + defp period_bound(_), do: "…" + + defp full_year?(from, to), + do: + from.month == 1 and from.day == 1 and to.year == from.year and to.month == 12 and + to.day == 31 + + defp full_quarter?(from, to) do + from.day == 1 and from.month in [1, 4, 7, 10] and to.year == from.year and + to == Date.end_of_month(Date.new!(from.year, from.month + 2, 1)) + end + @doc """ 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 diff --git a/lib/mv_web/live/member_live/index/stichtag.ex b/lib/mv_web/live/member_live/index/stichtag.ex new file mode 100644 index 00000000..647d609d --- /dev/null +++ b/lib/mv_web/live/member_live/index/stichtag.ex @@ -0,0 +1,36 @@ +defmodule MvWeb.MemberLive.Index.Stichtag do + @moduledoc """ + URL codec for the point-in-time membership ("Stichtag") filter (§1.24/§2.6). + + The Stichtag is a single ISO-8601 date X carried in the flat URL contract + under the `stichtag` key. When set, the overview keeps members who were active + on X (`join_date <= X and (exit_date is nil or exit_date > X)`); the predicate + itself lives in `OverviewQuery`. Absent or malformed values decode to nil (no + filter), and nil encodes to the empty map so a fresh URL stays canonical. + """ + + @stichtag_param Mv.Constants.stichtag_param() + + @doc """ + Decodes the Stichtag date from a params map. Missing or malformed ISO-8601 + values yield nil. + """ + @spec parse(map()) :: Date.t() | nil + def parse(params) when is_map(params), do: parse_date(Map.get(params, @stichtag_param)) + + @doc """ + Encodes a Stichtag date into URL params. nil yields the empty map. + """ + @spec to_params(Date.t() | nil) :: %{optional(String.t()) => String.t()} + def to_params(%Date{} = date), do: %{@stichtag_param => Date.to_iso8601(date)} + def to_params(_), do: %{} + + defp parse_date(value) when is_binary(value) do + case Date.from_iso8601(String.trim(value)) do + {:ok, date} -> date + _ -> nil + end + end + + defp parse_date(_), do: nil +end diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index a8243b50..d6f9123f 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3956,6 +3956,7 @@ msgid "email %{email} has already been taken" msgstr "E-Mail %{email} wurde bereits verwendet" #: lib/mv_web/components/bulk_actions_dropdown.ex +#: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format msgid "filtered" msgstr "gefiltert" @@ -4212,11 +4213,6 @@ msgstr "Beigetreten" msgid "Last 30 days" msgstr "Letzte 30 Tage" -#: lib/mv_web/live/components/add_filter_builder_component.ex -#, elixir-autogen, elixir-format -msgid "Last year" -msgstr "Letztes Jahr" - #: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "Membership" @@ -4307,11 +4303,6 @@ msgstr "Februar" msgid "Fees" msgstr "Beiträge" -#: lib/mv_web/live/member_live/index.ex -#, elixir-autogen, elixir-format -msgid "Fees · %{range}" -msgstr "Beiträge · %{range}" - #: lib/mv_web/live/member_live/index/payment_aging.ex #, elixir-autogen, elixir-format msgid "January" @@ -4347,27 +4338,37 @@ msgstr "Oktober" msgid "September" msgstr "September" -#~ #: lib/mv_web/live/member_live/index.html.heex -#~ #, elixir-autogen, elixir-format -#~ msgid "Click for member details" -#~ msgstr "Klicke für Mitglieds-Details" +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click to sort by open payment cycles" +msgstr "Klicke, um nach offenen Beitragszyklen zu sortieren" -#~ #: lib/mv_web/live/member_live/index.html.heex -#~ #, elixir-autogen, elixir-format -#~ msgid "No open cycles for this period" -#~ msgstr "Keine offenen Zyklen in diesem Zeitraum" +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Last 12 months" +msgstr "Letzte 12 Monate" -#~ #: lib/mv_web/live/member_live/index.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "Payment · %{range}" -#~ msgstr "Zahlung · %{range}" +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Last 3 months" +msgstr "Letzte 3 Monate" -#~ #: lib/mv_web/live/member_live/index.html.heex -#~ #, elixir-autogen, elixir-format -#~ msgid "Suspended: %{date}" -#~ msgstr "Ausgesetzt: %{date}" +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Last 7 days" +msgstr "Letzte 7 Tage" -#~ #: lib/mv_web/live/member_live/index.html.heex -#~ #, elixir-autogen, elixir-format -#~ msgid "Unpaid until %{date}" -#~ msgstr "Offen bis %{date}" +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "This quarter" +msgstr "Dieses Quartal" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "Fees across all outstanding cycles" +msgstr "Beiträge über alle offenen Zyklen" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "Fees in period %{from}–%{to}" +msgstr "Beiträge im Zeitraum %{from}–%{to}" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 9de292e9..d0f9425b 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3956,6 +3956,7 @@ msgid "email %{email} has already been taken" msgstr "" #: lib/mv_web/components/bulk_actions_dropdown.ex +#: lib/mv_web/live/member_live/index.html.heex #, elixir-autogen, elixir-format msgid "filtered" msgstr "" @@ -4212,11 +4213,6 @@ msgstr "" msgid "Last 30 days" msgstr "" -#: lib/mv_web/live/components/add_filter_builder_component.ex -#, elixir-autogen, elixir-format -msgid "Last year" -msgstr "" - #: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "Membership" @@ -4307,11 +4303,6 @@ msgstr "" msgid "Fees" msgstr "" -#: lib/mv_web/live/member_live/index.ex -#, elixir-autogen, elixir-format -msgid "Fees · %{range}" -msgstr "" - #: lib/mv_web/live/member_live/index/payment_aging.ex #, elixir-autogen, elixir-format msgid "January" @@ -4346,3 +4337,38 @@ msgstr "" #, elixir-autogen, elixir-format msgid "September" msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click to sort by open payment cycles" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Last 12 months" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Last 3 months" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Last 7 days" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "This quarter" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "Fees across all outstanding cycles" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "Fees in period %{from}–%{to}" +msgstr "" diff --git a/test/mv_web/member_live/index_date_presets_property_test.exs b/test/mv_web/member_live/index_date_presets_property_test.exs new file mode 100644 index 00000000..94c9c69d --- /dev/null +++ b/test/mv_web/member_live/index_date_presets_property_test.exs @@ -0,0 +1,66 @@ +defmodule MvWeb.MemberLive.IndexDatePresetsPropertyTest do + @moduledoc """ + §2.8 — Relative date-range preset resolution. + + For any preset evaluated against any reference date "today", the resolved + `{from, to}` matches the preset's definition: rolling "last N days" windows end + on today, and the "this month/quarter/year" presets run period-to-date from the + respective period start to today. + """ + use ExUnit.Case, async: true + use ExUnitProperties + + alias MvWeb.MemberLive.Index.DatePresets + + defp date_gen do + gen all( + year <- StreamData.integer(2000..2100), + month <- StreamData.integer(1..12), + day <- StreamData.integer(1..28) + ) do + Date.new!(year, month, day) + end + end + + property "last-N-days presets end on today and span N days inclusive" do + check all(today <- date_gen()) do + assert DatePresets.resolve(:last_7_days, today) == %{from: Date.add(today, -6), to: today} + assert DatePresets.resolve(:last_30_days, today) == %{from: Date.add(today, -29), to: today} + end + end + + property "last-N-months presets shift the start back N months and end on today" do + check all(today <- date_gen()) do + assert DatePresets.resolve(:last_3_months, today) == + %{from: Date.shift(today, month: -3), to: today} + + assert DatePresets.resolve(:last_12_months, today) == + %{from: Date.shift(today, month: -12), to: today} + end + end + + property "this-period presets run from the period start to today" do + check all(today <- date_gen()) do + month_start = %{today | day: 1} + q_first_month = div(today.month - 1, 3) * 3 + 1 + quarter_start = Date.new!(today.year, q_first_month, 1) + year_start = Date.new!(today.year, 1, 1) + + assert DatePresets.resolve(:this_month, today) == %{from: month_start, to: today} + assert DatePresets.resolve(:this_quarter, today) == %{from: quarter_start, to: today} + assert DatePresets.resolve(:this_year, today) == %{from: year_start, to: today} + end + end + + test "all/0 lists every resolvable preset key in display order" do + assert DatePresets.all() == [ + :last_7_days, + :last_30_days, + :last_3_months, + :last_12_months, + :this_month, + :this_quarter, + :this_year + ] + end +end diff --git a/test/mv_web/member_live/index_date_presets_test.exs b/test/mv_web/member_live/index_date_presets_test.exs index ce30fe35..f8470b81 100644 --- a/test/mv_web/member_live/index_date_presets_test.exs +++ b/test/mv_web/member_live/index_date_presets_test.exs @@ -1,8 +1,8 @@ defmodule MvWeb.MemberLive.IndexDatePresetsTest do @moduledoc """ - §1.8 — a date field's value control offers presets; picking "This year" - applies the Jan 1 – Dec 31 range of the current year and writes the equivalent - range params to the URL. + §1.25 (supersedes §1.8) — a date field's value control offers relative + presets; picking "This year" applies the period-to-date range (Jan 1 – today) + of the current year and writes the equivalent range params to the URL. """ use MvWeb.ConnCase, async: false @@ -20,7 +20,7 @@ defmodule MvWeb.MemberLive.IndexDatePresetsTest do %{conn: conn_with_oidc_user(conn)} end - test "the 'This year' preset applies the current-year range and writes URL params", %{ + test "the 'This year' preset applies the year-to-date range and writes URL params", %{ conn: conn } do {:ok, view, _html} = live(conn, ~p"/members") @@ -32,8 +32,20 @@ defmodule MvWeb.MemberLive.IndexDatePresetsTest do view |> element("[data-testid='date-preset-this_year']") |> render_click() path = assert_patch(view) - year = Date.utc_today().year - assert path =~ "jd_from=#{year}-01-01" - assert path =~ "jd_to=#{year}-12-31" + today = Date.utc_today() + assert path =~ "jd_from=#{today.year}-01-01" + assert path =~ "jd_to=#{Date.to_iso8601(today)}" + end + + test "the 'Last 30 days' preset applies a today-anchored 30-day window", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + pick_field(view, "join_date") + view |> element("[data-testid='date-preset-last_30_days']") |> render_click() + + path = assert_patch(view) + today = Date.utc_today() + assert path =~ "jd_from=#{Date.to_iso8601(Date.add(today, -29))}" + assert path =~ "jd_to=#{Date.to_iso8601(today)}" end end diff --git a/test/mv_web/member_live/index_filter_url_roundtrip_test.exs b/test/mv_web/member_live/index_filter_url_roundtrip_test.exs new file mode 100644 index 00000000..8fe3405a --- /dev/null +++ b/test/mv_web/member_live/index_filter_url_roundtrip_test.exs @@ -0,0 +1,38 @@ +defmodule MvWeb.MemberLive.IndexFilterUrlRoundtripTest do + @moduledoc """ + §2.1 (extended) — the v2 filter keys survive the LiveView decode→encode round + trip. Loading `/members` with a Stichtag date, the suspended flag, and a + two-sided payment count range, then triggering an unrelated re-patch (a sort + click), preserves every one of those keys in the pushed URL. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + setup %{conn: conn} do + actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, _member} = + Mv.Membership.create_member( + %{first_name: "Round", last_name: "Trip", email: "roundtrip@example.com"}, + actor: actor + ) + + %{conn: conn_with_oidc_user(conn)} + end + + test "stichtag, suspended and payment count range persist through a re-patch", %{conn: conn} do + url = "/members?stichtag=2026-03-15&suspended=1&pay_filter=has_unpaid&pay_min=2&pay_max=4" + {:ok, view, _html} = live(conn, url) + + # Trigger an unrelated URL re-patch by sorting a stable column. + view |> element("[data-testid='payment']") |> render_click() + path = assert_patch(view) + + assert path =~ "stichtag=2026-03-15" + assert path =~ "suspended=1" + assert path =~ "pay_filter=has_unpaid" + assert path =~ "pay_min=2" + assert path =~ "pay_max=4" + end +end diff --git a/test/mv_web/member_live/index_group_or_test.exs b/test/mv_web/member_live/index_group_or_test.exs new file mode 100644 index 00000000..ece478e1 --- /dev/null +++ b/test/mv_web/member_live/index_group_or_test.exs @@ -0,0 +1,72 @@ +defmodule MvWeb.MemberLive.IndexGroupOrTest do + @moduledoc """ + §1.29 / §2.7 — Multi-select group semantics is OR. + + Two "is" group selections match members in *either* group (union). Two + "is not" selections exclude members in *any* of them. Verified at the + `OverviewQuery` layer where the semantics live. + """ + use Mv.DataCase, async: false + + import Mv.Fixtures, only: [member_fixture_with_actor: 2, group_fixture: 1] + + alias Mv.Membership.Member + alias Mv.Membership.MemberGroup + alias MvWeb.MemberLive.Index.OverviewQuery + + setup do + actor = Mv.Helpers.SystemActor.get_system_actor() + Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor)) + + g1 = group_fixture(%{name: "G1-#{System.unique_integer([:positive])}"}) + g2 = group_fixture(%{name: "G2-#{System.unique_integer([:positive])}"}) + + m1 = member_fixture_with_actor(%{}, actor) + m2 = member_fixture_with_actor(%{}, actor) + m3 = member_fixture_with_actor(%{}, actor) + + add_to_group(m1, g1, actor) + add_to_group(m2, g2, actor) + + %{actor: actor, g1: g1, g2: g2, m1: m1, m2: m2, m3: m3} + end + + defp add_to_group(member, group, actor) do + MemberGroup + |> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id}) + |> Ash.create!(actor: actor) + end + + defp ids(opts, actor) do + opts + |> OverviewQuery.build() + |> Ash.read!(actor: actor) + |> MapSet.new(& &1.id) + end + + test "two 'is' group selections match members in either group (OR)", ctx do + result = + ids( + %{ + group_filters: %{to_string(ctx.g1.id) => :in, to_string(ctx.g2.id) => :in}, + groups: [ctx.g1, ctx.g2] + }, + ctx.actor + ) + + assert MapSet.equal?(result, MapSet.new([ctx.m1.id, ctx.m2.id])) + end + + test "two 'is not' group selections exclude members in any of them", ctx do + result = + ids( + %{ + group_filters: %{to_string(ctx.g1.id) => :not_in, to_string(ctx.g2.id) => :not_in}, + groups: [ctx.g1, ctx.g2] + }, + ctx.actor + ) + + assert MapSet.equal?(result, MapSet.new([ctx.m3.id])) + end +end diff --git a/test/mv_web/member_live/index_groups_filter_test.exs b/test/mv_web/member_live/index_groups_filter_test.exs index 639b3992..6bf133af 100644 --- a/test/mv_web/member_live/index_groups_filter_test.exs +++ b/test/mv_web/member_live/index_groups_filter_test.exs @@ -3,8 +3,8 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do Tests for filtering members by group in the member overview. Uses the filter dropdown (MemberFilterComponent) with one row per group: - All / Yes / No (per group). Multiple active group filters combine with AND - (member must match all selected group conditions). + All / Yes / No (per group). Multiple "is" selections combine with OR (union), + while each "is not" selection excludes members in that group (§1.29/§2.7). """ # Kept async: false as a deferred scope decision. The deferrable-FK migration # removed the concurrent-create_member deadlock that previously forced this, so diff --git a/test/mv_web/member_live/index_payment_count_codec_test.exs b/test/mv_web/member_live/index_payment_count_codec_test.exs new file mode 100644 index 00000000..fa2c6cd0 --- /dev/null +++ b/test/mv_web/member_live/index_payment_count_codec_test.exs @@ -0,0 +1,41 @@ +defmodule MvWeb.MemberLive.IndexPaymentCountCodecTest do + @moduledoc """ + §1.22 / §3.14 — URL codec for the two-sided payment open-cycle count range. + + The `{:unpaid_range, min, max}` filter round-trips through the flat URL + contract: encoding to params then decoding yields the canonical filter, with + the "has open" default (`min=1`, `max=nil`) and both-bounded ranges covered. + """ + use ExUnit.Case, async: true + + alias MvWeb.MemberLive.Index.PaymentAging + + defp roundtrip(filter), + do: filter |> PaymentAging.filter_to_params() |> PaymentAging.parse_filter_params() + + test "default has-open range (min 1, unbounded above) round-trips" do + assert roundtrip({:unpaid_range, 1, nil}) == {:unpaid_range, 1, nil} + end + + test "two-sided range round-trips" do + assert roundtrip({:unpaid_range, 2, 5}) == {:unpaid_range, 2, 5} + end + + test "exact single-value range round-trips" do + assert roundtrip({:unpaid_range, 3, 3}) == {:unpaid_range, 3, 3} + end + + test "has_unpaid params decode into an unpaid_range" do + params = %{"pay_filter" => "has_unpaid", "pay_min" => "2", "pay_max" => "4"} + assert PaymentAging.parse_filter_params(params) == {:unpaid_range, 2, 4} + end + + test "has_unpaid with no bounds defaults to min 1, max nil" do + assert PaymentAging.parse_filter_params(%{"pay_filter" => "has_unpaid"}) == + {:unpaid_range, 1, nil} + end + + test "fully_paid still round-trips" do + assert roundtrip(:fully_paid) == :fully_paid + end +end diff --git a/test/mv_web/member_live/index_payment_filter_test.exs b/test/mv_web/member_live/index_payment_filter_test.exs index f1b09fea..31ae2827 100644 --- a/test/mv_web/member_live/index_payment_filter_test.exs +++ b/test/mv_web/member_live/index_payment_filter_test.exs @@ -66,6 +66,24 @@ defmodule MvWeb.MemberLive.IndexPaymentFilterTest do refute MapSet.member?(ids({:has_unpaid, 1}, actor), zero.id) end + test "unpaid-range selects members whose in-period count is within [min, max]", %{ + actor: actor, + ft: ft + } do + zero = member_with_unpaid(0, actor, ft) + one = member_with_unpaid(1, actor, ft) + two = member_with_unpaid(2, actor, ft) + three = member_with_unpaid(3, actor, ft) + + # two-sided: 2..3 + assert ids({:unpaid_range, 2, 3}, actor) == MapSet.new([two.id, three.id]) + # lower-bounded only: >= 1 (max nil) + assert ids({:unpaid_range, 1, nil}, actor) == MapSet.new([one.id, two.id, three.id]) + # exact single value: 1..1 + assert ids({:unpaid_range, 1, 1}, actor) == MapSet.new([one.id]) + refute MapSet.member?(ids({:unpaid_range, 1, nil}, actor), zero.id) + end + test "suspended cycles do not count toward has-unpaid", %{actor: actor, ft: ft} do member = member_fixture_with_actor(%{}, actor) create_cycle(member, ft, %{cycle_start: ~D[2024-01-01], status: :suspended}, actor) diff --git a/test/mv_web/member_live/index_payment_period_test.exs b/test/mv_web/member_live/index_payment_period_test.exs index 7752b307..5f1977ac 100644 --- a/test/mv_web/member_live/index_payment_period_test.exs +++ b/test/mv_web/member_live/index_payment_period_test.exs @@ -40,6 +40,43 @@ defmodule MvWeb.MemberLive.IndexPaymentPeriodTest do end end + describe "period short code (§1.28)" do + test "the all-outstanding default has no short code" do + assert PaymentAging.period_short_code(%{from: nil, to: nil}) == nil + end + + test "a full calendar year renders as the year" do + assert PaymentAging.period_short_code(%{from: ~D[2026-01-01], to: ~D[2026-12-31]}) == "2026" + end + + test "a full calendar quarter renders as Q# YYYY" do + assert PaymentAging.period_short_code(%{from: ~D[2026-01-01], to: ~D[2026-03-31]}) == + "Q1 2026" + + assert PaymentAging.period_short_code(%{from: ~D[2026-10-01], to: ~D[2026-12-31]}) == + "Q4 2026" + end + + test "an arbitrary bounded period has no compact short code" do + assert PaymentAging.period_short_code(%{from: ~D[2026-02-03], to: ~D[2026-08-17]}) == nil + # An open-ended period is not compactly codeable either. + assert PaymentAging.period_short_code(%{from: ~D[2026-01-01], to: nil}) == nil + end + end + + describe "period tooltip (§1.28)" do + test "names the period with locally formatted bounds" do + tip = PaymentAging.period_tooltip(%{from: ~D[2026-01-01], to: ~D[2026-12-31]}) + assert tip =~ "01.01.2026" + assert tip =~ "31.12.2026" + end + + test "the all-outstanding default reads as all outstanding" do + tip = PaymentAging.period_tooltip(%{from: nil, to: nil}) + assert is_binary(tip) and tip =~ "outstanding" + end + end + describe "payment-count filter codec" do test "round-trips fully_paid and has-unpaid thresholds" do for state <- [:fully_paid, {:has_unpaid, 1}, {:has_unpaid, 2}, {:has_unpaid, 3}] do diff --git a/test/mv_web/member_live/index_payment_sort_test.exs b/test/mv_web/member_live/index_payment_sort_test.exs new file mode 100644 index 00000000..1ef14114 --- /dev/null +++ b/test/mv_web/member_live/index_payment_sort_test.exs @@ -0,0 +1,85 @@ +defmodule MvWeb.MemberLive.IndexPaymentSortTest do + @moduledoc """ + §1.27 — The Beiträge (payment) column sorts DB-side by the period-scoped + unpaid-cycle count, ascending and descending, with the unique id tie-breaker + preserving keyset stability. Verified at the `OverviewQuery` layer. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4] + + alias Mv.Membership.Member + alias MvWeb.MemberLive.Index.OverviewQuery + + @period %{from: ~D[2024-01-01], to: ~D[2024-12-31]} + + setup do + actor = Mv.Helpers.SystemActor.get_system_actor() + ft = create_fee_type(%{interval: :monthly}, actor) + Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor)) + %{actor: actor, ft: ft} + end + + defp member_with_unpaid(n, ctx) do + member = member_fixture_with_actor(%{}, ctx.actor) + + Enum.each(1..max(n, 0)//1, fn i -> + create_cycle( + member, + ctx.ft, + %{cycle_start: Date.new!(2024, i, 1), status: :unpaid}, + ctx.actor + ) + end) + + member + end + + defp sorted_ids(order, ctx) do + %{sort_field: :payment, sort_order: order, payment_period: @period} + |> OverviewQuery.build() + |> Ash.read!(actor: ctx.actor) + |> Enum.map(& &1.id) + end + + test "sorts ascending by in-period unpaid count", ctx do + zero = member_with_unpaid(0, ctx) + one = member_with_unpaid(1, ctx) + two = member_with_unpaid(2, ctx) + + assert sorted_ids(:asc, ctx) == [zero.id, one.id, two.id] + end + + test "sorts descending by in-period unpaid count", ctx do + zero = member_with_unpaid(0, ctx) + one = member_with_unpaid(1, ctx) + two = member_with_unpaid(2, ctx) + + assert sorted_ids(:desc, ctx) == [two.id, one.id, zero.id] + end + + describe "payment sort header (LiveView)" do + setup %{conn: conn} do + %{conn: conn_with_oidc_user(conn)} + end + + @tag :ui + test "clicking the payment header reflects the sort in the URL", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + view |> element("[data-testid='payment']") |> render_click() + + path = assert_patch(view) + assert path =~ "sort_field=payment" + assert path =~ "sort_order=asc" + end + + @tag :ui + test "the payment th exposes aria-sort when active", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members?sort_field=payment&sort_order=desc") + + assert has_element?(view, "th[aria-sort='descending']") + end + end +end diff --git a/test/mv_web/member_live/index_payment_wiring_test.exs b/test/mv_web/member_live/index_payment_wiring_test.exs index 8991ca08..8c7b0547 100644 --- a/test/mv_web/member_live/index_payment_wiring_test.exs +++ b/test/mv_web/member_live/index_payment_wiring_test.exs @@ -69,4 +69,25 @@ defmodule MvWeb.MemberLive.IndexPaymentWiringTest do {:ok, all_time, _} = live(ctx.conn, "/members?pay_filter=unpaid_1") assert has_element?(all_time, "#row-#{member.id}") end + + describe "period indicator badge (§1.28)" do + test "no badge in the default all-outstanding scope", ctx do + {:ok, view, _} = live(ctx.conn, "/members") + refute has_element?(view, "[data-testid='payment-period-badge']") + end + + test "a full year renders the compact year short code", ctx do + {:ok, view, _} = live(ctx.conn, "/members?pay_from=2026-01-01&pay_to=2026-12-31") + + assert has_element?(view, "[data-testid='payment-period-badge']", "2026") + end + + test "an arbitrary period falls back to a 'filtered' badge with a period tooltip", ctx do + {:ok, view, _} = live(ctx.conn, "/members?pay_from=2026-02-03&pay_to=2026-08-17") + + badge = view |> element("[data-testid='payment-period-badge']") |> render() + assert badge =~ "03.02.2026" + assert badge =~ "17.08.2026" + end + end end diff --git a/test/mv_web/member_live/index_status_params_codec_test.exs b/test/mv_web/member_live/index_status_params_codec_test.exs new file mode 100644 index 00000000..b1438969 --- /dev/null +++ b/test/mv_web/member_live/index_status_params_codec_test.exs @@ -0,0 +1,40 @@ +defmodule MvWeb.MemberLive.IndexStatusParamsCodecTest do + @moduledoc """ + §2.1 (extended) — URL codec round-trip for the new v2 filter keys: the + point-in-time Stichtag date and the suspended-status flag. Encoding a state to + params then decoding yields the canonical state, and the default (no filter) + encodes to the empty map so a fresh URL is canonical. + """ + use ExUnit.Case, async: true + + alias MvWeb.MemberLive.Index.PaymentAging + alias MvWeb.MemberLive.Index.Stichtag + + describe "Stichtag date" do + test "a set date round-trips" do + date = ~D[2026-03-15] + assert date |> Stichtag.to_params() |> Stichtag.parse() == date + end + + test "nil encodes to the empty map and decodes to nil" do + assert Stichtag.to_params(nil) == %{} + assert Stichtag.parse(%{}) == nil + end + + test "a malformed date param decodes to nil" do + assert Stichtag.parse(%{"stichtag" => "not-a-date"}) == nil + end + end + + describe "suspended flag" do + test "true round-trips" do + assert true |> PaymentAging.suspended_to_params() |> PaymentAging.parse_suspended() == true + end + + test "false/nil encodes to the empty map and decodes to false" do + assert PaymentAging.suspended_to_params(false) == %{} + assert PaymentAging.suspended_to_params(nil) == %{} + assert PaymentAging.parse_suspended(%{}) == false + end + end +end diff --git a/test/mv_web/member_live/index_stichtag_property_test.exs b/test/mv_web/member_live/index_stichtag_property_test.exs new file mode 100644 index 00000000..017d08fb --- /dev/null +++ b/test/mv_web/member_live/index_stichtag_property_test.exs @@ -0,0 +1,78 @@ +defmodule MvWeb.MemberLive.IndexStichtagPropertyTest do + @moduledoc """ + §2.6 — Point-in-time membership ("member on the reference date X"). + + For arbitrary (join_date, exit_date) pairs and reference dates X, the Stichtag + filter keeps a member iff `join_date <= X and (exit_date is nil or exit_date > X)`. + Verified at the `OverviewQuery` layer against an in-memory oracle. + """ + use Mv.DataCase, async: false + use ExUnitProperties + + import Mv.Fixtures, only: [member_fixture_with_actor: 2] + + alias Mv.Membership.Member + alias MvWeb.MemberLive.Index.OverviewQuery + + @dates [ + ~D[2023-01-01], + ~D[2024-06-15], + ~D[2025-01-01], + ~D[2025-06-15], + ~D[2026-01-01] + ] + + defp clear(actor) do + Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor)) + end + + defp on_date?(join, exit_date, x) do + Date.compare(join, x) != :gt and (is_nil(exit_date) or Date.compare(exit_date, x) == :gt) + end + + # Generates a (join_date, exit_date) pair where exit_date is nil or on/after + # join_date, so it satisfies the Member "exit not before join" validation. + defp valid_pair_gen do + StreamData.bind(StreamData.member_of(@dates), fn join -> + later = [nil | Enum.filter(@dates, &(Date.compare(&1, join) == :gt))] + + StreamData.bind(StreamData.member_of(later), fn exit_date -> + StreamData.constant({join, exit_date}) + end) + end) + end + + property "Stichtag filter keeps exactly the members active on X" do + actor = Mv.Helpers.SystemActor.get_system_actor() + + check all( + specs <- StreamData.list_of(valid_pair_gen(), min_length: 1, max_length: 5), + x <- StreamData.member_of(@dates), + max_runs: 25 + ) do + clear(actor) + + created = + Enum.map(specs, fn {join, exit_date} -> + member = + member_fixture_with_actor(%{join_date: join, exit_date: exit_date}, actor) + + {member.id, join, exit_date} + end) + + expected = + for {id, join, exit_date} <- created, + on_date?(join, exit_date, x), + into: MapSet.new(), + do: id + + actual = + %{stichtag: x} + |> OverviewQuery.build() + |> Ash.read!(actor: actor) + |> MapSet.new(& &1.id) + + assert MapSet.equal?(actual, expected) + end + end +end diff --git a/test/mv_web/member_live/index_suspended_filter_test.exs b/test/mv_web/member_live/index_suspended_filter_test.exs new file mode 100644 index 00000000..add447f9 --- /dev/null +++ b/test/mv_web/member_live/index_suspended_filter_test.exs @@ -0,0 +1,58 @@ +defmodule MvWeb.MemberLive.IndexSuspendedFilterTest do + @moduledoc """ + §1.23 — Filter by `suspended` status. + + With the suspended filter active, the overview keeps members that have at + least one `suspended` cycle whose `cycle_end` falls in the active period. + Suspended is filterable as its own status, independent of the unpaid count + (which excludes suspended, §2.5). Verified at the `OverviewQuery` layer. + """ + use Mv.DataCase, async: false + + import Mv.Fixtures, only: [member_fixture_with_actor: 2, create_fee_type: 2, create_cycle: 4] + + alias Mv.Membership.Member + alias MvWeb.MemberLive.Index.OverviewQuery + + setup do + actor = Mv.Helpers.SystemActor.get_system_actor() + Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor)) + ft = create_fee_type(%{interval: :yearly}, actor) + %{actor: actor, ft: ft} + end + + defp member_with_cycle(ctx, status, cycle_start) do + m = member_fixture_with_actor(%{}, ctx.actor) + create_cycle(m, ctx.ft, %{cycle_start: cycle_start, status: status}, ctx.actor) + m + end + + defp ids(opts, actor) do + opts |> OverviewQuery.build() |> Ash.read!(actor: actor) |> MapSet.new(& &1.id) + end + + test "keeps members with a suspended cycle whose cycle_end is in the period", ctx do + suspended_in = member_with_cycle(ctx, :suspended, ~D[2025-01-01]) + _unpaid = member_with_cycle(ctx, :unpaid, ~D[2025-01-01]) + _paid = member_with_cycle(ctx, :paid, ~D[2025-01-01]) + _suspended_out = member_with_cycle(ctx, :suspended, ~D[2023-01-01]) + + result = + ids( + %{suspended: true, payment_period: %{from: ~D[2025-01-01], to: ~D[2025-12-31]}}, + ctx.actor + ) + + assert MapSet.equal?(result, MapSet.new([suspended_in.id])) + end + + test "all-time period keeps every member with any suspended cycle", ctx do + s1 = member_with_cycle(ctx, :suspended, ~D[2025-01-01]) + s2 = member_with_cycle(ctx, :suspended, ~D[2023-01-01]) + _unpaid = member_with_cycle(ctx, :unpaid, ~D[2025-01-01]) + + result = ids(%{suspended: true, payment_period: %{from: nil, to: nil}}, ctx.actor) + + assert MapSet.equal?(result, MapSet.new([s1.id, s2.id])) + end +end