feat(overview): add point-in-time, suspended, OR-group and count-range filters
This commit is contained in:
parent
c547ca19af
commit
c0d1550401
23 changed files with 1116 additions and 117 deletions
|
|
@ -44,6 +44,14 @@ defmodule Mv.Constants do
|
||||||
|
|
||||||
@payment_filter_param "pay_filter"
|
@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_boolean_filters 50
|
||||||
|
|
||||||
@max_mailto_bulk_recipients 50
|
@max_mailto_bulk_recipients 50
|
||||||
|
|
@ -200,6 +208,49 @@ defmodule Mv.Constants do
|
||||||
"""
|
"""
|
||||||
def payment_filter_param, do: @payment_filter_param
|
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 """
|
@doc """
|
||||||
Returns the maximum number of boolean custom field filters allowed per request.
|
Returns the maximum number of boolean custom field filters allowed per request.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
|
||||||
|
|
||||||
alias Mv.Constants
|
alias Mv.Constants
|
||||||
alias MvWeb.MemberLive.Index.DateFilter
|
alias MvWeb.MemberLive.Index.DateFilter
|
||||||
|
alias MvWeb.MemberLive.Index.DatePresets
|
||||||
alias MvWeb.MemberLive.Index.FilterDescriptor
|
alias MvWeb.MemberLive.Index.FilterDescriptor
|
||||||
alias MvWeb.MemberLive.Index.FilterParams
|
alias MvWeb.MemberLive.Index.FilterParams
|
||||||
alias MvWeb.MemberLive.Index.PaymentAging
|
alias MvWeb.MemberLive.Index.PaymentAging
|
||||||
|
|
@ -968,32 +969,30 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
|
||||||
# --- date presets ---------------------------------------------------------
|
# --- date presets ---------------------------------------------------------
|
||||||
|
|
||||||
defp date_presets do
|
defp date_presets do
|
||||||
[
|
Enum.map(DatePresets.all(), fn preset -> {Atom.to_string(preset), preset_label(preset)} end)
|
||||||
{"this_year", gettext("This year")},
|
|
||||||
{"last_year", gettext("Last year")},
|
|
||||||
{"this_month", gettext("This month")},
|
|
||||||
{"last_30_days", gettext("Last 30 days")}
|
|
||||||
]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp preset_range("this_year") do
|
defp preset_label(:last_7_days), do: gettext("Last 7 days")
|
||||||
y = Date.utc_today().year
|
defp preset_label(:last_30_days), do: gettext("Last 30 days")
|
||||||
{Date.new!(y, 1, 1), Date.new!(y, 12, 31)}
|
defp preset_label(:last_3_months), do: gettext("Last 3 months")
|
||||||
end
|
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
|
# Resolves a preset string (from `phx-value-preset`) to a concrete `{from, to}`
|
||||||
y = Date.utc_today().year - 1
|
# tuple via the shared, clock-free `DatePresets` resolver, or nil for an
|
||||||
{Date.new!(y, 1, 1), Date.new!(y, 12, 31)}
|
# unknown preset. The resolver defines "this year" as period-to-date
|
||||||
end
|
# ([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
|
key ->
|
||||||
today = Date.utc_today()
|
%{from: from, to: to} = DatePresets.resolve(key)
|
||||||
{Date.beginning_of_month(today), Date.end_of_month(today)}
|
{from, to}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp preset_range("last_30_days") do
|
|
||||||
today = Date.utc_today()
|
|
||||||
{Date.add(today, -30), today}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp preset_range(_), do: nil
|
defp preset_range(_), do: nil
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||||
alias MvWeb.MemberLive.Index.OverviewQuery
|
alias MvWeb.MemberLive.Index.OverviewQuery
|
||||||
alias MvWeb.MemberLive.Index.PaymentAging
|
alias MvWeb.MemberLive.Index.PaymentAging
|
||||||
|
alias MvWeb.MemberLive.Index.Stichtag
|
||||||
alias MvWeb.MemberLive.Index.ViewSettings
|
alias MvWeb.MemberLive.Index.ViewSettings
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
@ -177,6 +178,8 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> assign_new(:sort_order, fn -> :asc end)
|
|> assign_new(:sort_order, fn -> :asc end)
|
||||||
|> assign(:payment_filter, nil)
|
|> assign(:payment_filter, nil)
|
||||||
|> assign(:payment_period, PaymentAging.default_period())
|
|> assign(:payment_period, PaymentAging.default_period())
|
||||||
|
|> assign(:suspended, false)
|
||||||
|
|> assign(:stichtag, nil)
|
||||||
|> assign(:group_filters, %{})
|
|> assign(:group_filters, %{})
|
||||||
|> assign(:groups, groups)
|
|> assign(:groups, groups)
|
||||||
|> assign(:fee_type_filters, %{})
|
|> assign(:fee_type_filters, %{})
|
||||||
|
|
@ -679,6 +682,8 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> maybe_update_sort(params)
|
|> maybe_update_sort(params)
|
||||||
|> maybe_update_payment_filter(params)
|
|> maybe_update_payment_filter(params)
|
||||||
|> maybe_update_payment_period(params)
|
|> maybe_update_payment_period(params)
|
||||||
|
|> maybe_update_suspended(params)
|
||||||
|
|> maybe_update_stichtag(params)
|
||||||
|> maybe_update_group_filters(params)
|
|> maybe_update_group_filters(params)
|
||||||
|> maybe_update_fee_type_filters(params)
|
|> maybe_update_fee_type_filters(params)
|
||||||
|> maybe_update_boolean_filters(params)
|
|> maybe_update_boolean_filters(params)
|
||||||
|
|
@ -728,6 +733,8 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket.assigns.sort_order,
|
socket.assigns.sort_order,
|
||||||
socket.assigns.payment_filter,
|
socket.assigns.payment_filter,
|
||||||
socket.assigns.payment_period,
|
socket.assigns.payment_period,
|
||||||
|
socket.assigns[:suspended],
|
||||||
|
socket.assigns[:stichtag],
|
||||||
socket.assigns[:group_filters],
|
socket.assigns[:group_filters],
|
||||||
socket.assigns[:fee_type_filters],
|
socket.assigns[:fee_type_filters],
|
||||||
socket.assigns.boolean_custom_field_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_group_filters(base_params, opts.group_filters || %{})
|
||||||
base_params = add_fee_type_filters(base_params, opts.fee_type_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_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 || %{})
|
base_params = add_boolean_filters(base_params, opts.boolean_filters || %{})
|
||||||
add_date_filters(base_params, opts.date_filters)
|
add_date_filters(base_params, opts.date_filters)
|
||||||
end
|
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-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).
|
# period both serialize into the flat URL contract (pay_filter/pay_from/pay_to).
|
||||||
defp add_payment_params(params, payment_filter, payment_period) do
|
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,
|
sort_order: socket.assigns.sort_order,
|
||||||
payment_filter: socket.assigns.payment_filter,
|
payment_filter: socket.assigns.payment_filter,
|
||||||
payment_period: socket.assigns.payment_period,
|
payment_period: socket.assigns.payment_period,
|
||||||
|
suspended: socket.assigns[:suspended] || false,
|
||||||
|
stichtag: socket.assigns[:stichtag],
|
||||||
group_filters: socket.assigns[:group_filters] || %{},
|
group_filters: socket.assigns[:group_filters] || %{},
|
||||||
boolean_filters: socket.assigns.boolean_custom_field_filters || %{},
|
boolean_filters: socket.assigns.boolean_custom_field_filters || %{},
|
||||||
fee_type_filters: socket.assigns[:fee_type_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],
|
date_custom_fields: socket.assigns[:date_custom_fields],
|
||||||
payment_filter: socket.assigns.payment_filter,
|
payment_filter: socket.assigns.payment_filter,
|
||||||
payment_period: socket.assigns.payment_period,
|
payment_period: socket.assigns.payment_period,
|
||||||
|
suspended: socket.assigns[:suspended] || false,
|
||||||
|
stichtag: socket.assigns[:stichtag],
|
||||||
sort_field: socket.assigns.sort_field,
|
sort_field: socket.assigns.sort_field,
|
||||||
sort_order: socket.assigns.sort_order,
|
sort_order: socket.assigns.sort_order,
|
||||||
custom_fields: socket.assigns.all_custom_fields
|
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
|
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
|
||||||
|
|
||||||
field in valid_fields or custom_field_sort?(field) or
|
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
|
end
|
||||||
|
|
||||||
defp valid_sort_field_db_or_custom?(field) when is_binary(field) do
|
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 == "membership_fee_type" -> :membership_fee_type
|
||||||
field == "name" -> :name
|
field == "name" -> :name
|
||||||
field == "address" -> :address
|
field == "address" -> :address
|
||||||
|
field == "payment" -> :payment
|
||||||
true -> safe_member_field_atom_only(field)
|
true -> safe_member_field_atom_only(field)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -1507,9 +1528,10 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
defp determine_field(default, nil), do: default
|
defp determine_field(default, nil), do: default
|
||||||
|
|
||||||
# Computed/pseudo fields that are nonetheless sortable (they resolve to DB
|
# Computed/pseudo fields that are nonetheless sortable (they resolve to DB
|
||||||
# sort keys in OverviewQuery): groups aggregate and the composite Name/Address
|
# sort keys in OverviewQuery): groups aggregate, the composite Name/Address
|
||||||
# columns. Other computed fields (e.g. membership_fee_status) are not sortable.
|
# columns, and the period-scoped payment (unpaid-cycle-count) column. Other
|
||||||
@sortable_computed_fields [:groups, :name, :address]
|
# 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
|
defp determine_field(default, sf) when is_binary(sf) do
|
||||||
sortable_strings = Enum.map(@sortable_computed_fields, &Atom.to_string/1)
|
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),
|
defp maybe_update_payment_period(socket, params),
|
||||||
do: assign(socket, :payment_period, PaymentAging.parse_period(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
|
defp maybe_update_group_filters(socket, params) when is_map(params) do
|
||||||
prefix = @group_filter_prefix
|
prefix = @group_filter_prefix
|
||||||
prefix_len = String.length(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
|
all-outstanding default (both bounds nil) reads just "Payment"; a bounded
|
||||||
period appends the range so the scope is always legible.
|
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
|
@doc """
|
||||||
range = "#{payment_bound(from)}–#{payment_bound(to)}"
|
Whether a payment period is actively scoped (at least one bound set), used to
|
||||||
gettext("Fees · %{range}", range: range)
|
decide whether the compact period indicator badge renders (§1.28). The
|
||||||
end
|
all-outstanding default (both bounds nil) shows no badge.
|
||||||
|
"""
|
||||||
def payment_column_label(_), do: gettext("Fees")
|
def payment_period_active?(%{from: nil, to: nil}), do: false
|
||||||
|
def payment_period_active?(%{from: _, to: _}), do: true
|
||||||
defp payment_bound(%Date{} = d), do: Date.to_iso8601(d)
|
def payment_period_active?(_), do: false
|
||||||
defp payment_bound(_), do: "…"
|
|
||||||
|
|
||||||
defp update_selection_assigns(socket) do
|
defp update_selection_assigns(socket) do
|
||||||
selected_members = socket.assigns.selected_members
|
selected_members = socket.assigns.selected_members
|
||||||
|
|
|
||||||
|
|
@ -456,7 +456,31 @@
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:membership_fee_status in @member_fields_visible}
|
:if={:membership_fee_status in @member_fields_visible}
|
||||||
|
sort_field={:payment}
|
||||||
|
label={
|
||||||
|
~H"""
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<.live_component
|
||||||
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
|
id={:sort_payment}
|
||||||
|
field={:payment}
|
||||||
label={MvWeb.MemberLive.Index.payment_column_label(@payment_period)}
|
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}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
:if={MvWeb.MemberLive.Index.payment_period_active?(@payment_period)}
|
||||||
|
class="badge badge-soft badge-info badge-sm shrink-0"
|
||||||
|
title={PaymentAging.period_tooltip(@payment_period)}
|
||||||
|
aria-label={PaymentAging.period_tooltip(@payment_period)}
|
||||||
|
data-testid="payment-period-badge"
|
||||||
|
>
|
||||||
|
{PaymentAging.period_short_code(@payment_period) || gettext("filtered")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<% count = member.unpaid_cycle_count || 0 %>
|
<% count = member.unpaid_cycle_count || 0 %>
|
||||||
<% badge = PaymentAging.badge(count) %>
|
<% badge = PaymentAging.badge(count) %>
|
||||||
|
|
|
||||||
70
lib/mv_web/live/member_live/index/date_presets.ex
Normal file
70
lib/mv_web/live/member_live/index/date_presets.ex
Normal file
|
|
@ -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
|
||||||
|
|
@ -46,9 +46,16 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
|
||||||
opts[:boolean_custom_fields]
|
opts[:boolean_custom_fields]
|
||||||
)
|
)
|
||||||
|> apply_date_filters(opts[:date_filters])
|
|> apply_date_filters(opts[:date_filters])
|
||||||
|
|> apply_stichtag_filter(opts[:stichtag])
|
||||||
|> apply_custom_date_filters(opts[:date_filters], opts[:date_custom_fields])
|
|> apply_custom_date_filters(opts[:date_filters], opts[:date_custom_fields])
|
||||||
|> apply_payment_filter(opts[:payment_filter], payment_period(opts))
|
|> 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
|
end
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -61,7 +68,12 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
|
||||||
defp apply_search(query, _), do: query
|
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)
|
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
|
defp apply_group_filters(query, group_filters, groups) do
|
||||||
valid_ids = valid_id_set(groups)
|
valid_ids = valid_id_set(groups)
|
||||||
|
|
||||||
Enum.reduce(group_filters, query, fn {group_id_str, value}, q ->
|
{in_filters, not_in_filters} =
|
||||||
if MapSet.member?(valid_ids, group_id_str) do
|
group_filters
|
||||||
apply_one_group_filter(q, group_id_str, value)
|
|> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end)
|
||||||
else
|
|> Enum.split_with(fn {_, value} -> value == :in end)
|
||||||
q
|
|
||||||
|
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)
|
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)
|
# Fee-type filters (:in OR; :not_in AND)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -209,8 +222,53 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
|
||||||
)
|
)
|
||||||
end
|
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_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)
|
# 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
|
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)
|
# 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, nil, _order, _custom_fields, _period),
|
||||||
defp apply_sort(query, _field, nil, _custom_fields), do: Ash.Query.sort(query, id: :asc)
|
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
|
# 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).
|
# 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}])
|
Ash.Query.sort(query, [{:last_name, order}, {:first_name, order}, {:id, :asc}])
|
||||||
end
|
end
|
||||||
|
|
||||||
# Composite "Address" column: sort by city, then postal code, then street.
|
# 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, [
|
Ash.Query.sort(query, [
|
||||||
{:city, order},
|
{:city, order},
|
||||||
{:postal_code, order},
|
{:postal_code, order},
|
||||||
|
|
@ -249,7 +327,20 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
|
||||||
])
|
])
|
||||||
end
|
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
|
case sort_key(field) do
|
||||||
nil -> apply_custom_field_sort(query, field, order, custom_fields)
|
nil -> apply_custom_field_sort(query, field, order, custom_fields)
|
||||||
key -> Ash.Query.sort(query, [{key, order}, {:id, :asc}])
|
key -> Ash.Query.sort(query, [{key, order}, {:id, :asc}])
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,18 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
|
||||||
|
|
||||||
@type period :: %{from: Date.t() | nil, to: Date.t() | nil}
|
@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_from_param Constants.payment_period_from_param()
|
||||||
@payment_period_to_param Constants.payment_period_to_param()
|
@payment_period_to_param Constants.payment_period_to_param()
|
||||||
@payment_filter_param Constants.payment_filter_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 """
|
@doc """
|
||||||
The default period: all outstanding cycles, all time (both bounds nil).
|
The default period: all outstanding cycles, all time (both bounds nil).
|
||||||
|
|
@ -83,6 +90,10 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Encodes a payment-count filter into URL params. `nil` yields the empty map.
|
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()}
|
@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(: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,
|
def filter_to_params({:has_unpaid, n}) when n in 1..3,
|
||||||
do: %{@payment_filter_param => "unpaid_#{n}"}
|
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: %{}
|
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 """
|
@doc """
|
||||||
Decodes the payment-count filter from a full params map.
|
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()
|
@spec parse_filter_params(map()) :: filter()
|
||||||
def parse_filter_params(params) when is_map(params),
|
def parse_filter_params(params) when is_map(params) do
|
||||||
do: parse_filter(Map.get(params, @payment_filter_param))
|
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 """
|
@doc """
|
||||||
Badge descriptor for an unpaid-cycle count. A count of 0 reads "Alle bezahlt"
|
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
|
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 """
|
@doc """
|
||||||
A member's open cycles for the badge tooltip: the unpaid and suspended cycles
|
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
|
whose `cycle_end` falls inside `period`, merged into a single list sorted
|
||||||
|
|
|
||||||
36
lib/mv_web/live/member_live/index/stichtag.ex
Normal file
36
lib/mv_web/live/member_live/index/stichtag.ex
Normal file
|
|
@ -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
|
||||||
|
|
@ -3956,6 +3956,7 @@ msgid "email %{email} has already been taken"
|
||||||
msgstr "E-Mail %{email} wurde bereits verwendet"
|
msgstr "E-Mail %{email} wurde bereits verwendet"
|
||||||
|
|
||||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "filtered"
|
msgid "filtered"
|
||||||
msgstr "gefiltert"
|
msgstr "gefiltert"
|
||||||
|
|
@ -4212,11 +4213,6 @@ msgstr "Beigetreten"
|
||||||
msgid "Last 30 days"
|
msgid "Last 30 days"
|
||||||
msgstr "Letzte 30 Tage"
|
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
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Membership"
|
msgid "Membership"
|
||||||
|
|
@ -4307,11 +4303,6 @@ msgstr "Februar"
|
||||||
msgid "Fees"
|
msgid "Fees"
|
||||||
msgstr "Beiträge"
|
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
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "January"
|
msgid "January"
|
||||||
|
|
@ -4347,27 +4338,37 @@ msgstr "Oktober"
|
||||||
msgid "September"
|
msgid "September"
|
||||||
msgstr "September"
|
msgstr "September"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
#~ msgid "Click for member details"
|
msgid "Click to sort by open payment cycles"
|
||||||
#~ msgstr "Klicke für Mitglieds-Details"
|
msgstr "Klicke, um nach offenen Beitragszyklen zu sortieren"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
#~ msgid "No open cycles for this period"
|
msgid "Last 12 months"
|
||||||
#~ msgstr "Keine offenen Zyklen in diesem Zeitraum"
|
msgstr "Letzte 12 Monate"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/member_live/index.ex
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
#~ msgid "Payment · %{range}"
|
msgid "Last 3 months"
|
||||||
#~ msgstr "Zahlung · %{range}"
|
msgstr "Letzte 3 Monate"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
#~ msgid "Suspended: %{date}"
|
msgid "Last 7 days"
|
||||||
#~ msgstr "Ausgesetzt: %{date}"
|
msgstr "Letzte 7 Tage"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
#~ msgid "Unpaid until %{date}"
|
msgid "This quarter"
|
||||||
#~ msgstr "Offen bis %{date}"
|
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}"
|
||||||
|
|
|
||||||
|
|
@ -3956,6 +3956,7 @@ msgid "email %{email} has already been taken"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "filtered"
|
msgid "filtered"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -4212,11 +4213,6 @@ msgstr ""
|
||||||
msgid "Last 30 days"
|
msgid "Last 30 days"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/components/add_filter_builder_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Membership"
|
msgid "Membership"
|
||||||
|
|
@ -4307,11 +4303,6 @@ msgstr ""
|
||||||
msgid "Fees"
|
msgid "Fees"
|
||||||
msgstr ""
|
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
|
#: lib/mv_web/live/member_live/index/payment_aging.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "January"
|
msgid "January"
|
||||||
|
|
@ -4346,3 +4337,38 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "September"
|
msgid "September"
|
||||||
msgstr ""
|
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 ""
|
||||||
|
|
|
||||||
66
test/mv_web/member_live/index_date_presets_property_test.exs
Normal file
66
test/mv_web/member_live/index_date_presets_property_test.exs
Normal file
|
|
@ -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
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
defmodule MvWeb.MemberLive.IndexDatePresetsTest do
|
defmodule MvWeb.MemberLive.IndexDatePresetsTest do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
§1.8 — a date field's value control offers presets; picking "This year"
|
§1.25 (supersedes §1.8) — a date field's value control offers relative
|
||||||
applies the Jan 1 – Dec 31 range of the current year and writes the equivalent
|
presets; picking "This year" applies the period-to-date range (Jan 1 – today)
|
||||||
range params to the URL.
|
of the current year and writes the equivalent range params to the URL.
|
||||||
"""
|
"""
|
||||||
use MvWeb.ConnCase, async: false
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
|
@ -20,7 +20,7 @@ defmodule MvWeb.MemberLive.IndexDatePresetsTest do
|
||||||
%{conn: conn_with_oidc_user(conn)}
|
%{conn: conn_with_oidc_user(conn)}
|
||||||
end
|
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
|
conn: conn
|
||||||
} do
|
} do
|
||||||
{:ok, view, _html} = live(conn, ~p"/members")
|
{: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()
|
view |> element("[data-testid='date-preset-this_year']") |> render_click()
|
||||||
|
|
||||||
path = assert_patch(view)
|
path = assert_patch(view)
|
||||||
year = Date.utc_today().year
|
today = Date.utc_today()
|
||||||
assert path =~ "jd_from=#{year}-01-01"
|
assert path =~ "jd_from=#{today.year}-01-01"
|
||||||
assert path =~ "jd_to=#{year}-12-31"
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
38
test/mv_web/member_live/index_filter_url_roundtrip_test.exs
Normal file
38
test/mv_web/member_live/index_filter_url_roundtrip_test.exs
Normal file
|
|
@ -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
|
||||||
72
test/mv_web/member_live/index_group_or_test.exs
Normal file
72
test/mv_web/member_live/index_group_or_test.exs
Normal file
|
|
@ -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
|
||||||
|
|
@ -3,8 +3,8 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
|
||||||
Tests for filtering members by group in the member overview.
|
Tests for filtering members by group in the member overview.
|
||||||
|
|
||||||
Uses the filter dropdown (MemberFilterComponent) with one row per group:
|
Uses the filter dropdown (MemberFilterComponent) with one row per group:
|
||||||
All / Yes / No (per group). Multiple active group filters combine with AND
|
All / Yes / No (per group). Multiple "is" selections combine with OR (union),
|
||||||
(member must match all selected group conditions).
|
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
|
# Kept async: false as a deferred scope decision. The deferrable-FK migration
|
||||||
# removed the concurrent-create_member deadlock that previously forced this, so
|
# removed the concurrent-create_member deadlock that previously forced this, so
|
||||||
|
|
|
||||||
41
test/mv_web/member_live/index_payment_count_codec_test.exs
Normal file
41
test/mv_web/member_live/index_payment_count_codec_test.exs
Normal file
|
|
@ -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
|
||||||
|
|
@ -66,6 +66,24 @@ defmodule MvWeb.MemberLive.IndexPaymentFilterTest do
|
||||||
refute MapSet.member?(ids({:has_unpaid, 1}, actor), zero.id)
|
refute MapSet.member?(ids({:has_unpaid, 1}, actor), zero.id)
|
||||||
end
|
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
|
test "suspended cycles do not count toward has-unpaid", %{actor: actor, ft: ft} do
|
||||||
member = member_fixture_with_actor(%{}, actor)
|
member = member_fixture_with_actor(%{}, actor)
|
||||||
create_cycle(member, ft, %{cycle_start: ~D[2024-01-01], status: :suspended}, actor)
|
create_cycle(member, ft, %{cycle_start: ~D[2024-01-01], status: :suspended}, actor)
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,43 @@ defmodule MvWeb.MemberLive.IndexPaymentPeriodTest do
|
||||||
end
|
end
|
||||||
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
|
describe "payment-count filter codec" do
|
||||||
test "round-trips fully_paid and has-unpaid thresholds" 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
|
for state <- [:fully_paid, {:has_unpaid, 1}, {:has_unpaid, 2}, {:has_unpaid, 3}] do
|
||||||
|
|
|
||||||
85
test/mv_web/member_live/index_payment_sort_test.exs
Normal file
85
test/mv_web/member_live/index_payment_sort_test.exs
Normal file
|
|
@ -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
|
||||||
|
|
@ -69,4 +69,25 @@ defmodule MvWeb.MemberLive.IndexPaymentWiringTest do
|
||||||
{:ok, all_time, _} = live(ctx.conn, "/members?pay_filter=unpaid_1")
|
{:ok, all_time, _} = live(ctx.conn, "/members?pay_filter=unpaid_1")
|
||||||
assert has_element?(all_time, "#row-#{member.id}")
|
assert has_element?(all_time, "#row-#{member.id}")
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
40
test/mv_web/member_live/index_status_params_codec_test.exs
Normal file
40
test/mv_web/member_live/index_status_params_codec_test.exs
Normal file
|
|
@ -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
|
||||||
78
test/mv_web/member_live/index_stichtag_property_test.exs
Normal file
78
test/mv_web/member_live/index_stichtag_property_test.exs
Normal file
|
|
@ -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
|
||||||
58
test/mv_web/member_live/index_suspended_filter_test.exs
Normal file
58
test/mv_web/member_live/index_suspended_filter_test.exs
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue