+ <%!-- Two-row overview toolbar (Option C): row 1 is search + column/view
+ controls; row 2 is the dedicated filter row (add-filter + applied chips). --%>
+
+
+ <.live_component
+ module={MvWeb.Components.SearchBarComponent}
+ id="search-bar"
+ query={@query}
+ placeholder={gettext("Search...")}
+ />
+
+ <.live_component
+ module={MvWeb.Components.FieldVisibilityDropdownComponent}
+ id="field-visibility-dropdown"
+ all_fields={@all_available_fields}
+ custom_fields={@all_custom_fields}
+ selected_fields={@user_field_selection}
+ />
+ <.live_component
+ module={MvWeb.Components.ViewSettingsDropdownComponent}
+ id="view-settings-dropdown"
+ density={@density}
+ compact_member={@compact_member}
+ member_include_email={@member_include_email}
+ compact_address={@compact_address}
+ />
+
+
+
<.live_component
- module={MvWeb.Components.SearchBarComponent}
- id="search-bar"
- query={@query}
- placeholder={gettext("Search...")}
- />
- <.live_component
- module={MvWeb.Components.MemberFilterComponent}
+ module={MvWeb.Components.AddFilterBuilderComponent}
id="member-filter"
- cycle_status_filter={@cycle_status_filter}
groups={@groups}
group_filters={@group_filters}
fee_types={@fee_types}
@@ -40,57 +61,11 @@
boolean_filters={@boolean_custom_field_filters}
date_custom_fields={@date_custom_fields}
date_filters={@date_filters}
- member_count={@total_count}
+ payment_filter={@payment_filter}
+ payment_period={@payment_period}
+ suspended={@suspended}
+ as_of_date={@as_of_date}
/>
- <.tooltip
- content={
- gettext(
- "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle."
- )
- }
- position="top"
- >
- <.button
- type="button"
- variant="secondary"
- class="gap-2"
- active={@show_current_cycle}
- aria-pressed={to_string(@show_current_cycle)}
- phx-click="toggle_cycle_view"
- data-testid="toggle-cycle-view"
- aria-label={
- if(@show_current_cycle,
- do: gettext("Current payment cycle"),
- else: gettext("Last payment cycle")
- )
- }
- >
- <.icon name="hero-arrow-path" class="h-5 w-5" />
-
- {if(@show_current_cycle,
- do: gettext("Current payment cycle"),
- else: gettext("Last payment cycle")
- )}
-
-
-
-
- <.live_component
- module={MvWeb.Components.FieldVisibilityDropdownComponent}
- id="field-visibility-dropdown"
- all_fields={@all_available_fields}
- custom_fields={@all_custom_fields}
- selected_fields={@user_field_selection}
- />
- <.live_component
- module={MvWeb.Components.ViewSettingsDropdownComponent}
- id="view-settings-dropdown"
- density={@density}
- compact_member={@compact_member}
- member_include_email={@member_include_email}
- compact_address={@compact_address}
- />
-
<%!-- Polite live region: present on first render (before it is filled) so
@@ -131,7 +106,6 @@
JS.push("select_row_and_navigate", value: %{id: member.id})
end
}
- row_tooltip={gettext("Click for member details")}
row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end}
dynamic_cols={@dynamic_cols}
sort_field={@sort_field}
@@ -484,17 +458,116 @@
<:col
:let={member}
:if={:membership_fee_status in @member_fields_visible}
- label={gettext("Membership Fee Status")}
+ 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}
+ />
+ <.badge
+ :if={MvWeb.MemberLive.Index.payment_period_active?(@payment_period)}
+ variant="primary"
+ size="sm"
+ class="font-normal 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")}
+
+
+ """
+ }
>
- <%= if badge = MembershipFeeStatus.format_cycle_status_badge(
- MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle)
- ) do %>
- <.badge variant={badge.variant}>
- <.icon name={badge.icon} class="size-4" />
- {badge.label}
-
- <% else %>
- <.empty_cell sr_text={gettext("No cycle")} />
+ <% count = member.unpaid_cycle_count || 0 %>
+ <% badge = PaymentAging.badge(count) %>
+ <% tip_id = "payment-tip-#{member.id}" %>
+ <% anchor = "--pay-#{member.id}" %>
+ <%!-- The badge is a real navigation link (semantic, keyboard- and
+ right-click-friendly) into the member's payment history. Its hover/focus
+ tooltip is a native Popover-API element in the top layer, placed via CSS
+ anchor positioning (see the `payment-tip` class + the HoverPopover hook),
+ so it escapes the members table's `overflow` clipping without any custom
+ coordinate math. No tooltip renders when the member is fully paid. --%>
+ <.link
+ navigate={~p"/members/#{member.id}?tab=membership_fees"}
+ id={"payment-badge-#{member.id}"}
+ class={["badge badge-soft badge-md", badge.color_class]}
+ style={count > 0 && "anchor-name: #{anchor}"}
+ phx-hook={count > 0 && "HoverPopover"}
+ data-popover-target={count > 0 && tip_id}
+ aria-details={count > 0 && tip_id}
+ aria-label={gettext("%{label} — open payment history", label: badge.label)}
+ data-testid="payment-badge"
+ >
+ <.icon name={badge.icon} class="size-4" />
+ {badge.label}
+
+ <%= if count > 0 do %>
+ <% %{unpaid: unpaid, suspended: suspended} =
+ PaymentAging.partition_open_cycles(member, @payment_period) %>
+ <% {shown, overflow} = PaymentAging.tooltip_cycles(unpaid) %>
+
+
+
+ <.badge
+ variant={MembershipFeeHelpers.status_variant(cycle.status)}
+ size="sm"
+ class="w-full justify-start"
+ >
+ <:icon>
+ <.icon name={MembershipFeeHelpers.status_icon(cycle.status)} class="size-3.5" />
+
+ {PaymentAging.short_period(cycle)}
+
+
+ 0} class="flex">
+
+ <.icon name="hero-plus-circle" class="size-3.5" />
+ {gettext("%{count} more", count: overflow)}
+
+
+
+ <%!-- Suspended cycles are shown separately under their own label so
+ the status distinction is not color-only (§1.14). --%>
+
+
{gettext("Suspended")}
+
+
+ <.badge
+ variant={MembershipFeeHelpers.status_variant(cycle.status)}
+ size="sm"
+ class="w-full justify-start"
+ >
+ <:icon>
+ <.icon
+ name={MembershipFeeHelpers.status_icon(cycle.status)}
+ class="size-3.5"
+ />
+
+ {PaymentAging.short_period(cycle)}
+
+
+
+
+
<% end %>
<:col
diff --git a/lib/mv_web/live/member_live/index/as_of_date.ex b/lib/mv_web/live/member_live/index/as_of_date.ex
new file mode 100644
index 00000000..6ec9d9a1
--- /dev/null
+++ b/lib/mv_web/live/member_live/index/as_of_date.ex
@@ -0,0 +1,37 @@
+defmodule MvWeb.MemberLive.Index.AsOfDate do
+ @moduledoc """
+ URL codec for the point-in-time "active on X" membership filter (§1.24/§2.6).
+
+ The as-of date is a single ISO-8601 date X carried in the flat URL contract
+ under the `as_of_date` 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.
+ """
+
+ @as_of_date_param Mv.Constants.as_of_date_param()
+
+ @doc """
+ Decodes the as-of 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, @as_of_date_param))
+
+ @doc """
+ Encodes an as-of 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: %{@as_of_date_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/lib/mv_web/live/member_live/index/date_filter.ex b/lib/mv_web/live/member_live/index/date_filter.ex
index b24c2f49..ca6ed704 100644
--- a/lib/mv_web/live/member_live/index/date_filter.ex
+++ b/lib/mv_web/live/member_live/index/date_filter.ex
@@ -67,6 +67,52 @@ defmodule MvWeb.MemberLive.Index.DateFilter do
}
end
+ @typedoc """
+ The three-state active/former quick filter. It is not a stored field but a
+ view onto `exit_date.mode`: `:active` ↔ `:active_only`, `:former` ↔
+ `:inactive_only`, `:all` ↔ `:all`. A detailed `:custom` exit-date selection
+ has no quick equivalent and surfaces as `:custom` (no quick chip selected).
+ """
+ @type quick_state :: :active | :former | :all | :custom
+
+ @quick_to_mode %{active: :active_only, former: :inactive_only, all: :all}
+
+ @doc """
+ Derives the active/former quick-filter state from the single `exit_date`
+ source. Total function; an absent or malformed `exit_date` reads as the
+ default `:active`. This is the only reader — there is no second state to keep
+ in sync (§2.4).
+ """
+ @spec quick_state(map()) :: quick_state()
+ def quick_state(filters) when is_map(filters) do
+ case Map.get(filters, :exit_date, %{}) do
+ %{mode: :active_only} -> :active
+ %{mode: :inactive_only} -> :former
+ %{mode: :all} -> :all
+ %{mode: :custom} -> :custom
+ _ -> :active
+ end
+ end
+
+ @doc """
+ Applies an active/former quick-filter choice onto the date filter state by
+ writing the corresponding `exit_date` mode and clearing its bounds. Only the
+ `exit_date` slice is touched; `join_date` and custom-date entries are left
+ intact, so the quick filter and the detailed control share one source.
+ """
+ @spec set_quick_state(map(), :active | :former | :all) :: %{
+ :exit_date => %{
+ from: nil,
+ mode: :active_only | :inactive_only | :all,
+ to: nil
+ },
+ optional(any()) => any()
+ }
+ def set_quick_state(filters, state)
+ when is_map(filters) and state in [:active, :former, :all] do
+ Map.put(filters, :exit_date, %{mode: Map.fetch!(@quick_to_mode, state), from: nil, to: nil})
+ end
+
@doc """
Decodes URL params into a date filter state map.
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..dd886c94
--- /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/export_payload.ex b/lib/mv_web/live/member_live/index/export_payload.ex
index 3c15c611..637da8a4 100644
--- a/lib/mv_web/live/member_live/index/export_payload.ex
+++ b/lib/mv_web/live/member_live/index/export_payload.ex
@@ -68,8 +68,12 @@ defmodule MvWeb.MemberLive.Index.ExportPayload do
query: assigns[:query] || nil,
sort_field: sort_field(assigns[:sort_field]),
sort_order: sort_order(assigns[:sort_order]),
- show_current_cycle: assigns[:show_current_cycle] || false,
- cycle_status_filter: cycle_status_filter(assigns[:cycle_status_filter]),
+ # Decoupled from the removed overview current/last cycle toggle (§3.3): the
+ # export's fee-status column always uses the current cycle as its basis and
+ # applies no cycle-status row filter (the overview now filters payment via
+ # the period-scoped aging model, which the export does not mirror).
+ show_current_cycle: true,
+ cycle_status_filter: nil,
boolean_filters: assigns[:boolean_custom_field_filters] || %{}
}
end
@@ -87,11 +91,6 @@ defmodule MvWeb.MemberLive.Index.ExportPayload do
def sort_order(:desc), do: "desc"
def sort_order(o) when is_binary(o), do: o
- defp cycle_status_filter(nil), do: nil
- defp cycle_status_filter(:paid), do: "paid"
- defp cycle_status_filter(:unpaid), do: "unpaid"
- defp cycle_status_filter(_), do: nil
-
defp build_member_fields_list(ordered_db, member_fields_visible) do
member_fields_visible = member_fields_visible || []
diff --git a/lib/mv_web/live/member_live/index/filter_descriptor.ex b/lib/mv_web/live/member_live/index/filter_descriptor.ex
new file mode 100644
index 00000000..9cbde461
--- /dev/null
+++ b/lib/mv_web/live/member_live/index/filter_descriptor.ex
@@ -0,0 +1,145 @@
+defmodule MvWeb.MemberLive.Index.FilterDescriptor do
+ @moduledoc """
+ Data-driven catalog of the filter fields offered by the member-overview
+ add-filter builder.
+
+ Each descriptor is a plain, serializable map describing one pickable field:
+
+ %{
+ key: atom() | String.t(), # stable identifier (custom fields use their UUID string)
+ group: :quick | :membership | :custom_fields,
+ label: String.t(), # human-readable, gettext-translated
+ control: atom() # which type-aware value control the builder renders
+ }
+
+ The catalog is the persistence basis the saved-views work (#549) docks onto:
+ it is derived purely from the field context (groups, fee types, custom fields)
+ and carries no UI or query state.
+
+ Grouping (confirmed taxonomy):
+
+ * `:quick` — Payment, Active/former (common shortcuts)
+ * `:membership` — Group, Fee type, Join date, Exit date
+ * `:custom_fields` — one entry per filterable club-defined custom field
+ (boolean and date fields; other value types are not filtered on the
+ overview)
+
+ A group with no descriptors is omitted entirely (no header) — see
+ `visible_groups/1`.
+ """
+
+ use Gettext, backend: MvWeb.Gettext
+
+ @type group :: :quick | :membership | :custom_fields
+
+ @type t :: %{
+ key: atom() | String.t(),
+ group: group(),
+ label: String.t(),
+ control: atom()
+ }
+
+ # Fixed display order of the groups in the picker.
+ @group_order [:quick, :membership, :custom_fields]
+
+ # Custom-field value types the overview can filter on.
+ @filterable_custom_field_types [:boolean, :date]
+
+ @doc """
+ Returns the ordered list of all descriptors available for the given field
+ context. Recognised context keys (all optional, default empty):
+
+ * `:groups` — list of `%{id: _, name: _}` group structs
+ * `:fee_types` — list of fee-type structs
+ * `:custom_fields` — list of custom-field structs (`:value_type`, `:name`, `:id`)
+ """
+ @spec all(map()) :: [t()]
+ def all(context) when is_map(context) do
+ quick() ++
+ membership(Map.get(context, :groups, []), Map.get(context, :fee_types, [])) ++
+ custom_fields(Map.get(context, :custom_fields, []))
+ end
+
+ @doc "The group an individual descriptor belongs to."
+ @spec group_for(t()) :: group()
+ def group_for(%{group: group}), do: group
+
+ @doc "The fixed display order of the picker groups."
+ @spec group_order() :: [group(), ...]
+ def group_order, do: @group_order
+
+ @doc """
+ Returns `[{group, [descriptor]}]` in display order, including only groups that
+ have at least one descriptor. Groups with no available fields are omitted so
+ the picker renders no empty header (§1.10).
+ """
+ @spec visible_groups([t()]) :: [{group(), [t()]}]
+ def visible_groups(descriptors) when is_list(descriptors) do
+ grouped = Enum.group_by(descriptors, & &1.group)
+
+ @group_order
+ |> Enum.map(fn group -> {group, Map.get(grouped, group, [])} end)
+ |> Enum.reject(fn {_group, ds} -> ds == [] end)
+ end
+
+ # --- static groups --------------------------------------------------------
+
+ defp quick do
+ [
+ %{key: :payment, group: :quick, label: gettext("Payment"), control: :payment_count},
+ %{
+ key: :active_former,
+ group: :quick,
+ label: gettext("Active / former"),
+ control: :active_former
+ }
+ ]
+ end
+
+ defp membership(groups, fee_types) do
+ maybe_group(groups) ++
+ maybe_fee_type(fee_types) ++
+ [
+ %{key: :join_date, group: :membership, label: gettext("Join date"), control: :date_range},
+ %{
+ key: :exit_date,
+ group: :membership,
+ label: gettext("Exit date"),
+ control: :active_former
+ }
+ ]
+ end
+
+ defp maybe_group([]), do: []
+
+ defp maybe_group(_groups),
+ do: [%{key: :group, group: :membership, label: gettext("Group"), control: :group_membership}]
+
+ defp maybe_fee_type([]), do: []
+
+ defp maybe_fee_type(_fee_types),
+ do: [
+ %{
+ key: :fee_type,
+ group: :membership,
+ label: gettext("Fee type"),
+ control: :fee_type_membership
+ }
+ ]
+
+ defp custom_fields(custom_fields) do
+ custom_fields
+ |> Enum.filter(&(Map.get(&1, :value_type) in @filterable_custom_field_types))
+ |> Enum.map(fn cf ->
+ %{
+ key: to_string(cf.id),
+ group: :custom_fields,
+ label: cf.name,
+ control: control_for_custom_field(cf.value_type)
+ }
+ end)
+ end
+
+ defp control_for_custom_field(:boolean), do: :boolean
+ defp control_for_custom_field(:date), do: :date_range
+end
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 ac3051f0..fa5559ca 100644
--- a/lib/mv_web/live/member_live/index/overview_query.ex
+++ b/lib/mv_web/live/member_live/index/overview_query.ex
@@ -46,17 +46,18 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
opts[:boolean_custom_fields]
)
|> apply_date_filters(opts[:date_filters])
+ |> apply_as_of_date_filter(opts[:as_of_date])
|> apply_custom_date_filters(opts[:date_filters], opts[:date_custom_fields])
- |> apply_cycle_status_filter(
- opts[:cycle_status_filter],
- opts[:show_current_cycle],
- today(opts)
+ |> apply_payment_filter(opts[:payment_filter], payment_period(opts))
+ |> apply_suspended_filter(opts[:suspended], payment_period(opts))
+ |> apply_sort(
+ opts[:sort_field],
+ opts[:sort_order],
+ opts[:custom_fields] || [],
+ payment_period(opts)
)
- |> apply_sort(opts[:sort_field], opts[:sort_order], opts[:custom_fields] || [])
end
- defp today(opts), do: opts[:today] || Date.utc_today()
-
# ---------------------------------------------------------------------------
# Search
# ---------------------------------------------------------------------------
@@ -67,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)
@@ -77,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)
# ---------------------------------------------------------------------------
@@ -184,54 +191,84 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
defp apply_one_boolean_filter(query, _uuid, _bool), do: query
# ---------------------------------------------------------------------------
- # Cycle-status filter (paid/unpaid, current or last-completed cycle)
+ # Payment aging filter (period-scoped unpaid-cycle count)
#
- # Backed by the DB cycle-status aggregates (denormalized `cycle_end`). A member
- # matches only when its selected-cycle status equals the requested status;
- # members with no matching cycle (nil aggregate) are excluded — exactly as the
- # previous in-memory classifier behaved.
+ # `fully_paid` keeps members with zero unpaid cycles whose cycle_end lies in
+ # the active period; `{:has_unpaid, n}` keeps members with at least n such
+ # cycles. Both push to SQL via the `unpaid_cycle_count` calculation (a
+ # correlated aggregate over `membership_fee_cycles`), so no member set is
+ # loaded into memory. Suspended/paid cycles are excluded by the calculation.
# ---------------------------------------------------------------------------
- defp apply_cycle_status_filter(query, status, _show_current, _today)
- when status not in [:paid, :unpaid],
- do: query
+ defp payment_period(opts) do
+ case opts[:payment_period] do
+ %{from: _, to: _} = period -> period
+ _ -> %{from: nil, to: nil}
+ end
+ end
- defp apply_cycle_status_filter(query, status, true = _show_current, today) do
- # Current cycle: contains today; when several would, the one with the latest
- # cycle_start wins. Keep members whose winning current cycle has `status`.
+ defp apply_payment_filter(query, :fully_paid, %{from: from, to: to}) do
Ash.Query.filter(
query,
- expr(
- exists(
- membership_fee_cycles,
- cycle_start <= ^today and cycle_end >= ^today and status == ^status and
- not exists(
- member.membership_fee_cycles,
- cycle_start <= ^today and cycle_end >= ^today and
- cycle_start > parent(cycle_start)
- )
- )
- )
+ expr(unpaid_cycle_count(period_from: ^from, period_to: ^to) == 0)
)
end
- defp apply_cycle_status_filter(query, status, _show_current, today) do
- # Last completed cycle: most recent cycle that has ended (cycle_end < today).
+ defp apply_payment_filter(query, {:has_unpaid, n}, %{from: from, to: to})
+ when is_integer(n) and n > 0 do
Ash.Query.filter(
query,
- expr(
- exists(
- membership_fee_cycles,
- cycle_end < ^today and status == ^status and
- not exists(
- member.membership_fee_cycles,
- cycle_end < ^today and cycle_start > parent(cycle_start)
- )
- )
- )
+ expr(unpaid_cycle_count(period_from: ^from, period_to: ^to) >= ^n)
)
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)
# ---------------------------------------------------------------------------
@@ -247,21 +284,41 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
defp apply_custom_date_filters(query, _filters, _custom_fields), do: query
+ # ---------------------------------------------------------------------------
+ # Point-in-time membership (as-of date): 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_as_of_date_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_as_of_date_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},
@@ -270,7 +327,20 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
])
end
- defp apply_sort(query, field, order, custom_fields) do
+ # Fees (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
new file mode 100644
index 00000000..4f48c326
--- /dev/null
+++ b/lib/mv_web/live/member_live/index/payment_aging.ex
@@ -0,0 +1,395 @@
+defmodule MvWeb.MemberLive.Index.PaymentAging do
+ @moduledoc """
+ Period-scoped payment aging for the member overview.
+
+ The overview presents payment as an accounts-receivable aging count: per
+ member, the number of that member's cycles with `status == :unpaid` whose
+ denormalized `cycle_end` falls inside an active period. The period is a view
+ dimension that scopes the column and the payment filter together; its default
+ is **all outstanding** (both bounds nil → all time). Suspended cycles are
+ excluded from the count but surfaced separately in the badge tooltip.
+
+ This module owns:
+
+ * the period value shape and its default,
+ * URL param encode/decode for the period,
+ * the badge descriptor derived from a count,
+ * the open-cycles list (unpaid + suspended, member-relative) that backs the
+ badge tooltip.
+
+ The count itself is computed DB-side by the `unpaid_cycle_count` calculation
+ on `Mv.Membership.Member`; `open_cycles/2` works over already-loaded cycles.
+ """
+
+ use Gettext, backend: MvWeb.Gettext
+
+ alias Mv.Constants
+ alias MvWeb.Helpers.DateFormatter
+ alias MvWeb.Helpers.MembershipFeeHelpers
+
+ @type period :: %{from: Date.t() | nil, to: Date.t() | nil}
+
+ @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).
+ """
+ @spec default_period() :: period()
+ def default_period, do: %{from: nil, to: nil}
+
+ @doc """
+ Decodes URL params into a period. Absent or malformed ISO-8601 bounds fall
+ back to nil, so no params yields the all-outstanding default.
+ """
+ @spec parse_period(map()) :: period()
+ def parse_period(params) when is_map(params) do
+ %{
+ from: parse_date(Map.get(params, @payment_period_from_param)),
+ to: parse_date(Map.get(params, @payment_period_to_param))
+ }
+ end
+
+ @doc """
+ Encodes a period into URL params. A nil bound is omitted; the default period
+ encodes to the empty map (a fresh URL is the canonical default).
+ """
+ @spec to_params(period()) :: %{optional(String.t()) => String.t()}
+ def to_params(%{from: from, to: to}) do
+ %{}
+ |> maybe_put_date(@payment_period_from_param, from)
+ |> maybe_put_date(@payment_period_to_param, to)
+ end
+
+ def to_params(_), do: %{}
+
+ @doc """
+ Decodes the payment-count filter param.
+
+ * `"fully_paid"` → `:fully_paid` (exactly 0 unpaid cycles in the period)
+ * `"unpaid_1"` / `"unpaid_2"` / `"unpaid_3"` → `{:has_unpaid, N}`
+ (at least N unpaid cycles in the period)
+ * anything else → `nil` (no payment-count filter)
+ """
+ @spec parse_filter(term()) :: nil | :fully_paid | {:has_unpaid, 1..3}
+ def parse_filter("fully_paid"), do: :fully_paid
+ def parse_filter("unpaid_1"), do: {:has_unpaid, 1}
+ def parse_filter("unpaid_2"), do: {:has_unpaid, 2}
+ def parse_filter("unpaid_3"), do: {:has_unpaid, 3}
+ def parse_filter(_), do: nil
+
+ @doc """
+ Encodes a payment-count filter into URL params. `nil` yields the empty map.
+
+ 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"}
+
+ 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
+ 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 "All paid"
+ (success/green); a positive count reads "N open" (error/red). The color class
+ is derived from the shared `MembershipFeeHelpers.status_variant/1` mapping, so
+ the overview badge reads consistently with the member show page.
+ """
+ @spec badge(non_neg_integer()) :: %{
+ color_class: String.t(),
+ icon: String.t(),
+ label: String.t(),
+ count: non_neg_integer()
+ }
+ def badge(0) do
+ %{
+ color_class: status_badge_class(:paid),
+ icon: MembershipFeeHelpers.status_icon(:paid),
+ label: gettext("All paid"),
+ count: 0
+ }
+ end
+
+ def badge(count) when is_integer(count) and count > 0 do
+ %{
+ color_class: status_badge_class(:unpaid),
+ icon: MembershipFeeHelpers.status_icon(:unpaid),
+ label: gettext("%{count} open", count: count),
+ count: count
+ }
+ end
+
+ @doc """
+ DaisyUI badge color class for a cycle status, derived from the single shared
+ `MembershipFeeHelpers.status_variant/1` mapping so the overview badges, the
+ tooltip badges and the member show page all stay in lock-step. daisyUI emits
+ all `badge-*` component colors, so the interpolated class is always bundled.
+ """
+ @spec status_badge_class(:paid | :unpaid | :suspended) :: String.t()
+ def status_badge_class(status),
+ do: "badge-#{MembershipFeeHelpers.status_variant(status)}"
+
+ @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). Explains that the shown figures refer to the
+ filtered contribution period, then names it: 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(
+ "The values refer to the filtered contribution period. Fees across all outstanding cycles"
+ )
+
+ def period_tooltip(%{from: from, to: to}) do
+ gettext(
+ "The values refer to the filtered contribution period. Fees in period %{from}–%{to}",
+ from: period_bound(from),
+ to: period_bound(to)
+ )
+ end
+
+ def period_tooltip(_),
+ do:
+ gettext(
+ "The values refer to the filtered contribution period. 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
+ chronologically by `cycle_start`. Paid cycles and cycles outside the period
+ are dropped. Expects `membership_fee_cycles` to be loaded on the member.
+ """
+ @spec open_cycles(map(), period()) :: [map()]
+ def open_cycles(member, %{from: from, to: to}) do
+ member
+ |> in_period_cycles(from, to)
+ |> Enum.filter(&(&1.status in [:unpaid, :suspended]))
+ |> Enum.sort_by(& &1.cycle_start, Date)
+ end
+
+ @doc """
+ Partitions a member's open cycles for the period into the unpaid cycles (which
+ drive the count and the badge) and the suspended cycles, which the tooltip
+ surfaces in their own labeled section rather than by color alone (§1.14).
+ Both lists stay in chronological order.
+ """
+ @spec partition_open_cycles(map(), period()) :: %{unpaid: [map()], suspended: [map()]}
+ def partition_open_cycles(member, period) do
+ {unpaid, suspended} =
+ member
+ |> open_cycles(period)
+ |> Enum.split_with(&(&1.status == :unpaid))
+
+ %{unpaid: unpaid, suspended: suspended}
+ end
+
+ @tooltip_slots 5
+
+ @doc """
+ Partitions the tooltip's unpaid sub-list into a fixed five-slot budget: at
+ most five badges for the unpaid cycles. Five or fewer unpaid cycles show in
+ full with no overflow. More than five collapse to the first four chronological
+ cycles plus a single overflow count (total − 4), so the unpaid sub-list
+ renders four cycle badges and one grey overflow badge — five items. Suspended
+ cycles are listed separately (in their own labeled section) and are not capped
+ by this budget, so the tooltip's overall badge count can exceed five.
+ """
+ @spec tooltip_cycles([map()]) :: {[map()], non_neg_integer()}
+ def tooltip_cycles(cycles) when is_list(cycles) do
+ if length(cycles) > @tooltip_slots do
+ {Enum.take(cycles, @tooltip_slots - 1), length(cycles) - (@tooltip_slots - 1)}
+ else
+ {cycles, 0}
+ end
+ end
+
+ @doc """
+ Short, human-readable period label for a cycle, derived from its `cycle_start`
+ and the fee-type interval:
+
+ * yearly → `"2025"`
+ * quarterly → `"Q1 2026"`
+ * half-yearly → `"H1 2026"`
+ * monthly → `"March 2026"`
+
+ Falls back to a `dd.MM.yyyy–dd.MM.yyyy` date range when the interval is not
+ loaded/known.
+ """
+ @spec short_period(map()) :: String.t()
+ def short_period(%{cycle_start: %Date{} = start} = cycle) do
+ case cycle_interval(cycle) do
+ :yearly -> Integer.to_string(start.year)
+ :quarterly -> "Q#{quarter(start.month)} #{start.year}"
+ :half_yearly -> "H#{half(start.month)} #{start.year}"
+ :monthly -> "#{month_name(start.month)} #{start.year}"
+ _ -> cycle_date_range(cycle)
+ end
+ end
+
+ def short_period(cycle), do: cycle_date_range(cycle)
+
+ defp in_period_cycles(member, from, to) do
+ case Map.get(member, :membership_fee_cycles) do
+ cycles when is_list(cycles) -> Enum.filter(cycles, &in_period?(&1.cycle_end, from, to))
+ _ -> []
+ end
+ end
+
+ defp in_period?(%Date{} = cycle_end, from, to) do
+ (is_nil(from) or Date.compare(cycle_end, from) != :lt) and
+ (is_nil(to) or Date.compare(cycle_end, to) != :gt)
+ end
+
+ defp in_period?(_, _, _), do: false
+
+ defp cycle_interval(%{membership_fee_type: %{interval: interval}})
+ when interval in [:monthly, :quarterly, :half_yearly, :yearly],
+ do: interval
+
+ defp cycle_interval(_), do: nil
+
+ defp cycle_date_range(%{cycle_start: %Date{} = s, cycle_end: %Date{} = e}),
+ do: "#{DateFormatter.format_date(s)}–#{DateFormatter.format_date(e)}"
+
+ defp cycle_date_range(%{cycle_start: %Date{} = s}), do: DateFormatter.format_date(s)
+ defp cycle_date_range(_), do: ""
+
+ defp quarter(month), do: div(month - 1, 3) + 1
+
+ defp half(month) when month <= 6, do: 1
+ defp half(_month), do: 2
+
+ defp month_name(1), do: gettext("January")
+ defp month_name(2), do: gettext("February")
+ defp month_name(3), do: gettext("March")
+ defp month_name(4), do: gettext("April")
+ defp month_name(5), do: gettext("May")
+ defp month_name(6), do: gettext("June")
+ defp month_name(7), do: gettext("July")
+ defp month_name(8), do: gettext("August")
+ defp month_name(9), do: gettext("September")
+ defp month_name(10), do: gettext("October")
+ defp month_name(11), do: gettext("November")
+ defp month_name(12), do: gettext("December")
+
+ defp maybe_put_date(params, _key, nil), do: params
+
+ defp maybe_put_date(params, key, %Date{} = date),
+ do: Map.put(params, key, Date.to_iso8601(date))
+
+ defp parse_date(nil), do: nil
+
+ 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/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex
index f1748591..d3979f46 100644
--- a/lib/mv_web/live/member_live/show.ex
+++ b/lib/mv_web/live/member_live/show.ex
@@ -424,7 +424,7 @@ defmodule MvWeb.MemberLive.Show do
end
@impl true
- def handle_params(%{"id" => id}, _, socket) do
+ def handle_params(%{"id" => id} = params, _, socket) do
actor = current_actor(socket)
# Load custom fields once using assign_new to avoid repeated queries
@@ -463,9 +463,17 @@ defmodule MvWeb.MemberLive.Show do
{:noreply,
socket
|> Layouts.assign_page_title(content_title)
- |> assign(:member, member)}
+ |> assign(:member, member)
+ |> maybe_assign_tab(params)}
end
+ # Deep-link support: the overview payment badge drills into the fees history
+ # via `?tab=membership_fees` (§1.15).
+ defp maybe_assign_tab(socket, %{"tab" => "membership_fees"}),
+ do: assign(socket, :active_tab, :membership_fees)
+
+ defp maybe_assign_tab(socket, _params), do: socket
+
@impl true
def handle_event("switch_tab", %{"tab" => "contact"}, socket) do
{:noreply, assign(socket, :active_tab, :contact)}
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index db07d05f..5706d57b 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -20,13 +20,6 @@ msgstr " (Datenfeld: %{field})"
msgid "%{count} failed"
msgstr "%{count} fehlgeschlagen"
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "%{count} filter active"
-msgid_plural "%{count} filters active"
-msgstr[0] "%{count} Filter aktiv"
-msgstr[1] "%{count} Filter aktiv"
-
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "%{count} member has a value assigned for this datafield."
@@ -39,16 +32,6 @@ msgstr[1] "%{count} Mitglieder haben Werte für dieses benutzerdefinierte Feld z
msgid "%{count} synced"
msgstr "%{count} synchronisiert"
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "%{field} from"
-msgstr "%{field} von"
-
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "%{field} to"
-msgstr "%{field} bis"
-
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "(ISO-8601 format: YYYY-MM-DD)"
@@ -106,11 +89,6 @@ msgstr "Aktionen"
msgid "Active members"
msgstr "Aktive Mitglieder"
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Active only"
-msgstr "Nur aktive"
-
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Add Member"
@@ -149,7 +127,7 @@ msgid "Administration"
msgstr "Administration"
#: lib/mv_web/components/core_components.ex
-#: lib/mv_web/live/components/member_filter_component.ex
+#: lib/mv_web/live/components/add_filter_builder_component.ex
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "All"
@@ -210,11 +188,6 @@ msgstr "App-URL (Link zur Kontaktansicht)"
msgid "Applicant data"
msgstr "Angaben des Antragstellers"
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Apply filters"
-msgstr "Filter auswählen"
-
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Approve"
@@ -489,11 +462,6 @@ msgstr "CSV-Datei auswählen"
msgid "City"
msgstr "Stadt"
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Clear filters"
-msgstr "Filter zurücksetzen"
-
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Click for details"
@@ -504,11 +472,6 @@ msgstr "Klicken für Details"
msgid "Click for group details"
msgstr "Klicke für Gruppen-Details"
-#: 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/role_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Click for role details"
@@ -550,11 +513,6 @@ msgstr "Client-ID"
msgid "Client Secret"
msgstr "Client-Geheimnis"
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Close"
-msgstr "Schließen"
-
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Close sidebar"
@@ -816,11 +774,6 @@ msgstr "Aktueller Zyklus"
msgid "Current amount"
msgstr "Aktueller Betrag"
-#: lib/mv_web/live/member_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "Current payment cycle"
-msgstr "Aktueller Zahlungszyklus"
-
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Custom"
@@ -831,11 +784,6 @@ msgstr "Benutzerdefiniert"
msgid "Custom Fields"
msgstr "Benutzerdefinierte Felder"
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Custom date fields"
-msgstr "Benutzerdefinierte Datumsfelder"
-
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Custom field"
@@ -915,11 +863,6 @@ msgstr "Datenfelder"
msgid "Date"
msgstr "Datum"
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Dates"
-msgstr "Daten"
-
#: lib/mv_web/live/member_live/show/deactivate_component.ex
#, elixir-autogen, elixir-format
msgid "Deactivate"
@@ -1296,22 +1239,12 @@ msgstr "Beispiele"
msgid "Exit Date"
msgstr "Austrittsdatum"
-#: lib/mv_web/live/components/member_filter_component.ex
+#: lib/mv_web/live/member_live/index/filter_descriptor.ex
#: lib/mv_web/live/member_live/show/deactivate_component.ex
#, elixir-autogen, elixir-format
msgid "Exit date"
msgstr "Austrittsdatum"
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Exit date from"
-msgstr "Austrittsdatum von"
-
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Exit date to"
-msgstr "Austrittsdatum bis"
-
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Exits"
@@ -1470,6 +1403,7 @@ msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitrag
msgstr "Beitragsstatus-Spalten (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) werden immer ignoriert und können nicht importiert werden."
#: lib/mv_web/live/import_live/components.ex
+#: lib/mv_web/live/member_live/index/filter_descriptor.ex
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Fee type"
@@ -1485,11 +1419,6 @@ msgstr "Beitragsart '%{name}' nicht gefunden; Standard-Beitragsart wird verwende
msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default."
msgstr "Beitragsart-Spalte (erkannte Spaltennamen): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unbekannte Beitragsarten erhalten die Standard-Beitragsart."
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Fee types"
-msgstr "Beitragsarten"
-
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Fee types could not be loaded."
@@ -1506,11 +1435,6 @@ msgstr "Feld"
msgid "Fields on the join form"
msgstr "Felder im Beitrittsformular"
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Filter members"
-msgstr "Mitglieder filtern"
-
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
@@ -1530,7 +1454,7 @@ msgstr "Vorname"
msgid "Fixed after creation. Members can only switch between types with the same interval."
msgstr "Festgelegt nach der Erstellung. Mitglieder können nur zwischen Beitragsarten mit gleichem Intervall wechseln."
-#: lib/mv_web/live/components/member_filter_component.ex
+#: lib/mv_web/live/components/add_filter_builder_component.ex
#, elixir-autogen, elixir-format
msgid "From"
msgstr "Von"
@@ -1657,7 +1581,6 @@ msgid "Group saved successfully."
msgstr "Gruppe erfolgreich gespeichert."
#: lib/mv_web/components/layouts/sidebar.ex
-#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/import_live/components.ex
#: lib/mv_web/live/member_live/index.html.heex
@@ -1772,11 +1695,6 @@ msgstr "Import-Status fehlt. Chunk %{idx} kann nicht verarbeitet werden."
msgid "Inactive members"
msgstr "Inaktive Mitglieder"
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Inactive only"
-msgstr "Nur ehemalige"
-
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Include both letters and numbers"
@@ -1812,7 +1730,6 @@ msgstr "Falsche E-Mail oder Passwort"
msgid "Individual Datafields"
msgstr "Individuelle Datenfelder"
-#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Individual datafields"
@@ -1905,21 +1822,11 @@ msgstr "Beitrittsformular"
msgid "Join confirmation"
msgstr "Beitrittsbestätigung"
-#: lib/mv_web/live/components/member_filter_component.ex
+#: lib/mv_web/live/member_live/index/filter_descriptor.ex
#, elixir-autogen, elixir-format
msgid "Join date"
msgstr "Beitrittsdatum"
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Join date from"
-msgstr "Beitrittsdatum von"
-
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Join date to"
-msgstr "Beitrittsdatum bis"
-
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Join form enabled"
@@ -2009,11 +1916,6 @@ msgstr "Nachname"
msgid "Last name"
msgstr "Nachname"
-#: lib/mv_web/live/member_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "Last payment cycle"
-msgstr "Letzter Zahlungszyklus"
-
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Last sync result:"
@@ -2108,6 +2010,7 @@ msgstr "Als ausgesetzt markieren"
msgid "Mark as unpaid"
msgstr "Als unbezahlt markieren"
+#: lib/mv_web/live/member_live/index/payment_aging.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "May"
@@ -2154,11 +2057,6 @@ msgstr "Mitgliedsfeld"
msgid "Member field %{action} successfully"
msgstr "Mitgliedsfeld wurde erfolgreich %{action}"
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Member filter"
-msgstr "Mitgliedsfilter"
-
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Member is not in this group."
@@ -2244,7 +2142,6 @@ msgstr "Mitgliedsbeitrag"
msgid "Membership Fee Start Date"
msgstr "Startdatum Mitgliedsbeitrag"
-#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership Fee Status"
@@ -2408,7 +2305,7 @@ msgid "New amount"
msgstr "Neuer Betrag"
#: lib/mv_web/components/core_components.ex
-#: lib/mv_web/live/components/member_filter_component.ex
+#: lib/mv_web/live/components/add_filter_builder_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/index_component.ex
@@ -2430,11 +2327,6 @@ msgstr "Für dieses Mitglied existiert kein Vereinfacht-Kontakt."
msgid "No approved or rejected requests yet"
msgstr "Noch keine genehmigten oder abgelehnten Anträge"
-#: lib/mv_web/live/member_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "No cycle"
-msgstr "Kein Zyklus"
-
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "No cycles"
@@ -2694,7 +2586,6 @@ msgid "Options"
msgstr "Optionen"
#: lib/mv/membership/members_pdf.ex
-#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
@@ -2730,16 +2621,6 @@ msgstr "Beitragsdaten"
msgid "Payment Interval"
msgstr "Zahlungsintervall"
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Payment Status"
-msgstr "Bezahlstatus"
-
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Payments"
-msgstr "Zahlungen"
-
#: lib/mv_web/live/join_request_live/helpers.ex
#, elixir-autogen, elixir-format
msgid "Pending confirmation"
@@ -2838,11 +2719,6 @@ msgstr "Vierteljährlich"
msgid "Quarterly Interval - Joining Cycle Excluded"
msgstr "Vierteljährliches Intervall – Beitrittszeitraum nicht einbezogen"
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Range"
-msgstr "Zeitraum"
-
#: lib/mv_web/live/member_live/show/deactivate_component.ex
#, elixir-autogen, elixir-format
msgid "Reactivate member"
@@ -3262,11 +3138,6 @@ msgstr "Server nicht erreichbar. Host und Port prüfen."
msgid "Set Password"
msgstr "Passwort setzen"
-#: lib/mv_web/live/member_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle."
-msgstr "Legt fest, ob Bezahlstatusfilter und Mitgliedsbeitragsstatus-Spalte den letzten abgeschlossenen oder den aktuellen Zahlungszyklus verwenden."
-
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Settings saved successfully."
@@ -3388,6 +3259,7 @@ msgid "Summary"
msgstr "Zusammenfassung"
#: lib/mv/membership/members_pdf.ex
+#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
@@ -3596,7 +3468,7 @@ msgstr "Diese*r Benutzer*in ist über SSO (Single Sign-On) verbunden. Ein hier f
msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
msgstr "Tipp: E-Mail-Adressen ins BCC-Feld einfügen, für Datenschutzkonformität"
-#: lib/mv_web/live/components/member_filter_component.ex
+#: lib/mv_web/live/components/add_filter_builder_component.ex
#, elixir-autogen, elixir-format
msgid "To"
msgstr "Bis"
@@ -3696,7 +3568,6 @@ msgid "Unlinking scheduled"
msgstr "Aufhebung der Verknüpfung geplant"
#: lib/mv/membership/members_pdf.ex
-#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
@@ -3902,7 +3773,7 @@ msgid "Yearly Interval - Joining Cycle Included"
msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen"
#: lib/mv_web/components/core_components.ex
-#: lib/mv_web/live/components/member_filter_component.ex
+#: lib/mv_web/live/components/add_filter_builder_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/index_component.ex
@@ -4075,6 +3946,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"
@@ -4125,11 +3997,6 @@ msgstr "aktualisiert"
msgid "updated"
msgstr "aktualisiert"
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "without %{name}"
-msgstr "ohne %{name}"
-
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgctxt "action"
@@ -4213,3 +4080,380 @@ msgstr "Klicke, um nach Nachname zu sortieren"
#, elixir-autogen, elixir-format
msgid "No address"
msgstr "Keine Adresse"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#: lib/mv_web/live/member_live/index/filter_descriptor.ex
+#, elixir-autogen, elixir-format
+msgid "Active / former"
+msgstr "Aktiv / ehemalig"
+
+#: lib/mv_web/live/member_live/index/filter_descriptor.ex
+#, elixir-autogen, elixir-format
+msgid "Group"
+msgstr "Gruppe"
+
+#: lib/mv_web/live/member_live/index/filter_descriptor.ex
+#, elixir-autogen, elixir-format
+msgid "Payment"
+msgstr "Zahlung"
+
+#: lib/mv_web/live/member_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "%{label} — open payment history"
+msgstr "%{label} — Zahlungsverlauf öffnen"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Active"
+msgstr "Aktiv"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Add filter"
+msgstr "Filter hinzufügen"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "All (incl. former)"
+msgstr "Alle (inkl. ehemalige)"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Available filters"
+msgstr "Verfügbare Filter"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Clear all"
+msgstr "Alle löschen"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Custom fields"
+msgstr "Eigene Felder"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Exited"
+msgstr "Ausgetreten"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Fee type: %{name}"
+msgstr "Beitragsart: %{name}"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Fee type: not %{name}"
+msgstr "Beitragsart: nicht %{name}"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Filter by …"
+msgstr "Filtern nach …"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Former"
+msgstr "Ehemalig"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Group: %{name}"
+msgstr "Gruppe: %{name}"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Group: not %{name}"
+msgstr "Gruppe: nicht %{name}"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Joined"
+msgstr "Beigetreten"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Last 30 days"
+msgstr "Letzte 30 Tage"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Membership"
+msgstr "Mitgliedschaft"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Payment status"
+msgstr "Zahlungsstatus"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Quick"
+msgstr "Schnellfilter"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Remove filter: %{label}"
+msgstr "Filter entfernen: %{label}"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "This month"
+msgstr "Dieser Monat"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "This year"
+msgstr "Dieses Jahr"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "≥ %{n} unpaid"
+msgstr "≥ %{n} offen"
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "%{count} open"
+msgstr "%{count} offen"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "All paid"
+msgstr "Alle bezahlt"
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "April"
+msgstr "April"
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "August"
+msgstr "August"
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "December"
+msgstr "Dezember"
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "February"
+msgstr "Februar"
+
+#: lib/mv_web/live/member_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "Fees"
+msgstr "Beiträge"
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "January"
+msgstr "Januar"
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "July"
+msgstr "Juli"
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "June"
+msgstr "Juni"
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "March"
+msgstr "März"
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "November"
+msgstr "November"
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "October"
+msgstr "Oktober"
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "September"
+msgstr "September"
+
+#: 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/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Last 12 months"
+msgstr "Letzte 12 Monate"
+
+#: 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/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Last 7 days"
+msgstr "Letzte 7 Tage"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "This quarter"
+msgstr "Dieses Quartal"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "%{min}–%{max} unpaid"
+msgstr "%{min}–%{max} offen"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "%{n} unpaid"
+msgstr "%{n} offen"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Active on %{date}"
+msgstr "Aktiv am %{date}"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Active on reference date"
+msgstr "Aktiv am Stichtag"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Filter: %{field}"
+msgstr "Filter: %{field}"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Open cycles"
+msgstr "Offene Zyklen"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Suspended fees"
+msgstr "Ausgesetzte Beiträge"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "max"
+msgstr "max"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "min"
+msgstr "min"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Custom range"
+msgstr "Eigener Zeitraum"
+
+#: lib/mv_web/live/member_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "%{count} more"
+msgstr "%{count} weitere"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Open contributions"
+msgstr "offene Beiträge"
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Contribution period"
+msgstr "Beitragszeitraum"
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "The values refer to the filtered contribution period. Fees across all outstanding cycles"
+msgstr "Die Angaben beziehen sich auf den gefilterten Beitragszeitraum. Beiträge über alle offenen Zyklen"
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "The values refer to the filtered contribution period. Fees in period %{from}–%{to}"
+msgstr "Die Angaben beziehen sich auf den gefilterten Beitragszeitraum. Beiträge im Zeitraum %{from}–%{to}"
+
+#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Active only"
+#~ msgstr "Nur aktive"
+
+#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Apply"
+#~ msgstr "Übernehmen"
+
+#~ #: 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}"
+
+#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Former only"
+#~ msgstr "Nur ehemalige"
+
+#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Fully paid"
+#~ msgstr "Vollständig bezahlt"
+
+#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Has unpaid"
+#~ msgstr "Hat offene"
+
+#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Has unpaid ≥ 1"
+#~ msgstr "Offen ≥ 1"
+
+#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Has unpaid ≥ 3"
+#~ msgstr "Offen ≥ 3"
+
+#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Period"
+#~ msgstr "Zeitraum"
+
+#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Period from"
+#~ msgstr "Zeitraum von"
+
+#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Period to"
+#~ msgstr "Zeitraum bis"
+
+#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Range"
+#~ msgstr "Zeitraum"
+
+#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "is"
+#~ msgstr "ist"
+
+#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "is not"
+#~ msgstr "ist nicht"
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index c3b21727..309f5ae4 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -21,13 +21,6 @@ msgstr ""
msgid "%{count} failed"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "%{count} filter active"
-msgid_plural "%{count} filters active"
-msgstr[0] ""
-msgstr[1] ""
-
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "%{count} member has a value assigned for this datafield."
@@ -40,16 +33,6 @@ msgstr[1] ""
msgid "%{count} synced"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "%{field} from"
-msgstr ""
-
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "%{field} to"
-msgstr ""
-
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "(ISO-8601 format: YYYY-MM-DD)"
@@ -107,11 +90,6 @@ msgstr ""
msgid "Active members"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Active only"
-msgstr ""
-
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Add Member"
@@ -150,7 +128,7 @@ msgid "Administration"
msgstr ""
#: lib/mv_web/components/core_components.ex
-#: lib/mv_web/live/components/member_filter_component.ex
+#: lib/mv_web/live/components/add_filter_builder_component.ex
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "All"
@@ -211,11 +189,6 @@ msgstr ""
msgid "Applicant data"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Apply filters"
-msgstr ""
-
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Approve"
@@ -490,11 +463,6 @@ msgstr ""
msgid "City"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Clear filters"
-msgstr ""
-
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Click for details"
@@ -505,11 +473,6 @@ msgstr ""
msgid "Click for group details"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "Click for member details"
-msgstr ""
-
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Click for role details"
@@ -551,11 +514,6 @@ msgstr ""
msgid "Client Secret"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Close"
-msgstr ""
-
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Close sidebar"
@@ -817,11 +775,6 @@ msgstr ""
msgid "Current amount"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "Current payment cycle"
-msgstr ""
-
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Custom"
@@ -832,11 +785,6 @@ msgstr ""
msgid "Custom Fields"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Custom date fields"
-msgstr ""
-
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Custom field"
@@ -916,11 +864,6 @@ msgstr ""
msgid "Date"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Dates"
-msgstr ""
-
#: lib/mv_web/live/member_live/show/deactivate_component.ex
#, elixir-autogen, elixir-format
msgid "Deactivate"
@@ -1297,22 +1240,12 @@ msgstr ""
msgid "Exit Date"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
+#: lib/mv_web/live/member_live/index/filter_descriptor.ex
#: lib/mv_web/live/member_live/show/deactivate_component.ex
#, elixir-autogen, elixir-format
msgid "Exit date"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Exit date from"
-msgstr ""
-
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Exit date to"
-msgstr ""
-
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Exits"
@@ -1471,6 +1404,7 @@ msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitrag
msgstr ""
#: lib/mv_web/live/import_live/components.ex
+#: lib/mv_web/live/member_live/index/filter_descriptor.ex
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Fee type"
@@ -1486,11 +1420,6 @@ msgstr ""
msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default."
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Fee types"
-msgstr ""
-
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Fee types could not be loaded."
@@ -1507,11 +1436,6 @@ msgstr ""
msgid "Fields on the join form"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Filter members"
-msgstr ""
-
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
@@ -1531,7 +1455,7 @@ msgstr ""
msgid "Fixed after creation. Members can only switch between types with the same interval."
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
+#: lib/mv_web/live/components/add_filter_builder_component.ex
#, elixir-autogen, elixir-format
msgid "From"
msgstr ""
@@ -1658,7 +1582,6 @@ msgid "Group saved successfully."
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
-#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/import_live/components.ex
#: lib/mv_web/live/member_live/index.html.heex
@@ -1773,11 +1696,6 @@ msgstr ""
msgid "Inactive members"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Inactive only"
-msgstr ""
-
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Include both letters and numbers"
@@ -1813,7 +1731,6 @@ msgstr ""
msgid "Individual Datafields"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Individual datafields"
@@ -1906,21 +1823,11 @@ msgstr ""
msgid "Join confirmation"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
+#: lib/mv_web/live/member_live/index/filter_descriptor.ex
#, elixir-autogen, elixir-format
msgid "Join date"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Join date from"
-msgstr ""
-
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Join date to"
-msgstr ""
-
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Join form enabled"
@@ -2010,11 +1917,6 @@ msgstr ""
msgid "Last name"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "Last payment cycle"
-msgstr ""
-
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Last sync result:"
@@ -2109,6 +2011,7 @@ msgstr ""
msgid "Mark as unpaid"
msgstr ""
+#: lib/mv_web/live/member_live/index/payment_aging.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "May"
@@ -2155,11 +2058,6 @@ msgstr ""
msgid "Member field %{action} successfully"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Member filter"
-msgstr ""
-
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Member is not in this group."
@@ -2245,7 +2143,6 @@ msgstr ""
msgid "Membership Fee Start Date"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format
msgid "Membership Fee Status"
@@ -2409,7 +2306,7 @@ msgid "New amount"
msgstr ""
#: lib/mv_web/components/core_components.ex
-#: lib/mv_web/live/components/member_filter_component.ex
+#: lib/mv_web/live/components/add_filter_builder_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/index_component.ex
@@ -2431,11 +2328,6 @@ msgstr ""
msgid "No approved or rejected requests yet"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "No cycle"
-msgstr ""
-
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "No cycles"
@@ -2695,7 +2587,6 @@ msgid "Options"
msgstr ""
#: lib/mv/membership/members_pdf.ex
-#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
@@ -2731,16 +2622,6 @@ msgstr ""
msgid "Payment Interval"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Payment Status"
-msgstr ""
-
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Payments"
-msgstr ""
-
#: lib/mv_web/live/join_request_live/helpers.ex
#, elixir-autogen, elixir-format
msgid "Pending confirmation"
@@ -2839,11 +2720,6 @@ msgstr ""
msgid "Quarterly Interval - Joining Cycle Excluded"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Range"
-msgstr ""
-
#: lib/mv_web/live/member_live/show/deactivate_component.ex
#, elixir-autogen, elixir-format
msgid "Reactivate member"
@@ -3263,11 +3139,6 @@ msgstr ""
msgid "Set Password"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle."
-msgstr ""
-
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Settings saved successfully."
@@ -3389,6 +3260,7 @@ msgid "Summary"
msgstr ""
#: lib/mv/membership/members_pdf.ex
+#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
@@ -3597,7 +3469,7 @@ msgstr ""
msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
+#: lib/mv_web/live/components/add_filter_builder_component.ex
#, elixir-autogen, elixir-format
msgid "To"
msgstr ""
@@ -3697,7 +3569,6 @@ msgid "Unlinking scheduled"
msgstr ""
#: lib/mv/membership/members_pdf.ex
-#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
@@ -3902,7 +3773,7 @@ msgid "Yearly Interval - Joining Cycle Included"
msgstr ""
#: lib/mv_web/components/core_components.ex
-#: lib/mv_web/live/components/member_filter_component.ex
+#: lib/mv_web/live/components/add_filter_builder_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/index_component.ex
@@ -4075,6 +3946,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 ""
@@ -4125,11 +3997,6 @@ msgstr ""
msgid "updated"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "without %{name}"
-msgstr ""
-
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgctxt "action"
@@ -4213,3 +4080,305 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "No address"
msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#: lib/mv_web/live/member_live/index/filter_descriptor.ex
+#, elixir-autogen, elixir-format
+msgid "Active / former"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/filter_descriptor.ex
+#, elixir-autogen, elixir-format
+msgid "Group"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/filter_descriptor.ex
+#, elixir-autogen, elixir-format
+msgid "Payment"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "%{label} — open payment history"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Active"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Add filter"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "All (incl. former)"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Available filters"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Clear all"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Custom fields"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Exited"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Fee type: %{name}"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Fee type: not %{name}"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Filter by …"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Former"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Group: %{name}"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Group: not %{name}"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Joined"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Last 30 days"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Membership"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Payment status"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Quick"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Remove filter: %{label}"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "This month"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "This year"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "≥ %{n} unpaid"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "%{count} open"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "All paid"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "April"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "August"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "December"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "February"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "Fees"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "January"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "July"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "June"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "March"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "November"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "October"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, 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/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "%{min}–%{max} unpaid"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "%{n} unpaid"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Active on %{date}"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Active on reference date"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Filter: %{field}"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Open cycles"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Suspended fees"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "max"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "min"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Custom range"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "%{count} more"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Open contributions"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Contribution period"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "The values refer to the filtered contribution period. Fees across all outstanding cycles"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "The values refer to the filtered contribution period. Fees in period %{from}–%{to}"
+msgstr ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index fe871774..a8decc87 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -21,13 +21,6 @@ msgstr ""
msgid "%{count} failed"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "%{count} filter active"
-msgid_plural "%{count} filters active"
-msgstr[0] "%{count} filter active"
-msgstr[1] "%{count} filters active"
-
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "%{count} member has a value assigned for this datafield."
@@ -40,16 +33,6 @@ msgstr[1] ""
msgid "%{count} synced"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "%{field} from"
-msgstr ""
-
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "%{field} to"
-msgstr ""
-
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "(ISO-8601 format: YYYY-MM-DD)"
@@ -107,11 +90,6 @@ msgstr ""
msgid "Active members"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Active only"
-msgstr ""
-
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Add Member"
@@ -150,7 +128,7 @@ msgid "Administration"
msgstr ""
#: lib/mv_web/components/core_components.ex
-#: lib/mv_web/live/components/member_filter_component.ex
+#: lib/mv_web/live/components/add_filter_builder_component.ex
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "All"
@@ -211,11 +189,6 @@ msgstr ""
msgid "Applicant data"
msgstr "Applicant data"
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Apply filters"
-msgstr ""
-
#: lib/mv_web/live/join_request_live/show.ex
#, elixir-autogen, elixir-format
msgid "Approve"
@@ -490,11 +463,6 @@ msgstr ""
msgid "City"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Clear filters"
-msgstr ""
-
#: lib/mv_web/live/join_request_live/index.ex
#, elixir-autogen, elixir-format
msgid "Click for details"
@@ -505,11 +473,6 @@ msgstr "Click for details"
msgid "Click for group details"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "Click for member details"
-msgstr ""
-
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Click for role details"
@@ -551,11 +514,6 @@ msgstr ""
msgid "Client Secret"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Close"
-msgstr ""
-
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Close sidebar"
@@ -817,11 +775,6 @@ msgstr ""
msgid "Current amount"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "Current payment cycle"
-msgstr "Current payment cycle"
-
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom"
@@ -832,11 +785,6 @@ msgstr ""
msgid "Custom Fields"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Custom date fields"
-msgstr ""
-
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom field"
@@ -916,11 +864,6 @@ msgstr ""
msgid "Date"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Dates"
-msgstr ""
-
#: lib/mv_web/live/member_live/show/deactivate_component.ex
#, elixir-autogen, elixir-format
msgid "Deactivate"
@@ -1297,22 +1240,12 @@ msgstr ""
msgid "Exit Date"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
+#: lib/mv_web/live/member_live/index/filter_descriptor.ex
#: lib/mv_web/live/member_live/show/deactivate_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Exit date"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Exit date from"
-msgstr ""
-
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Exit date to"
-msgstr ""
-
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Exits"
@@ -1471,6 +1404,7 @@ msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitrag
msgstr ""
#: lib/mv_web/live/import_live/components.ex
+#: lib/mv_web/live/member_live/index/filter_descriptor.ex
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Fee type"
@@ -1486,11 +1420,6 @@ msgstr ""
msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default."
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Fee types"
-msgstr "Fee types"
-
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Fee types could not be loaded."
@@ -1507,11 +1436,6 @@ msgstr ""
msgid "Fields on the join form"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Filter members"
-msgstr ""
-
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
@@ -1531,7 +1455,7 @@ msgstr ""
msgid "Fixed after creation. Members can only switch between types with the same interval."
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
+#: lib/mv_web/live/components/add_filter_builder_component.ex
#, elixir-autogen, elixir-format
msgid "From"
msgstr ""
@@ -1658,7 +1582,6 @@ msgid "Group saved successfully."
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
-#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/import_live/components.ex
#: lib/mv_web/live/member_live/index.html.heex
@@ -1773,11 +1696,6 @@ msgstr ""
msgid "Inactive members"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Inactive only"
-msgstr ""
-
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Include both letters and numbers"
@@ -1813,7 +1731,6 @@ msgstr ""
msgid "Individual Datafields"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Individual datafields"
@@ -1906,21 +1823,11 @@ msgstr ""
msgid "Join confirmation"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
+#: lib/mv_web/live/member_live/index/filter_descriptor.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Join date"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Join date from"
-msgstr ""
-
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Join date to"
-msgstr ""
-
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Join form enabled"
@@ -2010,11 +1917,6 @@ msgstr ""
msgid "Last name"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "Last payment cycle"
-msgstr "Last payment cycle"
-
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Last sync result:"
@@ -2109,6 +2011,7 @@ msgstr ""
msgid "Mark as unpaid"
msgstr ""
+#: lib/mv_web/live/member_live/index/payment_aging.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "May"
@@ -2155,11 +2058,6 @@ msgstr ""
msgid "Member field %{action} successfully"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Member filter"
-msgstr ""
-
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Member is not in this group."
@@ -2245,7 +2143,6 @@ msgstr ""
msgid "Membership Fee Start Date"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership Fee Status"
@@ -2409,7 +2306,7 @@ msgid "New amount"
msgstr ""
#: lib/mv_web/components/core_components.ex
-#: lib/mv_web/live/components/member_filter_component.ex
+#: lib/mv_web/live/components/add_filter_builder_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/index_component.ex
@@ -2431,11 +2328,6 @@ msgstr ""
msgid "No approved or rejected requests yet"
msgstr "No approved or rejected requests yet"
-#: lib/mv_web/live/member_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "No cycle"
-msgstr ""
-
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "No cycles"
@@ -2695,7 +2587,6 @@ msgid "Options"
msgstr ""
#: lib/mv/membership/members_pdf.ex
-#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
@@ -2731,16 +2622,6 @@ msgstr ""
msgid "Payment Interval"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Payment Status"
-msgstr ""
-
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Payments"
-msgstr ""
-
#: lib/mv_web/live/join_request_live/helpers.ex
#, elixir-autogen, elixir-format
msgid "Pending confirmation"
@@ -2839,11 +2720,6 @@ msgstr ""
msgid "Quarterly Interval - Joining Cycle Excluded"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Range"
-msgstr ""
-
#: lib/mv_web/live/member_live/show/deactivate_component.ex
#, elixir-autogen, elixir-format
msgid "Reactivate member"
@@ -3263,11 +3139,6 @@ msgstr ""
msgid "Set Password"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle."
-msgstr "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle."
-
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Settings saved successfully."
@@ -3389,6 +3260,7 @@ msgid "Summary"
msgstr ""
#: lib/mv/membership/members_pdf.ex
+#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
@@ -3597,7 +3469,7 @@ msgstr ""
msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
+#: lib/mv_web/live/components/add_filter_builder_component.ex
#, elixir-autogen, elixir-format
msgid "To"
msgstr ""
@@ -3697,7 +3569,6 @@ msgid "Unlinking scheduled"
msgstr ""
#: lib/mv/membership/members_pdf.ex
-#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/index/membership_fee_status.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
@@ -3902,7 +3773,7 @@ msgid "Yearly Interval - Joining Cycle Included"
msgstr ""
#: lib/mv_web/components/core_components.ex
-#: lib/mv_web/live/components/member_filter_component.ex
+#: lib/mv_web/live/components/add_filter_builder_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/join_request_live/show.ex
#: lib/mv_web/live/member_field_live/index_component.ex
@@ -4075,6 +3946,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 ""
@@ -4125,11 +3997,6 @@ msgstr ""
msgid "updated"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "without %{name}"
-msgstr "without %{name}"
-
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgctxt "action"
@@ -4213,3 +4080,415 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "No address"
msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#: lib/mv_web/live/member_live/index/filter_descriptor.ex
+#, elixir-autogen, elixir-format
+msgid "Active / former"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/filter_descriptor.ex
+#, elixir-autogen, elixir-format
+msgid "Group"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/filter_descriptor.ex
+#, elixir-autogen, elixir-format
+msgid "Payment"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "%{label} — open payment history"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Active"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Add filter"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "All (incl. former)"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Available filters"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Clear all"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Custom fields"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Exited"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Fee type: %{name}"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Fee type: not %{name}"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Filter by …"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Former"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Group: %{name}"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Group: not %{name}"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Joined"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Last 30 days"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Membership"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Payment status"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Quick"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Remove filter: %{label}"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "This month"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "This year"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "≥ %{n} unpaid"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "%{count} open"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "All paid"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "April"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "August"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "December"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "February"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "Fees"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "January"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "July"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "June"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "March"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "November"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "October"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "September"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index.html.heex
+#, elixir-autogen, elixir-format, fuzzy
+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, fuzzy
+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/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "%{min}–%{max} unpaid"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "%{n} unpaid"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Active on %{date}"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Active on reference date"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Filter: %{field}"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Open cycles"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Suspended fees"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "max"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "min"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Custom range"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index.html.heex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "%{count} more"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format
+msgid "Open contributions"
+msgstr ""
+
+#: lib/mv_web/live/components/add_filter_builder_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Contribution period"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "The values refer to the filtered contribution period. Fees across all outstanding cycles"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index/payment_aging.ex
+#, elixir-autogen, elixir-format
+msgid "The values refer to the filtered contribution period. Fees in period %{from}–%{to}"
+msgstr ""
+
+#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
+#~ #, elixir-autogen, elixir-format, fuzzy
+#~ msgid "Active only"
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Apply"
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/member_live/index.html.heex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Click for member details"
+#~ 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 ""
+
+#~ #: lib/mv_web/live/member_live/index.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Fees · %{range}"
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Former only"
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Fully paid"
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
+#~ #, elixir-autogen, elixir-format, fuzzy
+#~ msgid "Has unpaid"
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Has unpaid ≥ 1"
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Has unpaid ≥ 3"
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Last year"
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/member_live/index.html.heex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "No open cycles for this period"
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/member_live/index.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Payment · %{range}"
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Period"
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Period from"
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Period to"
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Range"
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/member_live/index.html.heex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Suspended: %{date}"
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/member_live/index.html.heex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Unpaid until %{date}"
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "is"
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/components/add_filter_builder_component.ex
+#~ #, elixir-autogen, elixir-format, fuzzy
+#~ msgid "is not"
+#~ msgstr ""
diff --git a/priv/repo/migrations/20260706191440_add_membership_fee_cycles_status_end_index.exs b/priv/repo/migrations/20260706191440_add_membership_fee_cycles_status_end_index.exs
new file mode 100644
index 00000000..b3fb8a55
--- /dev/null
+++ b/priv/repo/migrations/20260706191440_add_membership_fee_cycles_status_end_index.exs
@@ -0,0 +1,22 @@
+defmodule Mv.Repo.Migrations.AddMembershipFeeCyclesStatusEndIndex do
+ @moduledoc """
+ Adds a composite index on membership_fee_cycles(member_id, status, cycle_end)
+ to serve the period-scoped unpaid-cycle count on the member overview.
+
+ This file was autogenerated with `mix ash_postgres.generate_migrations`.
+ """
+
+ use Ecto.Migration
+
+ def up do
+ create index(:membership_fee_cycles, [:member_id, :status, :cycle_end],
+ name: "membership_fee_cycles_member_status_end_index"
+ )
+ end
+
+ def down do
+ drop_if_exists index(:membership_fee_cycles, [:member_id, :status, :cycle_end],
+ name: "membership_fee_cycles_member_status_end_index"
+ )
+ end
+end
diff --git a/priv/resource_snapshots/repo/membership_fee_cycles/20260706191442.json b/priv/resource_snapshots/repo/membership_fee_cycles/20260706191442.json
new file mode 100644
index 00000000..5d974b60
--- /dev/null
+++ b/priv/resource_snapshots/repo/membership_fee_cycles/20260706191442.json
@@ -0,0 +1,206 @@
+{
+ "attributes": [
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"uuid_generate_v7()\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": true,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "id",
+ "type": "uuid"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "cycle_start",
+ "type": "date"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "cycle_end",
+ "type": "date"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": 2,
+ "size": null,
+ "source": "amount",
+ "type": "decimal"
+ },
+ {
+ "allow_nil?": false,
+ "default": "\"unpaid\"",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "status",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "notes",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": {
+ "deferrable": false,
+ "destination_attribute": "id",
+ "destination_attribute_default": null,
+ "destination_attribute_generated": null,
+ "index?": false,
+ "match_type": null,
+ "match_with": null,
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "name": "membership_fee_cycles_member_id_fkey",
+ "on_delete": null,
+ "on_update": null,
+ "primary_key?": true,
+ "schema": "public",
+ "table": "members"
+ },
+ "scale": null,
+ "size": null,
+ "source": "member_id",
+ "type": "uuid"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": {
+ "deferrable": false,
+ "destination_attribute": "id",
+ "destination_attribute_default": null,
+ "destination_attribute_generated": null,
+ "index?": false,
+ "match_type": null,
+ "match_with": null,
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "name": "membership_fee_cycles_membership_fee_type_id_fkey",
+ "on_delete": null,
+ "on_update": null,
+ "primary_key?": true,
+ "schema": "public",
+ "table": "membership_fee_types"
+ },
+ "scale": null,
+ "size": null,
+ "source": "membership_fee_type_id",
+ "type": "uuid"
+ }
+ ],
+ "base_filter": null,
+ "check_constraints": [],
+ "create_table_options": null,
+ "custom_indexes": [
+ {
+ "all_tenants?": false,
+ "concurrently": false,
+ "error_fields": [
+ "member_id",
+ "status",
+ "cycle_end"
+ ],
+ "fields": [
+ {
+ "type": "atom",
+ "value": "member_id"
+ },
+ {
+ "type": "atom",
+ "value": "status"
+ },
+ {
+ "type": "atom",
+ "value": "cycle_end"
+ }
+ ],
+ "include": null,
+ "message": null,
+ "name": "membership_fee_cycles_member_status_end_index",
+ "nulls_distinct": true,
+ "prefix": null,
+ "table": null,
+ "unique": false,
+ "using": null,
+ "where": null
+ }
+ ],
+ "custom_statements": [],
+ "has_create_action": true,
+ "hash": "6CDBA37AC6000D63F3014D3EED08B613B32E8D908D598248747EFA093BC5246A",
+ "identities": [
+ {
+ "all_tenants?": false,
+ "base_filter": null,
+ "index_name": "membership_fee_cycles_unique_cycle_per_member_index",
+ "keys": [
+ {
+ "type": "atom",
+ "value": "member_id"
+ },
+ {
+ "type": "atom",
+ "value": "cycle_start"
+ }
+ ],
+ "name": "unique_cycle_per_member",
+ "nils_distinct?": true,
+ "where": null
+ }
+ ],
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "repo": "Elixir.Mv.Repo",
+ "schema": null,
+ "table": "membership_fee_cycles"
+}
\ No newline at end of file
diff --git a/test/mv_web/components/member_filter_component_test.exs b/test/mv_web/components/member_filter_component_test.exs
deleted file mode 100644
index b93acccb..00000000
--- a/test/mv_web/components/member_filter_component_test.exs
+++ /dev/null
@@ -1,660 +0,0 @@
-defmodule MvWeb.Components.MemberFilterComponentTest do
- @moduledoc """
- Unit tests for the MemberFilterComponent.
-
- Tests cover:
- - Rendering Payment Filter and Boolean Custom Fields
- - Boolean filter selection and event emission
- - Button label and badge logic
- - Filtering to show only boolean custom fields
- """
- # Kept async: false. The deferrable-FK migration removed the concurrent
- # create_member deadlock, but this file additionally showed an async-isolation
- # failure under load (filtered members from a parallel test leaking in), so it
- # is not trivially async-safe; resolving that is a separate follow-up.
- use MvWeb.ConnCase, async: false
-
- use Gettext, backend: MvWeb.Gettext
-
- import Phoenix.LiveViewTest
-
- alias Mv.Membership.CustomField
-
- # Helper to create a boolean custom field (uses system_actor - only admin can create)
- defp create_boolean_custom_field(attrs \\ %{}) do
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
-
- default_attrs = %{
- name: "test_boolean_#{System.unique_integer([:positive])}",
- value_type: :boolean
- }
-
- attrs = Map.merge(default_attrs, attrs)
-
- CustomField
- |> Ash.Changeset.for_create(:create, attrs)
- |> Ash.create!(actor: system_actor)
- end
-
- # Helper to create a non-boolean custom field (uses system_actor - only admin can create)
- defp create_string_custom_field(attrs \\ %{}) do
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
-
- default_attrs = %{
- name: "test_string_#{System.unique_integer([:positive])}",
- value_type: :string
- }
-
- attrs = Map.merge(default_attrs, attrs)
-
- CustomField
- |> Ash.Changeset.for_create(:create, attrs)
- |> Ash.create!(actor: system_actor)
- end
-
- describe "rendering" do
- test "trigger carries a trailing chevron affordance", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, _view, html} = live(conn, "/members")
-
- # Mirror the shared dropdown affordance: a trailing chevron inside the
- # bespoke filter trigger button.
- chevron =
- html
- |> LazyHTML.from_fragment()
- |> LazyHTML.query(~s(#member-filter button[aria-haspopup="true"] .hero-chevron-down))
-
- assert Enum.count(chevron) == 1
- end
-
- test "renders boolean custom fields when present", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- boolean_field = create_boolean_custom_field(%{name: "Active Member"})
-
- {:ok, view, _html} = live(conn, "/members")
-
- # Should show the boolean custom field name in the dropdown
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- html = render(view)
- assert html =~ boolean_field.name
- end
-
- test "renders payment and custom fields groups when boolean fields exist", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- _boolean_field = create_boolean_custom_field()
-
- {:ok, view, _html} = live(conn, "/members")
-
- # Open dropdown
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- html = render(view)
- # Should have both "Payments" and "Custom Fields" group labels
- assert html =~ gettext("Payments") || html =~ "Payment"
- assert html =~ gettext("Individual datafields")
- end
-
- test "renders only payment filter when no boolean custom fields exist", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- # Create a non-boolean field to ensure it's not shown
- _string_field = create_string_custom_field()
-
- {:ok, view, _html} = live(conn, "/members")
-
- # Component should exist with correct ID
- assert has_element?(view, "#member-filter")
-
- # Open dropdown
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- html = render(view)
-
- # Should show payment filter options (check both English and translated)
- assert html =~ "All" || html =~ gettext("All")
- assert html =~ "Paid" || html =~ gettext("Paid")
- assert html =~ "Unpaid" || html =~ gettext("Unpaid")
-
- # Should not show any boolean field names (since none exist)
- # We can't easily check this without knowing field names, but the structure should be correct
- end
- end
-
- describe "boolean filter selection" do
- test "selecting boolean filter sends correct event", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- boolean_field = create_boolean_custom_field(%{name: "Newsletter"})
-
- {:ok, view, _html} = live(conn, "/members")
-
- # Open dropdown
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- # Select "True" option for the boolean field using radio input
- # Radio inputs trigger phx-change on the form, so we use render_change on the form
- view
- |> form("#member-filter form", %{
- "custom_boolean" => %{to_string(boolean_field.id) => "true"}
- })
- |> render_change()
-
- # The event should be sent to the parent LiveView
- # We verify this by checking that the URL is updated
- assert_patch(view)
- end
-
- test "payment filter still works after component extension", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- _boolean_field = create_boolean_custom_field()
-
- {:ok, view, _html} = live(conn, "/members")
-
- # Open dropdown
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- # Select "Paid" option using radio input
- # Radio inputs trigger phx-change on the form, so we use render_change on the form
- view
- |> form("#member-filter form", %{"payment_filter" => "paid"})
- |> render_change()
-
- # URL should be updated with cycle_status_filter=paid
- path = assert_patch(view)
- assert path =~ "cycle_status_filter=paid"
- end
- end
-
- describe "button label" do
- test "shows active boolean filter names in button label", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- boolean_field1 = create_boolean_custom_field(%{name: "Active Member"})
- boolean_field2 = create_boolean_custom_field(%{name: "Newsletter"})
-
- # Set filters via URL
- {:ok, view, _html} =
- live(
- conn,
- "/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
- )
-
- # Component should exist
- assert has_element?(view, "#member-filter")
-
- # Button label should contain the custom field names
- # The exact format depends on implementation, but should show active filters
- button_html =
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render()
-
- assert button_html =~ boolean_field1.name || button_html =~ boolean_field2.name
- end
-
- test "truncates long custom field names in button label", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- # Create field with very long name (>30 characters)
- long_name = String.duplicate("A", 50)
- boolean_field = create_boolean_custom_field(%{name: long_name})
-
- # Set filter via URL
- {:ok, view, _html} =
- live(conn, "/members?bf_#{boolean_field.id}=true")
-
- # Component should exist
- assert has_element?(view, "#member-filter")
-
- # Get button label text
- button_html =
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render()
-
- # Button label should be truncated - full name should NOT appear in button
- # (it may appear in dropdown when opened, but not in the button label itself)
- # Check that button doesn't contain the full 50-character name
- refute button_html =~ long_name
-
- # Button should still contain some text (truncated version or indicator)
- assert String.length(button_html) > 0
- end
-
- test "date-only activation (ed_mode=all) replaces the idle label", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?ed_mode=all")
-
- button_html =
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render()
-
- # The idle label must not appear; some non-idle label is shown. This is
- # the same observable contract as the other filter categories — the
- # button visually communicates "a filter is active". The `btn-active`
- # CSS class is set by the parent class= attribute but the `<.button>`
- # core component currently composes its own class string and drops the
- # caller-supplied one — that is a pre-existing component constraint, not
- # specific to date filters.
- refute button_html =~ gettext("Apply filters")
- end
-
- test "date-only activation (jd_from) replaces the idle label", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?jd_from=2024-01-15")
-
- button_html =
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render()
-
- refute button_html =~ gettext("Apply filters")
- end
-
- test "date filter combined with one other filter shows '2 filters active'", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- boolean_field = create_boolean_custom_field(%{name: "Newsletter"})
-
- {:ok, view, _html} =
- live(conn, "/members?ed_mode=all&bf_#{boolean_field.id}=true")
-
- button_html =
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render()
-
- # With two distinct filter categories active, the label switches to the
- # pluralized "N filters active" form. Without counting date filters as
- # a category, this would show only "1 filter active" or the boolean
- # field name.
- assert button_html =~ "2"
- assert button_html =~ gettext("filters active")
- end
- end
-
- describe "badge" do
- test "shows total count of active boolean filters in badge", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- boolean_field1 = create_boolean_custom_field(%{name: "Field1"})
- boolean_field2 = create_boolean_custom_field(%{name: "Field2"})
-
- # Set two filters via URL
- {:ok, view, _html} =
- live(
- conn,
- "/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
- )
-
- # Component should exist
- assert has_element?(view, "#member-filter")
-
- # Badge should be visible when boolean filters are active
- assert has_element?(view, "#member-filter .badge")
-
- # Badge should show count of active boolean filters (2 in this case)
- badge_html =
- view
- |> element("#member-filter .badge")
- |> render()
-
- assert badge_html =~ "2"
- end
- end
-
- describe "filtering" do
- test "only boolean custom fields are displayed, not string or integer fields", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- boolean_field = create_boolean_custom_field(%{name: "Boolean Field"})
- _string_field = create_string_custom_field(%{name: "String Field"})
-
- {:ok, view, _html} = live(conn, "/members")
-
- # Open dropdown
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- # Should show boolean field in the dropdown panel
- # Extract only the dropdown panel HTML to check
- dropdown_html =
- view
- |> element("#member-filter div[role='dialog']")
- |> render()
-
- # Should show boolean field in dropdown
- assert dropdown_html =~ boolean_field.name
-
- # Should not show string field name in the filter dropdown
- # (String fields should not appear in boolean filter section)
- refute dropdown_html =~ "String Field"
- end
-
- test "renders the Dates section with exit_date and join_date controls", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
-
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- dropdown_html =
- view
- |> element("#member-filter div[role='dialog']")
- |> render()
-
- assert dropdown_html =~ gettext("Dates")
- assert dropdown_html =~ gettext("Join date")
- assert dropdown_html =~ gettext("Exit date")
- # Exit-date segmented control modes.
- assert dropdown_html =~ gettext("Active only")
- assert dropdown_html =~ gettext("Inactive only")
- # Built-in date inputs (always present for join_date and the ed_mode selector).
- assert dropdown_html =~ ~s(name="jd_from")
- assert dropdown_html =~ ~s(name="jd_to")
- assert dropdown_html =~ ~s(name="ed_mode")
- end
-
- test "exit_date custom mode reveals ed_from and ed_to inputs", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?ed_mode=custom")
-
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- dropdown_html =
- view
- |> element("#member-filter div[role='dialog']")
- |> render()
-
- assert dropdown_html =~ ~s(name="ed_from")
- assert dropdown_html =~ ~s(name="ed_to")
- end
-
- test "date inputs render via MvWeb.CoreComponents.input (no raw DaisyUI input markup)",
- %{conn: conn} do
- # DESIGN_GUIDELINES §1.1 mandates that LiveViews/HEEX use the project's
- # `<.input>` wrapper rather than emitting raw `
` tags carrying
- # DaisyUI component classes (e.g. `input input-sm input-bordered`)
- # directly in HEEX. `<.input>` is the project's single source of truth
- # for input styling; bypassing it splits styling across many call sites.
- #
- # The recognizable structural fingerprint of `<.input>` is a wrapping
- # `
` `` chain immediately
- # preceding the ` `. The raw inline form has no such wrapper —
- # the input sits directly inside a sibling ``/` ` flex row.
- # We assert that fingerprint on each of the date inputs.
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?ed_mode=custom")
-
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- dropdown_html =
- view
- |> element("#member-filter div[role='dialog']")
- |> render()
-
- for name <- ["jd_from", "jd_to", "ed_from", "ed_to"] do
- # Match `` followed (within a short
- # window of HTML) by an ` ` carrying the expected `name`. The
- # window prevents the regex from spanning unrelated `mb-2` /
- # `fieldset` occurrences scattered across the dropdown. The wrapper
- # is the canonical fingerprint of `MvWeb.CoreComponents.input/1`
- # (see `lib/mv_web/components/core_components.ex` — every input
- # branch starts with ``).
- assert Regex.match?(
- ~r/]*class="mb-2 fieldset"[^>]*>\s*]*>(?:\s*]*>.*?<\/span>)?\s* ]*name="#{name}"/s,
- dropdown_html
- ),
- "expected date input #{name} to be wrapped by MvWeb.CoreComponents.input " <>
- "(class=\"mb-2 fieldset\" fieldset wrapper), not a raw inline " <>
- " element"
- end
- end
-
- test "exit_date defaults to :active_only in the rendered radio", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
-
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- dropdown_html =
- view
- |> element("#member-filter div[role='dialog']")
- |> render()
-
- assert dropdown_html =~
- ~r/name="ed_mode"[^>]*value="active_only"[^>]*checked|checked[^>]*name="ed_mode"[^>]*value="active_only"/
- end
-
- test "Custom date fields section is non-scrollable with 5 or fewer fields (§3.4)", %{
- conn: conn
- } do
- conn = conn_with_oidc_user(conn)
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
-
- for i <- 1..5 do
- CustomField
- |> Ash.Changeset.for_create(:create, %{
- name: "DateField-#{i}-#{System.unique_integer([:positive])}",
- value_type: :date
- })
- |> Ash.create!(actor: system_actor)
- end
-
- {:ok, view, _html} = live(conn, "/members")
-
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- section_html = custom_date_section_html(view)
-
- # With ≤ 5 fields the section must NOT carry the scrollable wrapper.
- refute section_html =~ "max-h-60"
- refute section_html =~ "overflow-y-auto"
- end
-
- test "Custom date fields section becomes scrollable with more than 5 fields (§3.4)", %{
- conn: conn
- } do
- conn = conn_with_oidc_user(conn)
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
-
- for i <- 1..6 do
- CustomField
- |> Ash.Changeset.for_create(:create, %{
- name: "DateField-#{i}-#{System.unique_integer([:positive])}",
- value_type: :date
- })
- |> Ash.create!(actor: system_actor)
- end
-
- {:ok, view, _html} = live(conn, "/members")
-
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- section_html = custom_date_section_html(view)
-
- # With more than 5 fields the section is wrapped in the scrollable container.
- assert section_html =~ "max-h-60"
- assert section_html =~ "overflow-y-auto"
- end
-
- # Extract the HTML of the rendered "Custom date fields" section. Returns
- # "" if the section is not rendered. Used by the threshold tests to avoid
- # picking up scrollable classes from sibling sections.
- defp custom_date_section_html(view) do
- dropdown_html =
- view
- |> element("#member-filter div[role='dialog']")
- |> render()
-
- label = gettext("Custom date fields")
-
- case String.split(dropdown_html, label, parts: 2) do
- [_before, after_label] ->
- # Up to the next group header label, or the footer.
- after_label
- |> String.split(["text-xs font-semibold opacity-70 mb-2 uppercase"], parts: 2)
- |> List.first()
-
- _ ->
- ""
- end
- end
-
- test "Custom date fields section appears only when date custom fields exist", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view_no_field, _} = live(conn, "/members")
-
- view_no_field
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- dropdown_html =
- view_no_field
- |> element("#member-filter div[role='dialog']")
- |> render()
-
- refute dropdown_html =~ gettext("Custom date fields")
-
- # Add a date-typed custom field and re-load: the section appears.
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
-
- {:ok, field} =
- CustomField
- |> Ash.Changeset.for_create(:create, %{
- name: "Birthday-#{System.unique_integer([:positive])}",
- value_type: :date
- })
- |> Ash.create(actor: system_actor)
-
- {:ok, view, _} = live(conn, "/members")
-
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- dropdown_html =
- view
- |> element("#member-filter div[role='dialog']")
- |> render()
-
- assert dropdown_html =~ gettext("Custom date fields")
- assert dropdown_html =~ field.name
- assert dropdown_html =~ "cdf_#{field.id}_from"
- assert dropdown_html =~ "cdf_#{field.id}_to"
- end
-
- test "update_filters event dispatches a date_filters_changed patch with the new jd_from", %{
- conn: conn
- } do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
-
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- view
- |> form("#member-filter form", %{
- "jd_from" => "2024-01-15",
- "payment_filter" => "all"
- })
- |> render_change()
-
- # Parent LiveView receives {:date_filters_changed, ...} and patches the URL.
- path = assert_patch(view)
- assert path =~ "jd_from=2024-01-15"
- end
-
- test "selecting ed_mode=all updates the URL and reveals former members", %{conn: conn} do
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
- today = Date.utc_today()
- unique_name = "Zarquon-#{System.unique_integer([:positive])}"
-
- {:ok, former} =
- Mv.Membership.create_member(
- %{
- first_name: unique_name,
- last_name: "Exited",
- email: "ex-#{System.unique_integer([:positive])}@example.com",
- join_date: Date.add(today, -1000),
- exit_date: Date.add(today, -30)
- },
- actor: system_actor
- )
-
- conn = conn_with_oidc_user(conn)
- {:ok, view, html} = live(conn, "/members")
-
- # Fresh load hides the former member.
- refute html =~ former.first_name
-
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- view
- |> form("#member-filter form", %{
- "ed_mode" => "all",
- "payment_filter" => "all"
- })
- |> render_change()
-
- path = assert_patch(view)
- assert path =~ "ed_mode=all"
-
- # Now Eve appears in the rendered list.
- assert render(view) =~ former.first_name
- end
-
- test "dropdown shows scrollbar when many boolean custom fields exist", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
-
- # Create 15 boolean custom fields (more than typical, should trigger scrollbar)
- boolean_fields =
- Enum.map(1..15, fn i ->
- create_boolean_custom_field(%{name: "Field #{i}"})
- end)
-
- {:ok, view, _html} = live(conn, "/members")
-
- # Open dropdown
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- # Extract dropdown panel HTML
- dropdown_html =
- view
- |> element("#member-filter div[role='dialog']")
- |> render()
-
- # Should have scrollbar classes: max-h-60 overflow-y-auto pr-2
- # Check for the scrollable container (the div with max-h-60 and overflow-y-auto)
- assert dropdown_html =~ "max-h-60"
- assert dropdown_html =~ "overflow-y-auto"
-
- # Verify all fields are present in the dropdown
- Enum.each(boolean_fields, fn field ->
- assert dropdown_html =~ field.name
- end)
- end
- end
-end
diff --git a/test/mv_web/live/member_live/date_filter_property_test.exs b/test/mv_web/live/member_live/date_filter_property_test.exs
index 8844f911..7160262f 100644
--- a/test/mv_web/live/member_live/date_filter_property_test.exs
+++ b/test/mv_web/live/member_live/date_filter_property_test.exs
@@ -113,6 +113,74 @@ defmodule MvWeb.MemberLive.Index.DateFilterPropertyTest do
end
end
+ # §2.4 — single-source exit-date state (quick ↔ detail) ----------------
+ #
+ # The active/former quick filter is a shortcut onto the very same
+ # `exit_date` state the detailed control writes: there is no separate
+ # quick-filter field. This property drives interleaved sequences of quick
+ # and detailed changes and asserts that `quick_state/1` is always a pure
+ # function of the single `exit_date` source — i.e. the two views can never
+ # diverge.
+
+ defp quick_choice_gen, do: member_of([:active, :former, :all])
+
+ defp command_gen do
+ one_of([
+ gen(all(s <- quick_choice_gen()), do: {:quick, s}),
+ gen all(
+ mode <- exit_date_mode_gen(),
+ from <- optional_date_gen(),
+ to <- optional_date_gen()
+ ) do
+ {:detail, %{mode: mode, from: from, to: to}}
+ end
+ ])
+ end
+
+ defp apply_command(filters, {:quick, s}), do: DateFilter.set_quick_state(filters, s)
+ defp apply_command(filters, {:detail, exit_date}), do: Map.put(filters, :exit_date, exit_date)
+
+ defp expected_quick_for(:active_only), do: :active
+ defp expected_quick_for(:inactive_only), do: :former
+ defp expected_quick_for(:all), do: :all
+ defp expected_quick_for(:custom), do: :custom
+
+ property "quick filter is a single-source shortcut onto exit_date, never divergent" do
+ check all(commands <- list_of(command_gen(), max_length: 12)) do
+ final =
+ Enum.reduce(commands, DateFilter.default(), fn command, filters ->
+ next = apply_command(filters, command)
+
+ # Invariant at every step: quick_state is derived solely from the
+ # exit_date mode — one consistent value, no second source.
+ assert DateFilter.quick_state(next) == expected_quick_for(next.exit_date.mode)
+
+ # A quick command resolves to exactly the chosen state and clears bounds.
+ case command do
+ {:quick, s} ->
+ assert DateFilter.quick_state(next) == s
+ assert next.exit_date.from == nil
+ assert next.exit_date.to == nil
+
+ _ ->
+ :ok
+ end
+
+ next
+ end)
+
+ # join_date is untouched by any exit_date command (no cross-contamination).
+ assert final.join_date == DateFilter.default().join_date
+ end
+ end
+
+ property "set_quick_state ∘ quick_state round-trips for the three quick states" do
+ check all(s <- quick_choice_gen(), exit_date <- exit_date_state_gen()) do
+ filters = Map.put(DateFilter.default(), :exit_date, exit_date)
+ assert filters |> DateFilter.set_quick_state(s) |> DateFilter.quick_state() == s
+ end
+ end
+
property "encoding then decoding built-in date filter state is identity" do
check all(
join_date <- join_date_state_gen(),
diff --git a/test/mv_web/live/member_live/index/export_payload_test.exs b/test/mv_web/live/member_live/index/export_payload_test.exs
index 932816e6..b049dfff 100644
--- a/test/mv_web/live/member_live/index/export_payload_test.exs
+++ b/test/mv_web/live/member_live/index/export_payload_test.exs
@@ -56,8 +56,11 @@ defmodule MvWeb.MemberLive.Index.ExportPayloadTest do
assert payload.query == "ada"
assert payload.sort_field == "last_name"
assert payload.sort_order == "desc"
+ # Decoupled from the removed overview cycle toggle (§3.3): the export basis
+ # is always the current cycle and no cycle-status row filter is applied,
+ # regardless of any legacy cycle assigns.
assert payload.show_current_cycle == true
- assert payload.cycle_status_filter == "unpaid"
+ assert payload.cycle_status_filter == nil
assert payload.boolean_filters == %{"cf-x" => true}
end
@@ -79,7 +82,7 @@ defmodule MvWeb.MemberLive.Index.ExportPayloadTest do
assert payload.query == nil
assert payload.sort_field == nil
assert payload.sort_order == nil
- assert payload.show_current_cycle == false
+ assert payload.show_current_cycle == true
assert payload.cycle_status_filter == nil
assert payload.boolean_filters == %{}
end
diff --git a/test/mv_web/live/member_live/index/overview_query_cycle_status_test.exs b/test/mv_web/live/member_live/index/overview_query_cycle_status_test.exs
deleted file mode 100644
index 1ee235c9..00000000
--- a/test/mv_web/live/member_live/index/overview_query_cycle_status_test.exs
+++ /dev/null
@@ -1,78 +0,0 @@
-defmodule MvWeb.MemberLive.Index.OverviewQueryCycleStatusTest do
- @moduledoc """
- §1.13 — the paid/unpaid cycle-status filter, under the current or last-completed
- cycle view, returns the same members as the previous in-memory classifier,
- computed DB-side via the `cycle_end`-backed aggregates.
- """
- use Mv.DataCase, async: false
-
- import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4]
-
- alias Mv.Membership.Member
- alias MvWeb.MemberLive.Index.MembershipFeeStatus
- alias MvWeb.MemberLive.Index.OverviewQuery
-
- @today ~D[2024-07-15]
-
- setup do
- actor = Mv.Helpers.SystemActor.get_system_actor()
- ft = create_fee_type(%{interval: :yearly}, actor)
-
- # current 2024 paid, last-completed 2023 unpaid
- paid_now = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor)
- create_cycle(paid_now, ft, %{cycle_start: ~D[2024-01-01], status: :paid}, actor)
- create_cycle(paid_now, ft, %{cycle_start: ~D[2023-01-01], status: :unpaid}, actor)
-
- # current 2024 unpaid, last-completed 2023 paid
- unpaid_now = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor)
- create_cycle(unpaid_now, ft, %{cycle_start: ~D[2024-01-01], status: :unpaid}, actor)
- create_cycle(unpaid_now, ft, %{cycle_start: ~D[2023-01-01], status: :paid}, actor)
-
- # no cycles
- _no_cycles = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor)
-
- %{actor: actor, paid_now: paid_now, unpaid_now: unpaid_now}
- end
-
- defp db_ids(status, show_current, actor) do
- OverviewQuery.build(%{
- cycle_status_filter: status,
- show_current_cycle: show_current,
- today: @today
- })
- |> Ash.read!(actor: actor)
- |> MapSet.new(& &1.id)
- end
-
- defp oracle_ids(status, show_current, actor) do
- Member
- |> Ash.Query.load(membership_fee_cycles: [:membership_fee_type])
- |> Ash.Query.load(:membership_fee_type)
- |> Ash.read!(actor: actor)
- |> Enum.filter(fn m ->
- MembershipFeeStatus.get_cycle_status_for_member(m, show_current, @today) == status
- end)
- |> MapSet.new(& &1.id)
- end
-
- # {status, show_current, member-key expected in the result}
- @cases [
- {:paid, true, :paid_now},
- {:unpaid, true, :unpaid_now},
- {:paid, false, :unpaid_now},
- {:unpaid, false, :paid_now}
- ]
-
- for {status, show_current, expected_key} <- @cases do
- test "#{status} under #{if show_current, do: "current", else: "last"} cycle matches oracle",
- %{actor: actor} = ctx do
- status = unquote(status)
- show_current = unquote(show_current)
- expected = Map.fetch!(ctx, unquote(expected_key))
-
- db = db_ids(status, show_current, actor)
- assert db == oracle_ids(status, show_current, actor)
- assert expected.id in db
- end
- end
-end
diff --git a/test/mv_web/member_live/index_active_former_test.exs b/test/mv_web/member_live/index_active_former_test.exs
new file mode 100644
index 00000000..e959643d
--- /dev/null
+++ b/test/mv_web/member_live/index_active_former_test.exs
@@ -0,0 +1,156 @@
+defmodule MvWeb.MemberLive.IndexActiveFormerTest do
+ @moduledoc """
+ §1.7 — the active/former quick filter is a three-state shortcut onto the
+ shared `exit_date` state:
+
+ * "Former" → members with a past exit date (`ed_mode=inactive_only`)
+ * "Active" → members with no or a future exit date (default / `active_only`)
+ * "All" → every member (`ed_mode=all`)
+
+ These assert the *result semantics* through the live overview and its URL
+ contract, which the quick-filter UI drives; they are independent of the
+ particular chip control and survive the builder rework.
+ """
+ use MvWeb.ConnCase, async: false
+ import Phoenix.LiveViewTest
+
+ alias Mv.Fixtures
+
+ setup %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+
+ active = Fixtures.member_fixture(%{first_name: "Ava", last_name: "Active", exit_date: nil})
+
+ future =
+ Fixtures.member_fixture(%{
+ first_name: "Finn",
+ last_name: "Future",
+ exit_date: Date.add(Date.utc_today(), 30)
+ })
+
+ former =
+ Fixtures.member_fixture(%{
+ first_name: " Former",
+ last_name: "Past",
+ exit_date: Date.add(Date.utc_today(), -30)
+ })
+
+ %{conn: conn, active: active, future: future, former: former}
+ end
+
+ test "Former shows only members with a past exit date", ctx do
+ {:ok, view, _html} = live(ctx.conn, "/members?ed_mode=inactive_only")
+
+ assert has_element?(view, "#row-#{ctx.former.id}")
+ refute has_element?(view, "#row-#{ctx.active.id}")
+ refute has_element?(view, "#row-#{ctx.future.id}")
+ end
+
+ test "Active shows members with no or a future exit date", ctx do
+ {:ok, view, _html} = live(ctx.conn, "/members")
+
+ assert has_element?(view, "#row-#{ctx.active.id}")
+ assert has_element?(view, "#row-#{ctx.future.id}")
+ refute has_element?(view, "#row-#{ctx.former.id}")
+ end
+
+ test "All shows every member regardless of exit date", ctx do
+ {:ok, view, _html} = live(ctx.conn, "/members?ed_mode=all")
+
+ assert has_element?(view, "#row-#{ctx.active.id}")
+ assert has_element?(view, "#row-#{ctx.future.id}")
+ assert has_element?(view, "#row-#{ctx.former.id}")
+ end
+
+ describe "as-of-date paragraph (§1.24)" do
+ test "the active/former control carries a separate 'active on reference date' input", ctx do
+ {:ok, view, _html} = live(ctx.conn, ~p"/members")
+
+ pick_field(view, "active_former")
+
+ # The as-of-date input sits in its own paragraph, not inside the
+ # Active/Former button-join.
+ assert has_element?(view, "[data-testid='as-of-date-paragraph'] input[name='as_of_date']")
+ refute has_element?(view, ".join input[name='as_of_date']")
+ end
+
+ test "setting a reference date activates the point-in-time membership filter", ctx do
+ {:ok, view, _html} = live(ctx.conn, ~p"/members")
+
+ pick_field(view, "active_former")
+
+ view
+ |> element("[data-testid='value-control-active-former']")
+ |> render_change(%{"quick" => "active", "as_of_date" => "2024-06-15"})
+
+ _ = render(view)
+ path = assert_patch(view)
+ assert path =~ "as_of_date=2024-06-15"
+ end
+ end
+
+ describe "custom exit-date range (§1.24)" do
+ test "the exit-date control carries a separate native From/To range paragraph", ctx do
+ {:ok, view, _html} = live(ctx.conn, ~p"/members")
+
+ pick_field(view, "active_former")
+
+ # Third paragraph, same native two-input range control the join-date
+ # builder uses — separate from the Active/Former join and the as-of-date box.
+ assert has_element?(view, "[data-testid='value-control-exit-range']")
+
+ assert has_element?(
+ view,
+ "[data-testid='value-control-exit-range'] input[type='date'][name='ed_from']"
+ )
+
+ assert has_element?(
+ view,
+ "[data-testid='value-control-exit-range'] input[type='date'][name='ed_to']"
+ )
+ end
+
+ test "typing an exit-date range commits ed_mode=custom with bounds and filters to it", ctx do
+ from = Date.add(Date.utc_today(), -60)
+ to = Date.add(Date.utc_today(), -1)
+
+ {:ok, view, _html} = live(ctx.conn, ~p"/members")
+
+ pick_field(view, "active_former")
+
+ view
+ |> element("[data-testid='value-control-exit-range']")
+ |> render_change(%{"ed_from" => Date.to_iso8601(from), "ed_to" => Date.to_iso8601(to)})
+
+ _ = render(view)
+ path = assert_patch(view)
+ assert path =~ "ed_mode=custom"
+ assert path =~ "ed_from=#{Date.to_iso8601(from)}"
+ assert path =~ "ed_to=#{Date.to_iso8601(to)}"
+
+ # Only the member whose exit date falls within [from, to] is shown.
+ assert has_element?(view, "#row-#{ctx.former.id}")
+ refute has_element?(view, "#row-#{ctx.active.id}")
+ refute has_element?(view, "#row-#{ctx.future.id}")
+ end
+
+ test "an exit-date range preserves an existing join-date filter", ctx do
+ from = Date.add(Date.utc_today(), -60)
+ to = Date.add(Date.utc_today(), -1)
+
+ {:ok, view, _html} = live(ctx.conn, ~p"/members?jd_from=2020-01-01")
+
+ pick_field(view, "active_former")
+
+ view
+ |> element("[data-testid='value-control-exit-range']")
+ |> render_change(%{"ed_from" => Date.to_iso8601(from), "ed_to" => Date.to_iso8601(to)})
+
+ _ = render(view)
+ path = assert_patch(view)
+ # The exit range must not wipe the pre-existing join-date bound.
+ assert path =~ "jd_from=2020-01-01"
+ assert path =~ "ed_mode=custom"
+ end
+ end
+end
diff --git a/test/mv_web/member_live/index_add_filter_flow_test.exs b/test/mv_web/member_live/index_add_filter_flow_test.exs
new file mode 100644
index 00000000..d52bd32c
--- /dev/null
+++ b/test/mv_web/member_live/index_add_filter_flow_test.exs
@@ -0,0 +1,115 @@
+defmodule MvWeb.MemberLive.IndexAddFilterFlowTest do
+ @moduledoc """
+ §1.1–§1.3, §1.11, §3.1 — the Polaris-style add-filter flow of the
+ `AddFilterBuilderComponent`: field pick → focused value control →
+ commit-on-select / drop-on-dismiss, with no detail "Add" button, and the
+ removal of the old vertical filter panel.
+ """
+ use MvWeb.ConnCase, async: false
+
+ import Phoenix.LiveViewTest
+
+ alias Mv.Membership.Group
+
+ setup %{conn: conn} do
+ actor = Mv.Helpers.SystemActor.get_system_actor()
+
+ {:ok, member} =
+ Mv.Membership.create_member(
+ %{first_name: "Flow", last_name: "Member", email: "flow@example.com"},
+ actor: actor
+ )
+
+ {:ok, group} =
+ Group
+ |> Ash.Changeset.for_create(:create, %{name: "Board"})
+ |> Ash.create(actor: actor)
+
+ {:ok, _mg} =
+ Mv.Membership.create_member_group(%{member_id: member.id, group_id: group.id}, actor: actor)
+
+ %{conn: conn_with_oidc_user(conn), member: member, group: group}
+ end
+
+ test "the add-filter builder replaces the old vertical filter panel (§1.11)", %{conn: conn} do
+ {:ok, view, html} = live(conn, ~p"/members")
+
+ assert has_element?(view, "[data-testid='add-filter-trigger']")
+ # No residue of the removed MemberFilterComponent.
+ refute html =~ ~s(data-testid="member-filter-form")
+ refute html =~ "member-filter-dropdown"
+ refute has_element?(view, ~s(button[aria-label="Filter members"]))
+ end
+
+ test "picking a field closes the picker and opens a focused value control, no Add button (§1.1)",
+ %{conn: conn} do
+ {:ok, view, _html} = live(conn, ~p"/members")
+
+ open_picker(view)
+ assert has_element?(view, "#member-filter-field-listbox[role='listbox']")
+
+ view |> element("#member-filter-field-opt-group") |> render_click()
+
+ # Picker closes, the group value control opens; there is no detail "Add" button.
+ refute has_element?(view, "#member-filter-field-listbox")
+ assert has_element?(view, "[data-testid='value-control-group']")
+ refute has_element?(view, "[data-testid='pending-filter'] button[type='submit']")
+ end
+
+ test "a valid value selection commits a chip and reloads (§1.2)", %{conn: conn, group: group} do
+ {:ok, view, _html} = live(conn, ~p"/members")
+
+ path = apply_group_filter(view, group.id, "in")
+
+ assert path =~ "group_#{group.id}=in"
+ assert has_element?(view, "[data-testid='filter-chip']")
+ end
+
+ test "dismissing a pending filter without a selection drops it, no chip, no reload (§1.3)",
+ %{conn: conn} do
+ {:ok, view, _html} = live(conn, ~p"/members")
+
+ pick_field(view, "group")
+ assert has_element?(view, "[data-testid='value-control-group']")
+
+ # No chip has been added and no navigation happened yet (pending, uncommitted).
+ refute has_element?(view, "[data-testid='filter-chip']")
+ refute_patched(view)
+
+ # Dismiss (Escape/click-away) drops the pending control without committing.
+ render_keydown(element(view, "#member-filter"), %{"key" => "Escape"})
+ refute has_element?(view, "[data-testid='value-control-group']")
+ refute has_element?(view, "[data-testid='filter-chip']")
+ end
+
+ describe "§3.7 join-toggles are keyboard-operable (WCAG 2.1.1 / 2.4.7)" do
+ test "the shared toggle radios are focusable (not display:none) with a visible focus ring",
+ %{conn: conn} do
+ {:ok, view, _html} = live(conn, ~p"/members")
+
+ pick_field(view, "group")
+
+ scope = "[data-testid='value-control-group']"
+
+ assert has_element?(view, "#{scope} input[type='radio']"),
+ "expected the join-toggle to render native radio inputs"
+
+ # A `hidden`/`display:none` (or negative-tabindex) input is skipped by Tab
+ # and cannot be reached with the keyboard — the WCAG 2.1.1 failure.
+ refute has_element?(view, "#{scope} input[type='radio'].hidden"),
+ "toggle radio must not be display:none-hidden (keyboard-unreachable)"
+
+ refute has_element?(view, "#{scope} input[type='radio'][tabindex='-1']"),
+ "toggle radio must stay in the natural tab order (no tabindex=-1)"
+
+ # WCAG 2.4.7: the styled label must surface a visible focus indicator when
+ # its (visually-hidden) input is keyboard-focused.
+ assert render(element(view, scope)) =~ "focus-visible",
+ "toggle label must carry a :focus-visible ring indicator"
+ end
+ end
+
+ defp refute_patched(view) do
+ assert_raise ArgumentError, fn -> assert_patch(view, 50) end
+ end
+end
diff --git a/test/mv_web/member_live/index_as_of_date_property_test.exs b/test/mv_web/member_live/index_as_of_date_property_test.exs
new file mode 100644
index 00000000..a30fef52
--- /dev/null
+++ b/test/mv_web/member_live/index_as_of_date_property_test.exs
@@ -0,0 +1,78 @@
+defmodule MvWeb.MemberLive.IndexAsOfDatePropertyTest 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 as-of-date
+ 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 "as-of-date 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 =
+ %{as_of_date: 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_chip_edit_test.exs b/test/mv_web/member_live/index_chip_edit_test.exs
new file mode 100644
index 00000000..a79dc675
--- /dev/null
+++ b/test/mv_web/member_live/index_chip_edit_test.exs
@@ -0,0 +1,91 @@
+defmodule MvWeb.MemberLive.IndexChipEditTest do
+ @moduledoc """
+ §1.18 — clicking an applied-filter chip body re-opens that filter's value
+ control pre-filled with the current value; a changed selection re-commits and
+ updates the chip; the × still removes the filter.
+ """
+ use MvWeb.ConnCase, async: false
+
+ import Phoenix.LiveViewTest
+
+ alias Mv.Membership.Group
+
+ setup %{conn: conn} do
+ actor = Mv.Helpers.SystemActor.get_system_actor()
+
+ {:ok, member} =
+ Mv.Membership.create_member(
+ %{first_name: "Edit", last_name: "Member", email: "edit@example.com"},
+ actor: actor
+ )
+
+ {:ok, group} =
+ Group |> Ash.Changeset.for_create(:create, %{name: "Board"}) |> Ash.create(actor: actor)
+
+ {:ok, _mg} =
+ Mv.Membership.create_member_group(%{member_id: member.id, group_id: group.id}, actor: actor)
+
+ %{conn: conn_with_oidc_user(conn), group: group}
+ end
+
+ test "clicking the chip body opens the value control pre-filled with the current value", %{
+ conn: conn,
+ group: group
+ } do
+ {:ok, view, _html} = live(conn, "/members?group_#{group.id}=in")
+
+ refute has_element?(view, "[data-testid='value-control-group']")
+
+ view |> element("[data-testid='filter-chip-edit']") |> render_click()
+
+ # The group control opens with this group's "is" (in) operator pre-selected.
+ assert has_element?(view, "[data-testid='value-control-group']")
+ assert has_element?(view, "input[name='group_#{group.id}'][value='in'][checked]")
+ end
+
+ test "the value control opens anchored under the edited chip, not as a new pending chip", %{
+ conn: conn,
+ group: group
+ } do
+ {:ok, view, _html} = live(conn, "/members?group_#{group.id}=in")
+
+ view |> element("[data-testid='filter-chip-edit']") |> render_click()
+
+ # §1.18: the control is a descendant of the chip's own list item (anchored in
+ # place under that chip), and no separate pending chip is spun up at the row.
+ assert has_element?(
+ view,
+ "[data-testid='filter-chip-item'] [data-testid='value-control-group']"
+ )
+
+ refute has_element?(view, "[data-testid='pending-chip']")
+ refute has_element?(view, "[data-testid='pending-filter']")
+ end
+
+ test "changing the value from the re-opened control re-commits and updates the URL", %{
+ conn: conn,
+ group: group
+ } do
+ {:ok, view, _html} = live(conn, "/members?group_#{group.id}=in")
+
+ view |> element("[data-testid='filter-chip-edit']") |> render_click()
+
+ view
+ |> element("[data-testid='value-control-group']")
+ |> render_change(%{"group_#{group.id}" => "not_in"})
+
+ _ = render(view)
+ path = assert_patch(view)
+ assert path =~ "group_#{group.id}=not_in"
+ end
+
+ test "the × still removes the filter", %{conn: conn, group: group} do
+ {:ok, view, _html} = live(conn, "/members?group_#{group.id}=in")
+
+ view |> element("[data-testid='filter-chip-remove']") |> render_click()
+
+ path = assert_patch(view)
+ refute path =~ "group_#{group.id}"
+ refute has_element?(view, "[data-testid='filter-chip']")
+ end
+end
diff --git a/test/mv_web/member_live/index_column_manager_test.exs b/test/mv_web/member_live/index_column_manager_test.exs
index 723b8f2d..7833d128 100644
--- a/test/mv_web/member_live/index_column_manager_test.exs
+++ b/test/mv_web/member_live/index_column_manager_test.exs
@@ -23,9 +23,9 @@ defmodule MvWeb.MemberLive.IndexColumnManagerTest do
test "column manager trigger sits alongside the filter controls", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
- # Both the filter panel trigger and the column-manager trigger render in the
- # same toolbar.
- assert has_element?(view, ~s(button[aria-label="Filter members"]))
+ # Both the add-filter builder trigger and the column-manager trigger render
+ # in the same toolbar.
+ assert has_element?(view, "[data-testid='add-filter-trigger']")
assert has_element?(view, "button[aria-controls='field-visibility-menu']")
end
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
new file mode 100644
index 00000000..f8470b81
--- /dev/null
+++ b/test/mv_web/member_live/index_date_presets_test.exs
@@ -0,0 +1,51 @@
+defmodule MvWeb.MemberLive.IndexDatePresetsTest do
+ @moduledoc """
+ §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
+
+ import Phoenix.LiveViewTest
+
+ setup %{conn: conn} do
+ actor = Mv.Helpers.SystemActor.get_system_actor()
+
+ {:ok, _member} =
+ Mv.Membership.create_member(
+ %{first_name: "Preset", last_name: "Member", email: "preset@example.com"},
+ actor: actor
+ )
+
+ %{conn: conn_with_oidc_user(conn)}
+ end
+
+ 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")
+
+ # Open the Join date value control and apply the "This year" preset.
+ pick_field(view, "join_date")
+ assert has_element?(view, "[data-testid='date-presets']")
+
+ view |> element("[data-testid='date-preset-this_year']") |> render_click()
+
+ path = assert_patch(view)
+ 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_date_range_test.exs b/test/mv_web/member_live/index_date_range_test.exs
new file mode 100644
index 00000000..30df18f4
--- /dev/null
+++ b/test/mv_web/member_live/index_date_range_test.exs
@@ -0,0 +1,74 @@
+defmodule MvWeb.MemberLive.IndexDateRangeTest do
+ @moduledoc """
+ §1.25 / §3.12 (native-first) — the date-range value control combines the
+ relative presets with a separate "Eigener Zeitraum" (custom range) paragraph
+ holding two native ` ` fields (Von/Bis). cally is removed.
+
+ Picking a preset commits a concrete range; typing into the native Von/Bis
+ inputs commits the custom range through the shared date-filter dispatch.
+ """
+ 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: "Range", last_name: "Member", email: "range@example.com"},
+ actor: actor
+ )
+
+ %{conn: conn_with_oidc_user(conn)}
+ end
+
+ test "the join-date control shows presets and native Von/Bis date inputs", %{conn: conn} do
+ {:ok, view, _html} = live(conn, ~p"/members")
+
+ pick_field(view, "join_date")
+
+ assert has_element?(view, "[data-testid='value-control-date-range']")
+ assert has_element?(view, "[data-testid='date-presets']")
+ # Separate "Eigener Zeitraum" paragraph with two native date inputs side by side.
+ assert has_element?(view, "[data-testid='custom-range']")
+ assert has_element?(view, "[data-testid='custom-range'] input[type='date'][name='jd_from']")
+ assert has_element?(view, "[data-testid='custom-range'] input[type='date'][name='jd_to']")
+ end
+
+ test "cally is fully removed from the date-range control", %{conn: conn} do
+ {:ok, view, _html} = live(conn, ~p"/members")
+
+ pick_field(view, "join_date")
+
+ refute has_element?(view, "[data-testid='date-range-cally']")
+ refute has_element?(view, "calendar-range")
+ refute has_element?(view, "[phx-hook='DateRangeCally']")
+ end
+
+ test "picking a preset commits a concrete range to the URL", %{conn: conn} do
+ {:ok, view, _html} = live(conn, ~p"/members")
+
+ pick_field(view, "join_date")
+ view |> element("[data-testid='date-preset-this_year']") |> render_click()
+
+ path = assert_patch(view)
+ today = Date.utc_today()
+ assert path =~ "jd_from=#{today.year}-01-01"
+ assert path =~ "jd_to=#{Date.to_iso8601(today)}"
+ end
+
+ test "typing a native custom range commits it to the URL", %{conn: conn} do
+ {:ok, view, _html} = live(conn, ~p"/members")
+
+ pick_field(view, "join_date")
+
+ view
+ |> element("[data-testid='value-control-date-range']")
+ |> render_change(%{"jd_from" => "2024-03-01", "jd_to" => "2024-03-31"})
+
+ path = assert_patch(view)
+ assert path =~ "jd_from=2024-03-01"
+ assert path =~ "jd_to=2024-03-31"
+ end
+end
diff --git a/test/mv_web/member_live/index_default_columns_test.exs b/test/mv_web/member_live/index_default_columns_test.exs
index 5f5545d6..3cac0353 100644
--- a/test/mv_web/member_live/index_default_columns_test.exs
+++ b/test/mv_web/member_live/index_default_columns_test.exs
@@ -40,7 +40,9 @@ defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do
# Fee type, fee status, groups, join date headers.
assert has_element?(view, "[data-testid='membership_fee_type']")
assert has_element?(view, "[data-testid='join_date']")
- assert has_element?(view, "th", "Membership Fee Status")
+ # The former "Membership Fee Status" column is now the period-scoped payment
+ # aging column (§3.3), headed "Fees".
+ assert has_element?(view, "th", "Fees")
assert has_element?(view, "[data-testid='groups']")
end
diff --git a/test/mv_web/member_live/index_field_picker_test.exs b/test/mv_web/member_live/index_field_picker_test.exs
new file mode 100644
index 00000000..6f208d8c
--- /dev/null
+++ b/test/mv_web/member_live/index_field_picker_test.exs
@@ -0,0 +1,82 @@
+defmodule MvWeb.MemberLive.IndexFieldPickerTest do
+ @moduledoc """
+ §1.10 / §3.5 — the FilterDescriptor catalog behind the field picker.
+
+ Fields are grouped Quick / Membership / Custom fields; a group with no
+ available fields is omitted (renders no header). The catalog is derived
+ purely from the field context and is serializable (the persistence basis for
+ saved views, #549).
+ """
+ use ExUnit.Case, async: true
+
+ alias MvWeb.MemberLive.Index.FilterDescriptor
+
+ defp group_keys(descriptors, group) do
+ descriptors
+ |> Enum.filter(&(&1.group == group))
+ |> Enum.map(& &1.key)
+ end
+
+ test "groups are ordered Quick / Membership / Custom fields" do
+ context = %{
+ groups: [%{id: Ecto.UUID.generate(), name: "Board"}],
+ fee_types: [%{id: Ecto.UUID.generate(), name: "Standard"}],
+ custom_fields: [%{id: Ecto.UUID.generate(), name: "Newsletter", value_type: :boolean}]
+ }
+
+ descriptors = FilterDescriptor.all(context)
+
+ assert Enum.map(FilterDescriptor.visible_groups(descriptors), &elem(&1, 0)) ==
+ [:quick, :membership, :custom_fields]
+ end
+
+ test "quick and membership fields are present with type-aware controls" do
+ descriptors = FilterDescriptor.all(%{groups: [%{id: Ecto.UUID.generate(), name: "G"}]})
+
+ assert group_keys(descriptors, :quick) == [:payment, :active_former]
+ assert :group in group_keys(descriptors, :membership)
+ assert :join_date in group_keys(descriptors, :membership)
+ assert :exit_date in group_keys(descriptors, :membership)
+
+ payment = Enum.find(descriptors, &(&1.key == :payment))
+ assert payment.control == :payment_count
+ end
+
+ test "empty custom-fields group is omitted (no header)" do
+ descriptors = FilterDescriptor.all(%{custom_fields: []})
+
+ groups = Enum.map(FilterDescriptor.visible_groups(descriptors), &elem(&1, 0))
+ refute :custom_fields in groups
+ end
+
+ test "membership omits Group/Fee type when the club has none" do
+ descriptors = FilterDescriptor.all(%{groups: [], fee_types: []})
+
+ membership_keys = group_keys(descriptors, :membership)
+ refute :group in membership_keys
+ refute :fee_type in membership_keys
+ # Join/exit date are always available.
+ assert :join_date in membership_keys
+ assert :exit_date in membership_keys
+ end
+
+ test "one custom-fields descriptor per filterable field; non-filterable types dropped" do
+ date_id = Ecto.UUID.generate()
+ bool_id = Ecto.UUID.generate()
+
+ context = %{
+ custom_fields: [
+ %{id: date_id, name: "Birthday", value_type: :date},
+ %{id: bool_id, name: "Consent", value_type: :boolean},
+ %{id: Ecto.UUID.generate(), name: "Phone", value_type: :string}
+ ]
+ }
+
+ custom = Enum.filter(FilterDescriptor.all(context), &(&1.group == :custom_fields))
+
+ assert Enum.map(custom, & &1.key) == [to_string(date_id), to_string(bool_id)]
+ # Keys are plain strings — serializable for #549.
+ assert Enum.all?(custom, &is_binary(&1.key))
+ assert Enum.find(custom, &(&1.key == to_string(date_id))).control == :date_range
+ end
+end
diff --git a/test/mv_web/member_live/index_filter_chips_test.exs b/test/mv_web/member_live/index_filter_chips_test.exs
new file mode 100644
index 00000000..c5be18a4
--- /dev/null
+++ b/test/mv_web/member_live/index_filter_chips_test.exs
@@ -0,0 +1,92 @@
+defmodule MvWeb.MemberLive.IndexFilterChipsTest do
+ @moduledoc """
+ §1.4, §1.5, §3.2 — applied-filter chips render one per value, each is an
+ independently removable real button, and "Clear all" removes every active
+ filter in a single reload with the URL reset to the unfiltered state.
+ """
+ use MvWeb.ConnCase, async: false
+
+ import Phoenix.LiveViewTest
+
+ alias Mv.Membership.Group
+
+ setup %{conn: conn} do
+ actor = Mv.Helpers.SystemActor.get_system_actor()
+
+ {:ok, member} =
+ Mv.Membership.create_member(
+ %{first_name: "Chip", last_name: "Member", email: "chip@example.com"},
+ actor: actor
+ )
+
+ {:ok, group1} =
+ Group |> Ash.Changeset.for_create(:create, %{name: "Alpha"}) |> Ash.create(actor: actor)
+
+ {:ok, group2} =
+ Group |> Ash.Changeset.for_create(:create, %{name: "Beta"}) |> Ash.create(actor: actor)
+
+ {:ok, _mg} =
+ Mv.Membership.create_member_group(%{member_id: member.id, group_id: group1.id},
+ actor: actor
+ )
+
+ %{conn: conn_with_oidc_user(conn), group1: group1, group2: group2}
+ end
+
+ test "an applied filter renders a removable chip that is a real button (§1.4, §3.2)", %{
+ conn: conn,
+ group1: group1
+ } do
+ {:ok, view, _html} = live(conn, "/members?group_#{group1.id}=in")
+
+ # The chip's remove control is a real button whose accessible name names the
+ # filter it removes (locale-independent: it contains the group name).
+ assert has_element?(
+ view,
+ "button[data-testid='filter-chip-remove'][aria-label*='Alpha']"
+ )
+ end
+
+ test "removing a chip drops its URL param and reloads (§1.4)", %{conn: conn, group1: group1} do
+ {:ok, view, _html} = live(conn, "/members?group_#{group1.id}=in")
+
+ view |> element("[data-testid='filter-chip-remove']") |> render_click()
+
+ path = assert_patch(view)
+ refute path =~ "group_#{group1.id}"
+ end
+
+ test "Clear all removes every active filter in one reload and resets the URL (§1.5)", %{
+ conn: conn,
+ group1: group1,
+ group2: group2
+ } do
+ {:ok, view, _html} =
+ live(conn, "/members?group_#{group1.id}=in&group_#{group2.id}=not_in")
+
+ assert has_element?(view, "[data-testid='clear-all-filters']")
+ view |> element("[data-testid='clear-all-filters']") |> render_click()
+
+ path = assert_patch(view)
+ refute path =~ "group_#{group1.id}"
+ refute path =~ "group_#{group2.id}"
+ refute has_element?(view, "[data-testid='filter-chip']")
+ end
+
+ test "each value renders its own chip (one chip per value)", %{
+ conn: conn,
+ group1: group1,
+ group2: group2
+ } do
+ {:ok, view, _html} =
+ live(conn, "/members?group_#{group1.id}=in&group_#{group2.id}=not_in")
+
+ chips =
+ render(view)
+ |> LazyHTML.from_fragment()
+ |> LazyHTML.query(~s([data-testid="filter-chip"]))
+ |> Enum.count()
+
+ assert chips == 2
+ end
+end
diff --git a/test/mv_web/member_live/index_filter_height_property_test.exs b/test/mv_web/member_live/index_filter_height_property_test.exs
new file mode 100644
index 00000000..1ba96cae
--- /dev/null
+++ b/test/mv_web/member_live/index_filter_height_property_test.exs
@@ -0,0 +1,50 @@
+defmodule MvWeb.MemberLive.IndexFilterHeightPropertyTest do
+ @moduledoc """
+ §2.3 — the resting filter UI's size depends only on the number of active
+ filters, never on the size of the available-field catalog. The builder renders
+ one chip per active filter regardless of how many groups/fee types/custom
+ fields are pickable, so the chip count is a function of the active-filter count
+ alone.
+ """
+ use ExUnit.Case, async: true
+ use ExUnitProperties
+
+ import Phoenix.LiveViewTest
+
+ alias MvWeb.Components.AddFilterBuilderComponent
+
+ defp group(i), do: %{id: "g#{i}", name: "Group #{i}"}
+
+ defp chip_count(html) do
+ html
+ |> LazyHTML.from_fragment()
+ |> LazyHTML.query(~s([data-testid="filter-chip"]))
+ |> Enum.count()
+ end
+
+ property "resting chip count depends only on active-filter count, not catalog size" do
+ check all(
+ catalog_size <- integer(0..8),
+ active_count <- integer(0..5)
+ ) do
+ # active filters must reference real catalog ids, so ensure enough groups.
+ total = max(catalog_size, active_count)
+ groups = Enum.map(1..max(total, 1)//1, &group/1)
+ groups = if total == 0, do: [], else: groups
+
+ group_filters =
+ groups
+ |> Enum.take(active_count)
+ |> Map.new(&{&1.id, :in})
+
+ html =
+ render_component(AddFilterBuilderComponent,
+ id: "member-filter",
+ groups: groups,
+ group_filters: group_filters
+ )
+
+ assert chip_count(html) == active_count
+ end
+ end
+end
diff --git a/test/mv_web/member_live/index_filter_panel_test.exs b/test/mv_web/member_live/index_filter_panel_test.exs
deleted file mode 100644
index 770c0944..00000000
--- a/test/mv_web/member_live/index_filter_panel_test.exs
+++ /dev/null
@@ -1,79 +0,0 @@
-defmodule MvWeb.MemberLive.IndexFilterPanelTest do
- @moduledoc """
- §1.18 — The existing "Apply filters" panel is unchanged: same fieldset/legend
- form structure and payment-status controls, no chip/add-filter-builder
- paradigm (that is #548). Its filters now resolve DB-side via the `:overview`
- read action.
- """
- use MvWeb.ConnCase, async: false
-
- import Phoenix.LiveViewTest
-
- alias Mv.Helpers.SystemActor
-
- setup %{conn: conn} do
- {:ok, _} =
- Mv.Membership.create_member(
- %{first_name: "Panel", last_name: "Member", email: "panel@example.com"},
- actor: SystemActor.get_system_actor()
- )
-
- %{conn: conn_with_oidc_user(conn)}
- end
-
- defp open_filter(view) do
- view
- |> element(~s(button[phx-click="toggle_dropdown"][aria-label="Filter members"]))
- |> render_click()
- end
-
- test "the filter panel keeps its fieldset form structure and payment controls", %{conn: conn} do
- {:ok, view, _html} = live(conn, ~p"/members")
- open_filter(view)
-
- # Semantic form structure (fieldset/legend, radio controls) is preserved.
- assert has_element?(view, ~s(form[data-testid="member-filter-form"]))
- assert has_element?(view, "#payment-filter-all")
- assert has_element?(view, "#payment-filter-paid")
- assert has_element?(view, "#payment-filter-unpaid")
- end
-
- test "no chip / add-filter-builder paradigm is present (#548 is out of scope)", %{conn: conn} do
- {:ok, view, html} = live(conn, ~p"/members")
- open_filter(view)
- html = html <> render(view)
-
- refute html =~ ~s(data-testid="add-filter")
- refute html =~ ~s(data-testid="filter-chip")
- refute html =~ "filter-builder"
- end
-
- test "the panel's payment filter resolves DB-side via :overview", %{conn: conn} do
- system_actor = SystemActor.get_system_actor()
- fee_type = Mv.Fixtures.create_fee_type(%{interval: :yearly}, system_actor)
- last_year_start = Date.new!(Date.utc_today().year - 1, 1, 1)
-
- {:ok, paid} =
- Mv.Membership.create_member(
- %{
- first_name: "PaidPanel",
- last_name: "X",
- email: "paidpanel@example.com",
- membership_fee_type_id: fee_type.id
- },
- actor: system_actor
- )
-
- Mv.Fixtures.create_cycle(
- paid,
- fee_type,
- %{cycle_start: last_year_start, status: :paid, replace_existing: true},
- system_actor
- )
-
- {:ok, _view, html} = live(conn, ~p"/members?cycle_status_filter=paid")
-
- assert html =~ "PaidPanel"
- refute html =~ "Panel Member"
- end
-end
diff --git a/test/mv_web/member_live/index_filter_parity_property_test.exs b/test/mv_web/member_live/index_filter_parity_property_test.exs
index 8cd17ce8..3568e1b4 100644
--- a/test/mv_web/member_live/index_filter_parity_property_test.exs
+++ b/test/mv_web/member_live/index_filter_parity_property_test.exs
@@ -8,8 +8,8 @@ defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do
The unchanged DB filters (group, fee-type) are applied identically in both
arms; the property isolates the filters that moved from memory to the DB
- (boolean custom fields, date custom fields, paid/unpaid cycle status) by
- applying them DB-side in one arm and in-memory in the other.
+ (boolean custom fields, date custom fields) plus the period-scoped payment
+ aging filter, by applying them DB-side in one arm and in-memory in the other.
"""
use Mv.DataCase, async: false
use ExUnitProperties
@@ -21,10 +21,8 @@ defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do
alias Mv.Membership.Member
alias MvWeb.MemberLive.Index
alias MvWeb.MemberLive.Index.DateFilter
- alias MvWeb.MemberLive.Index.MembershipFeeStatus
alias MvWeb.MemberLive.Index.OverviewQuery
- @today ~D[2024-07-15]
@cycle_starts [~D[2023-01-01], ~D[2024-01-01], ~D[2025-01-01]]
@date_values [~D[2023-06-01], ~D[2024-06-15], ~D[2025-06-01]]
@@ -74,8 +72,14 @@ defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do
defp filter_params_gen do
StreamData.fixed_map(%{
- cycle_status: StreamData.member_of([nil, :paid, :unpaid]),
- show_current: StreamData.boolean(),
+ payment:
+ StreamData.member_of([
+ nil,
+ :fully_paid,
+ {:has_unpaid, 1},
+ {:has_unpaid, 2},
+ {:has_unpaid, 3}
+ ]),
bool: StreamData.member_of([nil, true, false]),
date_range: StreamData.member_of([nil, {~D[2024-01-01], ~D[2024-12-31]}]),
group: StreamData.member_of([nil, :in, :not_in]),
@@ -144,16 +148,16 @@ defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do
Map.merge(opts, %{fee_type_filters: %{to_string(ctx.ft1.id) => dir}, fee_types: [ctx.ft1]})
defp moved_opts(params, ctx) do
- %{today: @today}
- |> put_cycle(params.cycle_status, params.show_current)
+ %{}
+ |> put_payment(params.payment)
|> put_bool(params.bool, ctx)
|> put_date(params.date_range, ctx)
end
- defp put_cycle(opts, nil, _show), do: opts
+ defp put_payment(opts, nil), do: opts
- defp put_cycle(opts, status, show),
- do: Map.merge(opts, %{cycle_status_filter: status, show_current_cycle: show})
+ defp put_payment(opts, filter),
+ do: Map.merge(opts, %{payment_filter: filter, payment_period: %{from: nil, to: nil}})
defp put_bool(opts, nil, _ctx), do: opts
@@ -195,7 +199,7 @@ defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do
base_members
|> apply_oracle_bool(params, ctx)
|> apply_oracle_date(params, ctx)
- |> apply_oracle_cycle(params)
+ |> apply_oracle_payment(params)
|> MapSet.new(& &1.id)
end
@@ -219,14 +223,22 @@ defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do
)
end
- defp apply_oracle_cycle(members, %{cycle_status: nil}), do: members
+ defp apply_oracle_payment(members, %{payment: nil}), do: members
- defp apply_oracle_cycle(members, %{cycle_status: status, show_current: show}) do
- Enum.filter(members, fn m ->
- MembershipFeeStatus.get_cycle_status_for_member(m, show, @today) == status
- end)
+ defp apply_oracle_payment(members, %{payment: filter}) do
+ Enum.filter(members, fn m -> payment_match?(unpaid_count(m), filter) end)
end
+ # Member-relative count of unpaid cycles (all-time period, so every unpaid
+ # cycle counts); suspended and paid are excluded, mirroring the calculation.
+ defp unpaid_count(member) do
+ member.membership_fee_cycles
+ |> Enum.count(&(&1.status == :unpaid))
+ end
+
+ defp payment_match?(count, :fully_paid), do: count == 0
+ defp payment_match?(count, {:has_unpaid, n}), do: count >= n
+
property "DB-backed filter result set equals the in-memory oracle", ctx do
check all(
specs <- StreamData.list_of(member_spec_gen(), min_length: 1, max_length: 5),
diff --git a/test/mv_web/member_live/index_filter_row_test.exs b/test/mv_web/member_live/index_filter_row_test.exs
new file mode 100644
index 00000000..a4593465
--- /dev/null
+++ b/test/mv_web/member_live/index_filter_row_test.exs
@@ -0,0 +1,48 @@
+defmodule MvWeb.MemberLive.IndexFilterRowTest do
+ @moduledoc """
+ §1.19 — the filter row renders "+ Add filter", the applied-filter chips and
+ "Clear all" as one uniform button size (all share the `btn-sm` sizing), and
+ each chip is composed of real, focusable buttons.
+ """
+ use MvWeb.ConnCase, async: false
+
+ import Phoenix.LiveViewTest
+
+ alias Mv.Membership.Group
+
+ setup %{conn: conn} do
+ actor = Mv.Helpers.SystemActor.get_system_actor()
+
+ {:ok, member} =
+ Mv.Membership.create_member(
+ %{first_name: "Row", last_name: "Member", email: "row@example.com"},
+ actor: actor
+ )
+
+ {:ok, group} =
+ Group |> Ash.Changeset.for_create(:create, %{name: "Board"}) |> Ash.create(actor: actor)
+
+ {:ok, _mg} =
+ Mv.Membership.create_member_group(%{member_id: member.id, group_id: group.id}, actor: actor)
+
+ %{conn: conn_with_oidc_user(conn), group: group}
+ end
+
+ test "add-filter trigger, chips and clear-all share the uniform btn-sm size", %{
+ conn: conn,
+ group: group
+ } do
+ {:ok, view, _html} = live(conn, "/members?group_#{group.id}=in")
+
+ assert has_element?(view, "[data-testid='add-filter-trigger'].btn-sm")
+ assert has_element?(view, "[data-testid='clear-all-filters'].btn-sm")
+ # The chip body and its remove control are real, focusable buttons at btn-sm.
+ assert has_element?(view, "button[data-testid='filter-chip-edit'].btn-sm")
+ assert has_element?(view, "button[data-testid='filter-chip-remove'].btn-sm")
+
+ # The join seam must be a single hairline: the remove button drops its own
+ # left border so the body's right border is the only one at the seam (no
+ # doubled 1px border that sub-pixel-rounds to 1px/2px and flips on zoom).
+ assert has_element?(view, "button[data-testid='filter-chip-remove'].border-l-0")
+ 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..b01a9705
--- /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 an as-of 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 "as-of date, suspended and payment count range persist through a re-patch", %{conn: conn} do
+ url = "/members?as_of_date=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 =~ "as_of_date=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_accessibility_test.exs b/test/mv_web/member_live/index_groups_accessibility_test.exs
index bfe47c43..34cea277 100644
--- a/test/mv_web/member_live/index_groups_accessibility_test.exs
+++ b/test/mv_web/member_live/index_groups_accessibility_test.exs
@@ -65,21 +65,22 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do
end
@tag :ui
- test "filter dropdown has group presence section with legend", %{
+ test "field picker exposes a combobox with a Group field option", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
- # Open filter dropdown
- view
- |> element("button[aria-label='Filter members']")
- |> render_click()
+ # Open the add-filter picker (combobox + listbox of grouped fields)
+ open_picker(view)
- html = render(view)
- # Groups section: legend "Member has groups" and radios (Any / Yes / No)
- assert html =~ ~r/[Gg]roups/
- assert has_element?(view, "[data-testid='member-filter-form']")
+ assert has_element?(view, "[data-testid='field-combobox'][role='combobox']")
+ assert has_element?(view, "#member-filter-field-listbox[role='listbox']")
+ assert has_element?(view, "#member-filter-field-opt-group[role='option']")
+
+ # Picking the Group field opens its value control with per-group legends.
+ view |> element("#member-filter-field-opt-group") |> render_click()
+ assert has_element?(view, "[data-testid='value-control-group']")
end
@tag :ui
@@ -102,13 +103,7 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
- view
- |> element("button[aria-label='Filter members']")
- |> render_click()
-
- view
- |> element("[data-testid='member-filter-form']")
- |> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
+ apply_group_filter(view, group1.id, "in")
html = render(view)
assert html =~ member1.first_name
@@ -140,13 +135,7 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
- view
- |> element("button[aria-label='Filter members']")
- |> render_click()
-
- view
- |> element("[data-testid='member-filter-form']")
- |> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
+ apply_group_filter(view, group1.id, "in")
html = render(view)
assert html =~ member1.first_name
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 c8d5160a..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
@@ -62,20 +62,7 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
end
defp open_filter_and_set_group(view, group_id, value) do
- view
- |> element("button[aria-label='Filter members']")
- |> render_click()
-
- key = "group_#{group_id}"
-
- view
- |> element("[data-testid='member-filter-form']")
- |> render_change(%{key => value, "payment_filter" => "all"})
-
- # Force LiveView to process {:group_filter_changed, ...} (render triggers mailbox processing)
- _ = render(view)
- # Wait for patch; return path so callers can assert URL contains expected filter param
- path = assert_patch(view)
+ path = apply_group_filter(view, group_id, value)
{view, path}
end
diff --git a/test/mv_web/member_live/index_groups_integration_test.exs b/test/mv_web/member_live/index_groups_integration_test.exs
index cae9ce16..0e150a57 100644
--- a/test/mv_web/member_live/index_groups_integration_test.exs
+++ b/test/mv_web/member_live/index_groups_integration_test.exs
@@ -103,13 +103,7 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
- view
- |> element("button[aria-label='Filter members']")
- |> render_click()
-
- view
- |> element("[data-testid='member-filter-form']")
- |> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
+ apply_group_filter(view, group1.id, "in")
html = render(view)
assert html =~ member1.first_name
@@ -174,10 +168,10 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} =
- live(conn, "/members?group_#{group1.id}=in&cycle_status_filter=paid")
+ live(conn, "/members?group_#{group1.id}=in&pay_filter=fully_paid")
assert html =~ "Members"
- # member1 has a group and a paid cycle; page should load with both filters
+ # member1 has a group and no unpaid cycles; both filters keep it visible.
assert html =~ member1.first_name
end
@@ -191,13 +185,7 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
{:ok, view, _html} = live(conn, "/members")
# Apply group filter
- view
- |> element("button[aria-label='Filter members']")
- |> render_click()
-
- view
- |> element("[data-testid='member-filter-form']")
- |> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
+ apply_group_filter(view, group1.id, "in")
# Apply search (this tests that filter and search work together;
# search form is in SearchBarComponent with phx-submit="search")
@@ -242,13 +230,7 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
{:ok, view, _html} = live(conn, "/members")
# Apply group filter
- view
- |> element("button[aria-label='Filter members']")
- |> render_click()
-
- view
- |> element("[data-testid='member-filter-form']")
- |> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
+ apply_group_filter(view, group1.id, "in")
# Apply sorting
view
diff --git a/test/mv_web/member_live/index_groups_performance_test.exs b/test/mv_web/member_live/index_groups_performance_test.exs
index 6d05908b..3634b172 100644
--- a/test/mv_web/member_live/index_groups_performance_test.exs
+++ b/test/mv_web/member_live/index_groups_performance_test.exs
@@ -102,13 +102,7 @@ defmodule MvWeb.MemberLive.IndexGroupsPerformanceTest do
{:ok, view, _html} = live(conn, "/members")
# Open filter and apply "Yes" for group1 (even-indexed members are in group1)
- view
- |> element("button[aria-label='Filter members']")
- |> render_click()
-
- view
- |> element("[data-testid='member-filter-form']")
- |> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
+ apply_group_filter(view, group1.id, "in")
# Force LiveView to process {:group_filter_changed, ...}
html = render(view)
diff --git a/test/mv_web/member_live/index_groups_url_params_test.exs b/test/mv_web/member_live/index_groups_url_params_test.exs
index 3de60923..22329ab3 100644
--- a/test/mv_web/member_live/index_groups_url_params_test.exs
+++ b/test/mv_web/member_live/index_groups_url_params_test.exs
@@ -73,13 +73,7 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
- view
- |> element("button[aria-label='Filter members']")
- |> render_click()
-
- view
- |> element("[data-testid='member-filter-form']")
- |> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
+ apply_group_filter(view, group1.id, "in")
html = render(view)
assert html =~ member1.first_name
diff --git a/test/mv_web/member_live/index_membership_fee_status_test.exs b/test/mv_web/member_live/index_membership_fee_status_test.exs
index 5ee23afb..f4a2afdc 100644
--- a/test/mv_web/member_live/index_membership_fee_status_test.exs
+++ b/test/mv_web/member_live/index_membership_fee_status_test.exs
@@ -1,16 +1,15 @@
defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
@moduledoc """
- Tests for membership fee status column in member list view.
+ Tests for the period-scoped payment aging column in the member overview
+ (§1.13, §3.3). The column shows, per member, the count of unpaid cycles whose
+ `cycle_end` falls in the active period (default: all outstanding); 0 → "Paid";
+ suspended cycles are excluded from the count.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import Mv.Fixtures, only: [create_fee_type: 1, create_cycle: 3]
- alias Mv.MembershipFees.MembershipFeeCycle
-
- require Ash.Query
-
# Helper to create a member
defp create_member(attrs) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
@@ -26,253 +25,135 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
member
end
- describe "status column display" do
- test "shows status column in member list", %{conn: conn} do
- fee_type = create_fee_type(%{interval: :yearly})
- member = create_member(%{membership_fee_type_id: fee_type.id})
+ describe "payment aging column" do
+ test "header names the payment column", %{conn: conn} do
+ {:ok, _view, html} = live(conn, "/members")
+ assert html =~ "Fees"
+ end
- create_cycle(member, fee_type, %{
- cycle_start: ~D[2023-01-01],
- status: :paid,
- replace_existing: true
- })
+ test "a member with unpaid cycles shows the unpaid count; suspended excluded", %{conn: conn} do
+ fee_type = create_fee_type(%{interval: :yearly})
+ member = create_member(%{first_name: "OwesTwo", membership_fee_type_id: fee_type.id})
+
+ create_cycle(member, fee_type, %{cycle_start: ~D[2022-01-01], status: :unpaid})
+ create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
+ # A suspended cycle must never be counted.
+ create_cycle(member, fee_type, %{cycle_start: ~D[2024-01-01], status: :suspended})
{:ok, _view, html} = live(conn, "/members")
- # Should show membership fee status column
- assert html =~ "Membership Fee Status" || html =~ "Mitgliedsbeitrag Status"
+ assert html =~ "2 open"
+ refute html =~ "3 open"
end
- test "shows last completed cycle status by default", %{conn: conn} do
+ test "a member with no unpaid cycles reads Paid", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
- member = create_member(%{membership_fee_type_id: fee_type.id})
+ member = create_member(%{first_name: "AllPaid", membership_fee_type_id: fee_type.id})
- create_cycle(member, fee_type, %{
- cycle_start: ~D[2022-01-01],
- status: :paid,
- replace_existing: true
- })
+ create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
- create_cycle(member, fee_type, %{
- cycle_start: ~D[2023-01-01],
- status: :unpaid,
- replace_existing: true
- })
+ {:ok, _view, html} = live(conn, "/members")
+
+ assert html =~ "Paid"
+ end
+
+ test "the badge is a real navigation link that drills into the member fees history", %{
+ conn: conn
+ } do
+ fee_type = create_fee_type(%{interval: :yearly})
+ member = create_member(%{first_name: "Drill", membership_fee_type_id: fee_type.id})
+ create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
{:ok, view, _html} = live(conn, "/members")
- # Should show unpaid status (2023 is last completed)
- html = render(view)
- assert html =~ "hero-x-circle" || html =~ "unpaid"
+ badge = "a[data-testid='payment-badge']#payment-badge-#{member.id}"
+ href = "/members/#{member.id}?tab=membership_fees"
+
+ # A semantic navigating to the member's membership-fees section — not a
+ # div with JS.navigate — so it is keyboard- and right-click-friendly.
+ assert has_element?(view, "#{badge}[href='#{href}']")
+
+ # Its hover/focus tooltip is a top-layer native Popover opened via the
+ # HoverPopover hook (no SortTooltip id-linking on the badge anymore).
+ assert has_element?(view, "#{badge}[phx-hook='HoverPopover']")
+ assert has_element?(view, "#{badge}[data-popover-target='payment-tip-#{member.id}']")
+ refute has_element?(view, "[data-testid='payment-badge'][data-tooltip-id]")
+
+ # The tooltip popover lists the open cycles for the period.
+ assert has_element?(view, "#payment-tip-#{member.id}[popover]")
end
- test "toggle switches to current cycle view", %{conn: conn} do
+ test "the tooltip lists suspended cycles in a separate labeled section (§1.14)", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
- member = create_member(%{membership_fee_type_id: fee_type.id})
-
- today = Date.utc_today()
- current_year_start = %{today | month: 1, day: 1}
-
- create_cycle(member, fee_type, %{
- cycle_start: ~D[2023-01-01],
- status: :paid,
- replace_existing: true
- })
-
- create_cycle(member, fee_type, %{
- cycle_start: current_year_start,
- status: :suspended,
- replace_existing: true
- })
+ member = create_member(%{first_name: "Mixed", membership_fee_type_id: fee_type.id})
+ create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
+ create_cycle(member, fee_type, %{cycle_start: ~D[2024-01-01], status: :suspended})
{:ok, view, _html} = live(conn, "/members")
- # Toggle to current cycle (use the button in the header)
- view
- |> element("[data-testid=toggle-cycle-view]")
- |> render_click()
+ tip = "#payment-tip-#{member.id}"
- html = render(view)
- # Should show suspended status (current cycle)
- assert html =~ "hero-pause-circle" || html =~ "suspended"
+ # Suspended cycles are shown separately, under their own text label — the
+ # distinction is not color-only. The suspended cycle's period lives in the
+ # suspended section and is absent from the unpaid list.
+ assert has_element?(view, "#{tip} [data-testid='payment-tooltip-suspended']", "Suspended")
- # The toggle button exposes its pressed state to assistive tech.
- assert has_element?(view, ~s([data-testid=toggle-cycle-view][aria-pressed="true"]))
+ suspended =
+ view |> element("#{tip} [data-testid='payment-tooltip-suspended']") |> render()
+
+ assert suspended =~ "2024"
+
+ unpaid = view |> element("#{tip} [data-testid='payment-tooltip-unpaid']") |> render()
+ assert unpaid =~ "2023"
+ refute unpaid =~ "2024"
end
- test "shows correct color coding for paid status", %{conn: conn} do
+ test "a fully-paid member renders the badge but no tooltip popover", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
- member = create_member(%{membership_fee_type_id: fee_type.id})
-
- create_cycle(member, fee_type, %{
- cycle_start: ~D[2023-01-01],
- status: :paid,
- replace_existing: true
- })
+ member = create_member(%{first_name: "Clean", membership_fee_type_id: fee_type.id})
+ create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
{:ok, view, _html} = live(conn, "/members")
- html = render(view)
- assert html =~ "text-success" || html =~ "hero-check-circle"
+ assert has_element?(view, "a[data-testid='payment-badge']#payment-badge-#{member.id}")
+ refute has_element?(view, "#payment-tip-#{member.id}")
+ refute has_element?(view, "a#payment-badge-#{member.id}[phx-hook]")
end
- test "shows correct color coding for unpaid status", %{conn: conn} do
+ test "members without cycles render without error", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
- member = create_member(%{membership_fee_type_id: fee_type.id})
+ member = create_member(%{first_name: "NoCycles", membership_fee_type_id: fee_type.id})
- create_cycle(member, fee_type, %{
- cycle_start: ~D[2023-01-01],
- status: :unpaid,
- replace_existing: true
- })
-
- {:ok, view, _html} = live(conn, "/members")
-
- html = render(view)
- assert html =~ "text-error" || html =~ "hero-x-circle"
- end
-
- test "shows correct color coding for suspended status", %{conn: conn} do
- fee_type = create_fee_type(%{interval: :yearly})
- member = create_member(%{membership_fee_type_id: fee_type.id})
-
- create_cycle(member, fee_type, %{
- cycle_start: ~D[2023-01-01],
- status: :suspended,
- replace_existing: true
- })
-
- {:ok, view, _html} = live(conn, "/members")
-
- html = render(view)
- assert html =~ "text-base-content/60" || html =~ "hero-pause-circle"
- end
-
- test "handles members without cycles gracefully", %{conn: conn} do
- fee_type = create_fee_type(%{interval: :yearly})
- member = create_member(%{membership_fee_type_id: fee_type.id})
- # No cycles created
-
- {:ok, view, _html} = live(conn, "/members")
-
- html = render(view)
- # Should not crash, may show empty or default state
+ {:ok, _view, html} = live(conn, "/members")
assert html =~ member.first_name
end
end
- describe "filters" do
- test "filter unpaid in last cycle works", %{conn: conn} do
+ describe "payment filter" do
+ setup do
fee_type = create_fee_type(%{interval: :yearly})
- # Member with unpaid last cycle
- member1 = create_member(%{first_name: "UnpaidMember", membership_fee_type_id: fee_type.id})
+ unpaid = create_member(%{first_name: "HasUnpaid", membership_fee_type_id: fee_type.id})
+ create_cycle(unpaid, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
- create_cycle(member1, fee_type, %{
- cycle_start: ~D[2023-01-01],
- status: :unpaid,
- replace_existing: true
- })
+ paid = create_member(%{first_name: "FullyPaid", membership_fee_type_id: fee_type.id})
+ create_cycle(paid, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
- # Member with paid last cycle
- member2 = create_member(%{first_name: "PaidMember", membership_fee_type_id: fee_type.id})
-
- create_cycle(member2, fee_type, %{
- cycle_start: ~D[2023-01-01],
- status: :paid,
- replace_existing: true
- })
-
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
-
- # Verify cycles exist in database
- cycles1 =
- MembershipFeeCycle
- |> Ash.Query.filter(member_id == ^member1.id)
- |> Ash.read!(actor: system_actor)
-
- cycles2 =
- MembershipFeeCycle
- |> Ash.Query.filter(member_id == ^member2.id)
- |> Ash.read!(actor: system_actor)
-
- refute Enum.empty?(cycles1)
- refute Enum.empty?(cycles2)
-
- {:ok, _view, html} = live(conn, "/members?cycle_status_filter=unpaid")
-
- assert html =~ "UnpaidMember"
- refute html =~ "PaidMember"
+ %{}
end
- test "filter unpaid in current cycle works", %{conn: conn} do
- fee_type = create_fee_type(%{interval: :yearly})
+ test "has-unpaid ≥ 1 selects only members with unpaid cycles", %{conn: conn} do
+ {:ok, _view, html} = live(conn, "/members?pay_filter=unpaid_1")
- today = Date.utc_today()
- current_year_start = %{today | month: 1, day: 1}
-
- # Member with unpaid current cycle
- member1 = create_member(%{first_name: "UnpaidCurrent", membership_fee_type_id: fee_type.id})
-
- create_cycle(member1, fee_type, %{
- cycle_start: current_year_start,
- status: :unpaid,
- replace_existing: true
- })
-
- # Member with paid current cycle
- member2 = create_member(%{first_name: "PaidCurrent", membership_fee_type_id: fee_type.id})
-
- create_cycle(member2, fee_type, %{
- cycle_start: current_year_start,
- status: :paid,
- replace_existing: true
- })
-
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
-
- # Verify cycles exist in database
- cycles1 =
- MembershipFeeCycle
- |> Ash.Query.filter(member_id == ^member1.id)
- |> Ash.read!(actor: system_actor)
-
- cycles2 =
- MembershipFeeCycle
- |> Ash.Query.filter(member_id == ^member2.id)
- |> Ash.read!(actor: system_actor)
-
- refute Enum.empty?(cycles1)
- refute Enum.empty?(cycles2)
-
- {:ok, _view, html} =
- live(conn, "/members?cycle_status_filter=unpaid&show_current_cycle=true")
-
- assert html =~ "UnpaidCurrent"
- refute html =~ "PaidCurrent"
+ assert html =~ "HasUnpaid"
+ refute html =~ "FullyPaid"
end
- end
- @moduletag :slow
- describe "performance" do
- test "loads cycles efficiently without N+1 queries", %{conn: conn} do
- fee_type = create_fee_type(%{interval: :yearly})
+ test "fully-paid selects only members with zero unpaid cycles", %{conn: conn} do
+ {:ok, _view, html} = live(conn, "/members?pay_filter=fully_paid")
- # Create multiple members with cycles
- Enum.each(1..5, fn _ ->
- member = create_member(%{membership_fee_type_id: fee_type.id})
-
- create_cycle(member, fee_type, %{
- cycle_start: ~D[2023-01-01],
- status: :paid,
- replace_existing: true
- })
- end)
-
- {:ok, _view, html} = live(conn, "/members")
-
- # Should render without errors (N+1 would cause performance issues)
- assert html =~ "Members" || html =~ "Mitglieder"
+ assert html =~ "FullyPaid"
+ refute html =~ "HasUnpaid"
end
end
end
diff --git a/test/mv_web/member_live/index_payment_aging_property_test.exs b/test/mv_web/member_live/index_payment_aging_property_test.exs
new file mode 100644
index 00000000..ae6d7223
--- /dev/null
+++ b/test/mv_web/member_live/index_payment_aging_property_test.exs
@@ -0,0 +1,82 @@
+defmodule MvWeb.MemberLive.IndexPaymentAgingPropertyTest do
+ @moduledoc """
+ §2.5 — Unpaid-cycle count correctness (period-scoped, member-relative).
+
+ For arbitrary per-member cycle sets (status × cycle_end) and arbitrary active
+ periods, the `unpaid_cycle_count` calculation equals the set-theoretic
+ definition `|{c : c.status == :unpaid and c.cycle_end ∈ period}|` — paid and
+ suspended cycles excluded, nil period bounds unbounded.
+ """
+ use Mv.DataCase, async: false
+ use ExUnitProperties
+
+ import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4]
+
+ alias Mv.MembershipFees.MembershipFeeCycle
+
+ require Ash.Query
+
+ # Distinct monthly cycle_starts so cycles never collide on the
+ # (member_id, cycle_start) identity; monthly keeps each cycle_end inside a
+ # single month for legible in/out-of-period placement.
+ @starts for y <- 2022..2026, m <- [1, 4, 7, 10], do: Date.new!(y, m, 1)
+ @bounds [nil, ~D[2023-01-01], ~D[2024-06-30], ~D[2025-12-31]]
+
+ setup do
+ actor = Mv.Helpers.SystemActor.get_system_actor()
+ fee_type = create_fee_type(%{interval: :monthly}, actor)
+ %{actor: actor, fee_type: fee_type}
+ end
+
+ defp cycle_gen do
+ StreamData.tuple(
+ {StreamData.member_of(@starts), StreamData.member_of([:unpaid, :paid, :suspended])}
+ )
+ end
+
+ defp in_period?(cycle_end, from, to) do
+ (is_nil(from) or Date.compare(cycle_end, from) != :lt) and
+ (is_nil(to) or Date.compare(cycle_end, to) != :gt)
+ end
+
+ property "unpaid_cycle_count equals the set-theoretic definition", %{
+ actor: actor,
+ fee_type: ft
+ } do
+ check all(
+ cycles <- StreamData.list_of(cycle_gen(), max_length: 6),
+ from <- StreamData.member_of(@bounds),
+ to <- StreamData.member_of(@bounds),
+ max_runs: 40
+ ) do
+ member = member_fixture_with_actor(%{}, actor)
+
+ cycles
+ |> Enum.uniq_by(fn {start, _status} -> start end)
+ |> Enum.each(fn {start, status} ->
+ create_cycle(member, ft, %{cycle_start: start, status: status}, actor)
+ end)
+
+ stored =
+ MembershipFeeCycle
+ |> Ash.Query.filter(member_id == ^member.id)
+ |> Ash.read!(actor: actor)
+
+ expected =
+ Enum.count(stored, fn c ->
+ c.status == :unpaid and in_period?(c.cycle_end, from, to)
+ end)
+
+ actual =
+ member
+ |> Ash.load!([unpaid_cycle_count: %{period_from: from, period_to: to}], actor: actor)
+ |> Map.fetch!(:unpaid_cycle_count)
+
+ assert actual == expected
+
+ # Clean up so populations do not accumulate across runs.
+ Enum.each(stored, &Ash.destroy!(&1, actor: actor))
+ Ash.destroy!(member, actor: actor)
+ end
+ end
+end
diff --git a/test/mv_web/member_live/index_payment_aging_test.exs b/test/mv_web/member_live/index_payment_aging_test.exs
new file mode 100644
index 00000000..f80d1a42
--- /dev/null
+++ b/test/mv_web/member_live/index_payment_aging_test.exs
@@ -0,0 +1,79 @@
+defmodule MvWeb.MemberLive.IndexPaymentAgingTest do
+ @moduledoc """
+ §1.13 / §3.3 — the period-scoped `unpaid_cycle_count` calculation on Member.
+
+ Counts a member's cycles with `status == :unpaid` whose denormalized
+ `cycle_end` falls inside the active period; paid and suspended cycles are
+ never counted. A nil period bound means "unbounded on that side" (the
+ all-outstanding default counts every unpaid cycle).
+ """
+ use Mv.DataCase, async: false
+
+ import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4]
+
+ alias Mv.Membership.Member
+
+ require Ash.Query
+
+ setup do
+ actor = Mv.Helpers.SystemActor.get_system_actor()
+ # Monthly so several cycles can share one period without cycle_start collisions.
+ fee_type = create_fee_type(%{interval: :monthly}, actor)
+ %{actor: actor, fee_type: fee_type}
+ end
+
+ defp count(member, from, to, actor) do
+ member
+ |> Ash.load!([unpaid_cycle_count: %{period_from: from, period_to: to}], actor: actor)
+ |> Map.fetch!(:unpaid_cycle_count)
+ end
+
+ test "counts only unpaid cycles with cycle_end in the period; suspended and paid excluded",
+ %{actor: actor, fee_type: ft} do
+ # No membership_fee_type_id on the member itself → no auto-generated cycles.
+ member = member_fixture_with_actor(%{}, actor)
+
+ # In-period unpaid (counted)
+ create_cycle(member, ft, %{cycle_start: ~D[2024-01-01], status: :unpaid}, actor)
+ create_cycle(member, ft, %{cycle_start: ~D[2024-02-01], status: :unpaid}, actor)
+ # In-period but not unpaid (excluded)
+ create_cycle(member, ft, %{cycle_start: ~D[2024-03-01], status: :paid}, actor)
+ create_cycle(member, ft, %{cycle_start: ~D[2024-04-01], status: :suspended}, actor)
+ # Unpaid but out of period (excluded)
+ create_cycle(member, ft, %{cycle_start: ~D[2023-01-01], status: :unpaid}, actor)
+
+ assert count(member, ~D[2024-01-01], ~D[2024-12-31], actor) == 2
+
+ # Fully-paid member (only paid/suspended in period) → 0
+ paid_member = member_fixture_with_actor(%{}, actor)
+ create_cycle(paid_member, ft, %{cycle_start: ~D[2024-01-01], status: :paid}, actor)
+ create_cycle(paid_member, ft, %{cycle_start: ~D[2024-02-01], status: :suspended}, actor)
+ assert count(paid_member, ~D[2024-01-01], ~D[2024-12-31], actor) == 0
+ end
+
+ test "nil period bounds count every unpaid cycle (all-outstanding default)",
+ %{actor: actor, fee_type: ft} do
+ member = member_fixture_with_actor(%{}, actor)
+ create_cycle(member, ft, %{cycle_start: ~D[2020-01-01], status: :unpaid}, actor)
+ create_cycle(member, ft, %{cycle_start: ~D[2024-06-01], status: :unpaid}, actor)
+ create_cycle(member, ft, %{cycle_start: ~D[2026-01-01], status: :unpaid}, actor)
+ create_cycle(member, ft, %{cycle_start: ~D[2025-01-01], status: :paid}, actor)
+
+ assert count(member, nil, nil, actor) == 3
+ end
+
+ test "loads for a whole read query (DB-pushable aggregate)", %{actor: actor, fee_type: ft} do
+ member = member_fixture_with_actor(%{}, actor)
+ create_cycle(member, ft, %{cycle_start: ~D[2024-01-01], status: :unpaid}, actor)
+
+ [loaded] =
+ Member
+ |> Ash.Query.filter(id == ^member.id)
+ |> Ash.Query.load(
+ unpaid_cycle_count: %{period_from: ~D[2024-01-01], period_to: ~D[2024-12-31]}
+ )
+ |> Ash.read!(actor: actor)
+
+ assert loaded.unpaid_cycle_count == 1
+ end
+end
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
new file mode 100644
index 00000000..31ae2827
--- /dev/null
+++ b/test/mv_web/member_live/index_payment_filter_test.exs
@@ -0,0 +1,95 @@
+defmodule MvWeb.MemberLive.IndexPaymentFilterTest do
+ @moduledoc """
+ §1.16 / §2.2 / §3.3 — the payment-count filter in OverviewQuery.
+
+ `fully_paid` selects members with 0 unpaid cycles in the active period;
+ `{:has_unpaid, N}` selects members with at least N — consistent with the
+ aging count (§2.5). All filtering resolves DB-side via the
+ `unpaid_cycle_count` calculation.
+ """
+ use Mv.DataCase, async: false
+
+ import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4]
+
+ 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)
+
+ # Wipe the seeded/other members so id sets are deterministic.
+ Mv.Membership.Member
+ |> Ash.read!(actor: actor)
+ |> Enum.each(&Ash.destroy!(&1, actor: actor))
+
+ %{actor: actor, ft: ft}
+ end
+
+ defp member_with_unpaid(n, actor, ft) do
+ member = member_fixture_with_actor(%{}, actor)
+
+ Enum.each(1..max(n, 0)//1, fn i ->
+ create_cycle(member, ft, %{cycle_start: Date.new!(2024, i, 1), status: :unpaid}, actor)
+ end)
+
+ member
+ end
+
+ defp ids(filter, actor) do
+ %{payment_filter: filter, payment_period: @period}
+ |> OverviewQuery.build()
+ |> Ash.read!(actor: actor)
+ |> MapSet.new(& &1.id)
+ end
+
+ test "fully_paid selects members with zero in-period unpaid cycles", %{actor: actor, ft: ft} do
+ paid = member_with_unpaid(0, actor, ft)
+ create_cycle(paid, ft, %{cycle_start: ~D[2024-05-01], status: :paid}, actor)
+ one = member_with_unpaid(1, actor, ft)
+
+ result = ids(:fully_paid, actor)
+ assert MapSet.member?(result, paid.id)
+ refute MapSet.member?(result, one.id)
+ end
+
+ test "has-unpaid thresholds select members with count >= N", %{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)
+
+ assert ids({:has_unpaid, 1}, actor) == MapSet.new([one.id, two.id, three.id])
+ assert ids({:has_unpaid, 2}, actor) == MapSet.new([two.id, three.id])
+ assert ids({:has_unpaid, 3}, actor) == MapSet.new([three.id])
+ 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)
+ create_cycle(member, ft, %{cycle_start: ~D[2024-02-01], status: :suspended}, actor)
+
+ refute MapSet.member?(ids({:has_unpaid, 1}, actor), member.id)
+ assert MapSet.member?(ids(:fully_paid, actor), member.id)
+ end
+end
diff --git a/test/mv_web/member_live/index_payment_period_test.exs b/test/mv_web/member_live/index_payment_period_test.exs
new file mode 100644
index 00000000..554aaf6a
--- /dev/null
+++ b/test/mv_web/member_live/index_payment_period_test.exs
@@ -0,0 +1,218 @@
+defmodule MvWeb.MemberLive.IndexPaymentPeriodTest do
+ @moduledoc """
+ §1.13 / §1.14 / §3.3 — the PaymentAging module: period parse/serialize with
+ the all-outstanding default, badge formatting, and the open-cycles list that
+ backs the badge tooltip (unpaid cycles for the period; suspended shown apart).
+ """
+ use Mv.DataCase, async: false
+ use ExUnitProperties
+
+ import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4]
+
+ alias MvWeb.MemberLive.Index.PaymentAging
+
+ require Ash.Query
+
+ describe "period parsing / serialization" do
+ test "default period is all-outstanding (both bounds nil)" do
+ assert PaymentAging.default_period() == %{from: nil, to: nil}
+ end
+
+ test "no params parses to the default period" do
+ assert PaymentAging.parse_period(%{}) == %{from: nil, to: nil}
+ end
+
+ test "parses ISO bounds and round-trips through to_params" do
+ period = %{from: ~D[2024-01-01], to: ~D[2024-12-31]}
+ params = PaymentAging.to_params(period)
+
+ assert params["pay_from"] == "2024-01-01"
+ assert params["pay_to"] == "2024-12-31"
+ assert PaymentAging.parse_period(params) == period
+ end
+
+ test "default period serializes to no params" do
+ assert PaymentAging.to_params(PaymentAging.default_period()) == %{}
+ end
+
+ test "malformed bounds fall back to nil" do
+ assert PaymentAging.parse_period(%{"pay_from" => "not-a-date"}) == %{from: nil, to: nil}
+ 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
+
+ test "explains that the values refer to the filtered contribution period" do
+ # The header badge falls back to a plain "filtered" label whenever no
+ # compact short code fits (arbitrary or open-ended ranges); its tooltip
+ # must spell out what "filtered" means, not just repeat the range.
+ for period <- [
+ %{from: nil, to: nil},
+ %{from: ~D[2026-02-03], to: ~D[2026-08-17]},
+ %{from: ~D[2026-01-01], to: nil}
+ ] do
+ tip = PaymentAging.period_tooltip(period)
+ assert tip =~ "refer to the filtered contribution period"
+ end
+ 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
+ params = PaymentAging.filter_to_params(state)
+ assert PaymentAging.parse_filter_params(params) == state
+ end
+ end
+
+ test "nil filter serializes to no params and unknown values parse to nil" do
+ assert PaymentAging.filter_to_params(nil) == %{}
+ assert PaymentAging.parse_filter_params(%{}) == nil
+ assert PaymentAging.parse_filter_params(%{"pay_filter" => "bogus"}) == nil
+ end
+
+ test "serializes to stable string tokens" do
+ assert PaymentAging.filter_to_params(:fully_paid) == %{"pay_filter" => "fully_paid"}
+ assert PaymentAging.filter_to_params({:has_unpaid, 2}) == %{"pay_filter" => "unpaid_2"}
+ end
+ end
+
+ describe "payment URL round-trip (§2.1, payment keys)" do
+ @dates [nil, ~D[2020-01-01], ~D[2024-06-30], ~D[2025-12-31]]
+ @filters [nil, :fully_paid, {:has_unpaid, 1}, {:has_unpaid, 2}, {:has_unpaid, 3}]
+
+ property "decode∘encode is canonical and idempotent for period + payment filter" do
+ check all(
+ from <- StreamData.member_of(@dates),
+ to <- StreamData.member_of(@dates),
+ filter <- StreamData.member_of(@filters)
+ ) do
+ period = %{from: from, to: to}
+
+ params =
+ period
+ |> PaymentAging.to_params()
+ |> Map.merge(PaymentAging.filter_to_params(filter))
+
+ assert PaymentAging.parse_period(params) == period
+ assert PaymentAging.parse_filter_params(params) == filter
+
+ # Idempotence: re-encoding the decoded state yields the same params.
+ reencoded =
+ params
+ |> PaymentAging.parse_period()
+ |> PaymentAging.to_params()
+ |> Map.merge(PaymentAging.filter_to_params(PaymentAging.parse_filter_params(params)))
+
+ assert reencoded == params
+ end
+ end
+ end
+
+ describe "badge/1" do
+ test "0 renders as All paid (success/green), color derived from the shared status variant" do
+ badge = PaymentAging.badge(0)
+ refute Map.has_key?(badge, :variant)
+ assert badge.color_class == "badge-success"
+ assert badge.label == "All paid"
+ end
+
+ test "N > 0 renders as N open (error/red), color derived from the shared status variant" do
+ badge = PaymentAging.badge(2)
+ refute Map.has_key?(badge, :variant)
+ assert badge.color_class == "badge-error"
+ assert badge.label =~ "2"
+ assert badge.label =~ "open"
+ end
+ end
+
+ describe "open_cycles/2" do
+ setup do
+ actor = Mv.Helpers.SystemActor.get_system_actor()
+ ft = create_fee_type(%{interval: :monthly}, actor)
+ %{actor: actor, ft: ft}
+ end
+
+ test "lists in-period unpaid and suspended cycles chronologically; paid/out-of-period excluded",
+ %{actor: actor, ft: ft} do
+ member = member_fixture_with_actor(%{}, actor)
+ create_cycle(member, ft, %{cycle_start: ~D[2024-02-01], status: :unpaid}, actor)
+ create_cycle(member, ft, %{cycle_start: ~D[2024-01-01], status: :unpaid}, actor)
+ create_cycle(member, ft, %{cycle_start: ~D[2024-03-01], status: :suspended}, actor)
+ create_cycle(member, ft, %{cycle_start: ~D[2024-04-01], status: :paid}, actor)
+ # out of period
+ create_cycle(member, ft, %{cycle_start: ~D[2023-01-01], status: :unpaid}, actor)
+
+ member = Ash.load!(member, :membership_fee_cycles, actor: actor)
+ result = PaymentAging.open_cycles(member, %{from: ~D[2024-01-01], to: ~D[2024-12-31]})
+
+ assert Enum.map(result, & &1.status) == [:unpaid, :unpaid, :suspended]
+
+ assert Enum.map(result, & &1.cycle_start) == [
+ ~D[2024-01-01],
+ ~D[2024-02-01],
+ ~D[2024-03-01]
+ ]
+ end
+
+ test "nil bounds include every unpaid/suspended cycle", %{actor: actor, ft: ft} do
+ member = member_fixture_with_actor(%{}, actor)
+ create_cycle(member, ft, %{cycle_start: ~D[2020-01-01], status: :unpaid}, actor)
+ create_cycle(member, ft, %{cycle_start: ~D[2026-01-01], status: :suspended}, actor)
+
+ member = Ash.load!(member, :membership_fee_cycles, actor: actor)
+ result = PaymentAging.open_cycles(member, PaymentAging.default_period())
+
+ assert Enum.map(result, & &1.status) == [:unpaid, :suspended]
+ end
+ end
+
+ describe "tooltip_cycles/1 (five-slot cap)" do
+ test "five or fewer cycles show in full with no overflow" do
+ cycles = for i <- 1..5, do: %{n: i}
+ assert PaymentAging.tooltip_cycles(cycles) == {cycles, 0}
+ end
+
+ test "more than five collapses to first four cycles plus the overflow count" do
+ cycles = for i <- 1..19, do: %{n: i}
+ {shown, overflow} = PaymentAging.tooltip_cycles(cycles)
+
+ assert shown == Enum.take(cycles, 4)
+ assert overflow == 15
+ end
+ end
+end
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
new file mode 100644
index 00000000..1186cbbd
--- /dev/null
+++ b/test/mv_web/member_live/index_payment_wiring_test.exs
@@ -0,0 +1,94 @@
+defmodule MvWeb.MemberLive.IndexPaymentWiringTest do
+ @moduledoc """
+ §1.16 / §1.17 — the member overview LiveView honours the period-scoped
+ payment-count filter and payment-period URL contract (`pay_filter`,
+ `pay_from`, `pay_to`), driving the same DB result set the OverviewQuery unit
+ tests pin, but through `handle_params` and the query wiring.
+ """
+ use MvWeb.ConnCase, async: false
+
+ import Phoenix.LiveViewTest
+ import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4]
+
+ setup %{conn: conn} do
+ actor = Mv.Helpers.SystemActor.get_system_actor()
+ ft = create_fee_type(%{interval: :monthly}, actor)
+
+ # Deterministic id set: drop the seeded members.
+ Mv.Membership.Member
+ |> Ash.read!(actor: actor)
+ |> Enum.each(&Ash.destroy!(&1, actor: actor))
+
+ %{conn: conn_with_oidc_user(conn), actor: actor, ft: ft}
+ end
+
+ defp member_with_unpaid(n, actor, ft) do
+ member = member_fixture_with_actor(%{}, actor)
+
+ Enum.each(1..max(n, 0)//1, fn i ->
+ create_cycle(member, ft, %{cycle_start: Date.new!(2024, i, 1), status: :unpaid}, actor)
+ end)
+
+ member
+ end
+
+ test "has-unpaid >= 2 shows only members with at least two in-period unpaid cycles", ctx do
+ one = member_with_unpaid(1, ctx.actor, ctx.ft)
+ two = member_with_unpaid(2, ctx.actor, ctx.ft)
+
+ {:ok, view, _html} =
+ live(ctx.conn, "/members?pay_filter=unpaid_2&pay_from=2024-01-01&pay_to=2024-12-31")
+
+ assert has_element?(view, "#row-#{two.id}")
+ refute has_element?(view, "#row-#{one.id}")
+ end
+
+ test "fully_paid shows only members with zero in-period unpaid cycles", ctx do
+ paid = member_with_unpaid(0, ctx.actor, ctx.ft)
+ create_cycle(paid, ctx.ft, %{cycle_start: ~D[2024-05-01], status: :paid}, ctx.actor)
+ unpaid = member_with_unpaid(1, ctx.actor, ctx.ft)
+
+ {:ok, view, _html} =
+ live(ctx.conn, "/members?pay_filter=fully_paid&pay_from=2024-01-01&pay_to=2024-12-31")
+
+ assert has_element?(view, "#row-#{paid.id}")
+ refute has_element?(view, "#row-#{unpaid.id}")
+ end
+
+ test "the active period scopes the filter: a cycle outside the period does not count", ctx do
+ # One unpaid cycle in 2023, none in 2024. With a 2024 period, has-unpaid>=1
+ # must not select this member; with the all-outstanding default it must.
+ member = member_fixture_with_actor(%{}, ctx.actor)
+ create_cycle(member, ctx.ft, %{cycle_start: ~D[2023-03-01], status: :unpaid}, ctx.actor)
+
+ {:ok, scoped, _} =
+ live(ctx.conn, "/members?pay_filter=unpaid_1&pay_from=2024-01-01&pay_to=2024-12-31")
+
+ refute has_element?(scoped, "#row-#{member.id}")
+
+ {: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"
+ assert badge =~ "refer to the filtered contribution period"
+ 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..bf32962e
--- /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 as-of 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.AsOfDate
+ alias MvWeb.MemberLive.Index.PaymentAging
+
+ describe "as-of date" do
+ test "a set date round-trips" do
+ date = ~D[2026-03-15]
+ assert date |> AsOfDate.to_params() |> AsOfDate.parse() == date
+ end
+
+ test "nil encodes to the empty map and decodes to nil" do
+ assert AsOfDate.to_params(nil) == %{}
+ assert AsOfDate.parse(%{}) == nil
+ end
+
+ test "a malformed date param decodes to nil" do
+ assert AsOfDate.parse(%{"as_of_date" => "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_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
diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs
index fbff9f47..3e3b3d3a 100644
--- a/test/mv_web/member_live/index_test.exs
+++ b/test/mv_web/member_live/index_test.exs
@@ -473,17 +473,26 @@ defmodule MvWeb.MemberLive.IndexTest do
end
end
- # Opens the member-filter dropdown so its boolean filter controls are rendered.
- defp open_member_filter(view) do
- view
- |> element(~s(button[phx-click="toggle_dropdown"][aria-label="Filter members"]))
- |> render_click()
+ # Number of active add-filter chips currently rendered. The builder shows one
+ # chip per active filter value; this replaces the removed panel's active-count
+ # badge as the observable carrier of the filter count.
+ defp chip_count(view) do
+ render(view)
+ |> LazyHTML.from_fragment()
+ |> LazyHTML.query(~s([data-testid="filter-chip"]))
+ |> Enum.count()
end
- # Returns the rendered HTML of the member-filter dropdown (with it open).
- defp member_filter_html(view) do
- open_member_filter(view)
- render(view)
+ # Whether a boolean custom-field filter chip for `field` in the given state
+ # ("Yes"/"No") is rendered.
+ defp boolean_chip?(view, field, state) do
+ render(view) =~ "#{field.name}: #{state}"
+ end
+
+ # Whether the field picker offers a pickable option for the given custom field.
+ defp picker_has_field?(view, field_id) do
+ open_picker(view)
+ has_element?(view, "#member-filter-field-opt-#{field_id}")
end
describe "copy_emails feature" do
@@ -797,10 +806,8 @@ defmodule MvWeb.MemberLive.IndexTest do
assert html =~ "hero-chevron-down"
- # The bulk-actions trigger and the bespoke member-filter trigger each
- # carry their own chevron; assert the filter trigger's chevron is pinned
- # independently, so removing it from the filter component fails this test.
- assert has_element?(view, ".member-filter-dropdown .hero-chevron-down")
+ # The bulk-actions trigger carries its own trailing chevron.
+ assert has_element?(view, "[data-testid='bulk-actions-button'] .hero-chevron-down")
end
test "dropdown opens and closes on click", %{conn: conn} do
@@ -1055,7 +1062,7 @@ defmodule MvWeb.MemberLive.IndexTest do
test "scope is :filtered when a non-search filter is active", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid")
+ {:ok, _view, html} = live(conn, "/members?pay_filter=fully_paid")
badge = scope_badge(html)
assert badge |> LazyHTML.text() |> String.trim() == "filtered"
@@ -1162,230 +1169,6 @@ defmodule MvWeb.MemberLive.IndexTest do
end
end
- describe "cycle status filter" do
- # Helper to create a member (only used in this describe block)
- defp create_member(attrs, actor) do
- default_attrs = %{
- first_name: "Test",
- last_name: "Member",
- email: "test.member.#{System.unique_integer([:positive])}@example.com"
- }
-
- attrs = Map.merge(default_attrs, attrs)
- {:ok, member} = Membership.create_member(attrs, actor: actor)
- member
- end
-
- test "filter shows only members with paid status in last cycle", %{conn: conn} do
- system_actor = SystemActor.get_system_actor()
- conn = conn_with_oidc_user(conn)
- fee_type = create_fee_type(%{interval: :yearly}, system_actor)
- today = Date.utc_today()
- last_year_start = Date.new!(today.year - 1, 1, 1)
-
- # Member with paid last cycle
- paid_member =
- create_member(
- %{
- first_name: "PaidLast",
- membership_fee_type_id: fee_type.id
- },
- system_actor
- )
-
- create_cycle(
- paid_member,
- fee_type,
- %{cycle_start: last_year_start, status: :paid, replace_existing: true},
- system_actor
- )
-
- # Member with unpaid last cycle
- unpaid_member =
- create_member(
- %{
- first_name: "UnpaidLast",
- membership_fee_type_id: fee_type.id
- },
- system_actor
- )
-
- create_cycle(
- unpaid_member,
- fee_type,
- %{cycle_start: last_year_start, status: :unpaid, replace_existing: true},
- system_actor
- )
-
- {:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid")
-
- assert html =~ "PaidLast"
- refute html =~ "UnpaidLast"
- end
-
- test "filter shows only members with unpaid status in last cycle", %{conn: conn} do
- system_actor = SystemActor.get_system_actor()
- conn = conn_with_oidc_user(conn)
- fee_type = create_fee_type(%{interval: :yearly}, system_actor)
- today = Date.utc_today()
- last_year_start = Date.new!(today.year - 1, 1, 1)
-
- # Member with paid last cycle
- paid_member =
- create_member(
- %{
- first_name: "PaidLast",
- membership_fee_type_id: fee_type.id
- },
- system_actor
- )
-
- create_cycle(
- paid_member,
- fee_type,
- %{cycle_start: last_year_start, status: :paid, replace_existing: true},
- system_actor
- )
-
- # Member with unpaid last cycle
- unpaid_member =
- create_member(
- %{
- first_name: "UnpaidLast",
- membership_fee_type_id: fee_type.id
- },
- system_actor
- )
-
- create_cycle(
- unpaid_member,
- fee_type,
- %{cycle_start: last_year_start, status: :unpaid, replace_existing: true},
- system_actor
- )
-
- {:ok, _view, html} = live(conn, "/members?cycle_status_filter=unpaid")
-
- refute html =~ "PaidLast"
- assert html =~ "UnpaidLast"
- end
-
- test "filter shows only members with paid status in current cycle", %{conn: conn} do
- system_actor = SystemActor.get_system_actor()
- conn = conn_with_oidc_user(conn)
- fee_type = create_fee_type(%{interval: :yearly}, system_actor)
- today = Date.utc_today()
- current_year_start = Date.new!(today.year, 1, 1)
-
- # Member with paid current cycle
- paid_member =
- create_member(
- %{
- first_name: "PaidCurrent",
- membership_fee_type_id: fee_type.id
- },
- system_actor
- )
-
- create_cycle(
- paid_member,
- fee_type,
- %{cycle_start: current_year_start, status: :paid, replace_existing: true},
- system_actor
- )
-
- # Member with unpaid current cycle
- unpaid_member =
- create_member(
- %{
- first_name: "UnpaidCurrent",
- membership_fee_type_id: fee_type.id
- },
- system_actor
- )
-
- create_cycle(
- unpaid_member,
- fee_type,
- %{cycle_start: current_year_start, status: :unpaid, replace_existing: true},
- system_actor
- )
-
- {:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid&show_current_cycle=true")
-
- assert html =~ "PaidCurrent"
- refute html =~ "UnpaidCurrent"
- end
-
- test "filter shows only members with unpaid status in current cycle", %{conn: conn} do
- system_actor = SystemActor.get_system_actor()
- conn = conn_with_oidc_user(conn)
- fee_type = create_fee_type(%{interval: :yearly}, system_actor)
- today = Date.utc_today()
- current_year_start = Date.new!(today.year, 1, 1)
-
- # Member with paid current cycle
- paid_member =
- create_member(
- %{
- first_name: "PaidCurrent",
- membership_fee_type_id: fee_type.id
- },
- system_actor
- )
-
- create_cycle(
- paid_member,
- fee_type,
- %{cycle_start: current_year_start, status: :paid, replace_existing: true},
- system_actor
- )
-
- # Member with unpaid current cycle
- unpaid_member =
- create_member(
- %{
- first_name: "UnpaidCurrent",
- membership_fee_type_id: fee_type.id
- },
- system_actor
- )
-
- create_cycle(
- unpaid_member,
- fee_type,
- %{cycle_start: current_year_start, status: :unpaid, replace_existing: true},
- system_actor
- )
-
- {:ok, _view, html} =
- live(conn, "/members?cycle_status_filter=unpaid&show_current_cycle=true")
-
- refute html =~ "PaidCurrent"
- assert html =~ "UnpaidCurrent"
- end
-
- test "toggle cycle view updates URL and preserves filter", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
-
- # Start with last cycle view and paid filter
- {:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
-
- # Toggle to current cycle - this should update URL and preserve filter
- # Use the button in the toolbar
- view
- |> element("button[phx-click='toggle_cycle_view']")
- |> render_click()
-
- # Wait for patch to complete
- path = assert_patch(view)
-
- # URL should contain both filter and show_current_cycle
- assert path =~ "cycle_status_filter=paid"
- assert path =~ "show_current_cycle=true"
- end
- end
-
describe "boolean custom field filters" do
# Helper to create a boolean custom field (uses system actor for authorization)
defp create_boolean_custom_field(attrs \\ %{}) do
@@ -1454,12 +1237,13 @@ defmodule MvWeb.MemberLive.IndexTest do
string_field = create_string_custom_field(%{name: "Phone Number"})
{:ok, view, _html} = live(conn, "/members")
- open_member_filter(view)
+ open_picker(view)
- # Only the boolean fields render a tri-state filter control; the string field does not.
- assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field1.id}-all"}")
- assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field2.id}-all"}")
- refute has_element?(view, "##{"custom-boolean-filter-#{string_field.id}-all"}")
+ # Only the boolean fields are offered as pickable filter fields; the string
+ # field is not filterable on the overview.
+ assert has_element?(view, "#member-filter-field-opt-#{boolean_field1.id}")
+ assert has_element?(view, "#member-filter-field-opt-#{boolean_field2.id}")
+ refute has_element?(view, "#member-filter-field-opt-#{string_field.id}")
end
test "mount sorts boolean custom fields by name ascending", %{conn: conn} do
@@ -1471,19 +1255,16 @@ defmodule MvWeb.MemberLive.IndexTest do
field_m = create_boolean_custom_field(%{name: "Middle Field"})
{:ok, view, _html} = live(conn, "/members")
- html = member_filter_html(view)
+ open_picker(view)
- # The rendered boolean filter controls appear in name-ascending order.
+ # The picker lists the boolean custom fields in name-ascending order.
rendered_order =
- html
+ render(view)
|> LazyHTML.from_fragment()
- |> LazyHTML.query(~s(input[id$="-all"][name^="custom_boolean"]))
+ |> LazyHTML.query(~s(#member-filter-field-listbox [role="option"]))
|> LazyHTML.attribute("id")
- |> Enum.map(
- &(&1
- |> String.replace_prefix("custom-boolean-filter-", "")
- |> String.replace_suffix("-all", ""))
- )
+ |> Enum.map(&String.replace_prefix(&1, "member-filter-field-opt-", ""))
+ |> Enum.filter(&(&1 in [field_a.id, field_m.id, field_z.id]))
assert rendered_order == [field_a.id, field_m.id, field_z.id]
end
@@ -1492,17 +1273,14 @@ defmodule MvWeb.MemberLive.IndexTest do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
- # Test true value: the "Yes" radio is checked (the boolean true, not the string "true").
+ # Test true value: a "Yes" chip for the field (the boolean true, not "true").
{:ok, view1, _html} = live(conn, "/members?bf_#{boolean_field.id}=true")
- open_member_filter(view1)
- assert has_element?(view1, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]")
- refute has_element?(view1, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]")
+ assert boolean_chip?(view1, boolean_field, "Yes")
+ refute boolean_chip?(view1, boolean_field, "No")
- # Test false value: the "No" radio is checked.
+ # Test false value: a "No" chip for the field.
{:ok, view2, _html} = live(conn, "/members?bf_#{boolean_field.id}=false")
- open_member_filter(view2)
- assert has_element?(view2, "##{"custom-boolean-filter-#{boolean_field.id}-false"}[checked]")
- refute has_element?(view2, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]")
+ assert boolean_chip?(view2, boolean_field, "No")
end
test "handle_params ignores non-existent custom field IDs", %{conn: conn} do
@@ -1510,11 +1288,10 @@ defmodule MvWeb.MemberLive.IndexTest do
fake_id = Ecto.UUID.generate()
{:ok, view, _html} = live(conn, "/members?bf_#{fake_id}=true")
- open_member_filter(view)
- # No filter control exists for a non-existent field, and no active-filter badge appears.
- refute has_element?(view, "##{"custom-boolean-filter-#{fake_id}-true"}")
- refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active))
+ # A non-existent field is not offered in the picker and produces no chip.
+ refute picker_has_field?(view, fake_id)
+ assert chip_count(view) == 0
end
test "handle_params ignores non-boolean custom fields", %{conn: conn} do
@@ -1522,11 +1299,10 @@ defmodule MvWeb.MemberLive.IndexTest do
string_field = create_string_custom_field()
{:ok, view, _html} = live(conn, "/members?bf_#{string_field.id}=true")
- open_member_filter(view)
- # A string field is never rendered as a boolean filter, and no filter becomes active.
- refute has_element?(view, "##{"custom-boolean-filter-#{string_field.id}-true"}")
- refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active))
+ # A string field is never a filterable field, and no filter becomes active.
+ refute picker_has_field?(view, string_field.id)
+ assert chip_count(view) == 0
end
test "handle_params ignores invalid filter values", %{conn: conn} do
@@ -1538,11 +1314,10 @@ defmodule MvWeb.MemberLive.IndexTest do
for invalid_value <- invalid_values do
{:ok, view, _html} = live(conn, "/members?bf_#{boolean_field.id}=#{invalid_value}")
- open_member_filter(view)
- # An invalid value leaves the field's filter at "All" (no filter applied).
- assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]"),
- "Invalid value '#{invalid_value}' should leave the filter at All"
+ # An invalid value applies no filter, so no chip is rendered.
+ assert chip_count(view) == 0,
+ "Invalid value '#{invalid_value}' should leave the filter unset"
end
end
@@ -1557,16 +1332,11 @@ defmodule MvWeb.MemberLive.IndexTest do
"/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
)
- open_member_filter(view)
-
- # Both filters are reflected: field1 at "Yes", field2 at "No", and the
- # active-filter count badge shows 2.
- assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field1.id}-true"}[checked]")
- assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field2.id}-false"}[checked]")
-
- assert view
- |> element(~s(button[aria-label="Filter members"] .badge), "2")
- |> has_element?()
+ # Both filters are reflected as chips: field1 at "Yes", field2 at "No", for
+ # a total of two active-filter chips.
+ assert boolean_chip?(view, boolean_field1, "Yes")
+ assert boolean_chip?(view, boolean_field2, "No")
+ assert chip_count(view) == 2
end
test "build_query_params includes active boolean filters and excludes nil filters", %{
@@ -1630,22 +1400,20 @@ defmodule MvWeb.MemberLive.IndexTest do
assert path2 =~ "bf_#{boolean_field.id}=true"
end
- test "boolean filters work together with cycle_status_filter", %{conn: conn} do
+ test "boolean filters work together with the payment filter", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
{:ok, view, _html} =
live(
conn,
- "/members?cycle_status_filter=paid&bf_#{boolean_field.id}=true"
+ "/members?pay_filter=fully_paid&bf_#{boolean_field.id}=true"
)
- open_member_filter(view)
-
- # Both filters are reflected in the rendered controls: the boolean field at
- # "Yes" and the payment-status filter at "paid".
- assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]")
- assert has_element?(view, "#payment-filter-paid[checked]")
+ # Both filters are reflected as chips: the boolean field at "Yes" and the
+ # period-scoped payment filter as "All paid".
+ assert boolean_chip?(view, boolean_field, "Yes")
+ assert render(view) =~ "All paid"
# Both should be in URL when triggering search
view
@@ -1653,7 +1421,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|> render_change(%{value: "test"})
path = assert_patch(view)
- assert path =~ "cycle_status_filter=paid"
+ assert path =~ "pay_filter=fully_paid"
assert path =~ "bf_#{boolean_field.id}=true"
end
@@ -1666,8 +1434,7 @@ defmodule MvWeb.MemberLive.IndexTest do
{:ok, view, _html} =
live(conn, "/members?bf_#{boolean_field.id}=true")
- open_member_filter(view)
- assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]")
+ assert boolean_chip?(view, boolean_field, "Yes")
# Delete the custom field (requires actor with destroy permission)
Ash.destroy!(boolean_field, actor: system_actor)
@@ -1676,11 +1443,9 @@ defmodule MvWeb.MemberLive.IndexTest do
{:ok, view2, _html} =
live(conn, "/members?bf_#{boolean_field.id}=true")
- open_member_filter(view2)
-
- # The deleted field renders no filter control and no filter is active.
- refute has_element?(view2, "##{"custom-boolean-filter-#{boolean_field.id}-true"}")
- refute has_element?(view2, ~s(button[aria-label="Filter members"].btn-active))
+ # The deleted field is not offered in the picker and no filter is active.
+ refute picker_has_field?(view2, boolean_field.id)
+ assert chip_count(view2) == 0
end
test "handle_params handles URL-encoded custom field IDs correctly", %{conn: conn} do
@@ -1693,11 +1458,9 @@ defmodule MvWeb.MemberLive.IndexTest do
{:ok, view, _html} =
live(conn, "/members?bf_#{encoded_id}=true")
- open_member_filter(view)
-
# Phoenix decodes the param, so the filter applies under the original ID:
- # the "Yes" radio for the field is checked.
- assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]")
+ # a "Yes" chip is rendered for the field.
+ assert boolean_chip?(view, boolean_field, "Yes")
end
test "handle_params ignores malformed prefix (bf_bf_)", %{conn: conn} do
@@ -1708,12 +1471,8 @@ defmodule MvWeb.MemberLive.IndexTest do
{:ok, view, _html} =
live(conn, "/members?bf_bf_#{boolean_field.id}=true")
- open_member_filter(view)
-
- # The double-prefixed param is not a valid filter: the field stays at "All"
- # and no filter is active.
- assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]")
- refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active))
+ # The double-prefixed param is not a valid filter: no chip is active.
+ assert chip_count(view) == 0
end
test "handle_params limits number of boolean filters to prevent DoS", %{conn: conn} do
@@ -1728,11 +1487,9 @@ defmodule MvWeb.MemberLive.IndexTest do
{:ok, view, _html} = live(conn, "/members?#{filter_params}")
- # The active-filter count badge is the observable carrier of the filter count.
+ # The active-filter chips are the observable carrier of the filter count.
# With 60 requested filters, the DoS cap clamps it to exactly 50.
- assert view
- |> element(~s(button[aria-label="Filter members"] .badge), "50")
- |> has_element?()
+ assert chip_count(view) == 50
end
test "handle_params ignores extremely long custom field IDs", %{conn: conn} do
@@ -1745,12 +1502,10 @@ defmodule MvWeb.MemberLive.IndexTest do
{:ok, view, _html} =
live(conn, "/members?bf_#{fake_long_id}=true")
- open_member_filter(view)
-
- # The over-long ID is rejected: the real field stays at "All" and no filter
- # is active.
- assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]")
- refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active))
+ # The over-long ID is rejected: no filter is active. (`boolean_field` is
+ # created only to prove a real field is unaffected.)
+ assert boolean_field.id
+ assert chip_count(view) == 0
end
# Helper to create a member with a boolean custom field value
@@ -2092,7 +1847,7 @@ defmodule MvWeb.MemberLive.IndexTest do
refute html_false =~ "NoValue"
end
- test "boolean filter integration works together with cycle_status_filter", %{conn: conn} do
+ test "boolean filter integration works together with the payment filter", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
@@ -2156,11 +1911,12 @@ defmodule MvWeb.MemberLive.IndexTest do
system_actor
)
- # Test both filters together
+ # Test both filters together: fully-paid (0 unpaid cycles in the period)
+ # plus the boolean field at "Yes".
{:ok, _view, html} =
- live(conn, "/members?cycle_status_filter=paid&bf_#{boolean_field.id}=true")
+ live(conn, "/members?pay_filter=fully_paid&bf_#{boolean_field.id}=true")
- # Only member_paid_true should match both filters
+ # Only member_paid_true has zero unpaid cycles and the boolean value true.
assert html =~ "PaidTrue"
refute html =~ "UnpaidTrue"
end
@@ -2246,25 +2002,25 @@ defmodule MvWeb.MemberLive.IndexTest do
end
@tag :ui
- test "boolean custom field appears in filter dropdown after being added", %{conn: conn} do
+ test "boolean custom field appears in the field picker after being added", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Start with no boolean custom fields
{:ok, view, _html} = live(conn, "/members")
- open_member_filter(view)
+ open_picker(view)
- # No boolean field control is rendered yet.
- refute has_element?(view, ~s(input[name^="custom_boolean"]))
+ # With no custom fields, the picker renders no "Custom fields" group.
+ refute has_element?(view, "[data-testid='field-group-header']", "Custom fields")
# Create a new boolean custom field
new_boolean_field = create_boolean_custom_field(%{name: "Newly Added Field"})
- # Navigate again - the new field should appear in the filter dropdown.
+ # Navigate again - the new field should appear in the picker.
{:ok, view2, _html} = live(conn, "/members")
- html_after = member_filter_html(view2)
+ open_picker(view2)
- assert has_element?(view2, "##{"custom-boolean-filter-#{new_boolean_field.id}-all"}")
- assert html_after =~ "Newly Added Field"
+ assert has_element?(view2, "#member-filter-field-opt-#{new_boolean_field.id}")
+ assert render(view2) =~ "Newly Added Field"
end
@tag :slow
diff --git a/test/mv_web/member_live/index_value_control_test.exs b/test/mv_web/member_live/index_value_control_test.exs
new file mode 100644
index 00000000..4c92b948
--- /dev/null
+++ b/test/mv_web/member_live/index_value_control_test.exs
@@ -0,0 +1,257 @@
+defmodule MvWeb.MemberLive.IndexValueControlTest do
+ @moduledoc """
+ §1.20 / §1.21 / §1.22 / §1.23 — the reworked value controls of the add-filter
+ builder:
+
+ * no "All"/"Alle" option in the boolean / group value controls (§1.20);
+ * the field picker's search input is a live type-ahead (§1.21);
+ * the payment control reveals a two-sided open-cycle count range under
+ "Has unpaid" and offers a suspended-status option (§1.22, §1.23).
+ """
+ use MvWeb.ConnCase, async: false
+
+ import Phoenix.LiveViewTest
+
+ alias Mv.Membership.CustomField
+ alias Mv.Membership.Group
+
+ setup %{conn: conn} do
+ actor = Mv.Helpers.SystemActor.get_system_actor()
+
+ {:ok, _member} =
+ Mv.Membership.create_member(
+ %{first_name: "Val", last_name: "Member", email: "val@example.com"},
+ actor: actor
+ )
+
+ {:ok, group} =
+ Group |> Ash.Changeset.for_create(:create, %{name: "Board"}) |> Ash.create(actor: actor)
+
+ {:ok, field} =
+ CustomField
+ |> Ash.Changeset.for_create(:create, %{
+ name: "newsletter_#{System.unique_integer([:positive])}",
+ value_type: :boolean,
+ show_in_overview: true
+ })
+ |> Ash.create(actor: actor)
+
+ %{conn: conn_with_oidc_user(conn), group: group, field: field}
+ end
+
+ describe "unified popover shell (§3.11)" do
+ test "every value control shares the 'Filter: ' shell title", %{
+ conn: conn,
+ field: field
+ } do
+ {:ok, view, _html} = live(conn, ~p"/members")
+
+ pick_field(view, "group")
+ assert has_element?(view, "[data-testid='control-title']", "Filter:")
+
+ pick_field(view, field.id)
+ assert has_element?(view, "[data-testid='control-title']", "Filter:")
+
+ pick_field(view, "payment")
+ assert has_element?(view, "[data-testid='control-title']", "Filter:")
+ end
+ end
+
+ describe "no All option (§1.20)" do
+ test "the group control offers is / is-not but no 'all' option", %{conn: conn} do
+ {:ok, view, _html} = live(conn, ~p"/members")
+
+ pick_field(view, "group")
+
+ assert has_element?(view, "[data-testid='value-control-group'] input[value='in']")
+ assert has_element?(view, "[data-testid='value-control-group'] input[value='not_in']")
+ refute has_element?(view, "[data-testid='value-control-group'] input[value='all']")
+ end
+
+ test "the group operator uses the boolean-consistent Ja/Nein check/x toggle", %{conn: conn} do
+ {:ok, view, _html} = live(conn, ~p"/members")
+
+ pick_field(view, "group")
+
+ # Same join-toggle presentation as the boolean custom-field control: a
+ # check icon on the ":in" (Ja) option and an x-mark on ":not_in" (Nein),
+ # with a visible selected state.
+ assert has_element?(view, "[data-testid='value-control-group'] .join .hero-check-circle")
+ assert has_element?(view, "[data-testid='value-control-group'] .join .hero-x-circle")
+
+ assert has_element?(
+ view,
+ "[data-testid='value-control-group'] label.has-\\[\\:checked\\]\\:btn-primary"
+ )
+ end
+
+ test "the fee-type operator uses the boolean-consistent Ja/Nein check/x toggle", %{conn: conn} do
+ _fee_type = Mv.Fixtures.create_fee_type()
+
+ {:ok, view, _html} = live(conn, ~p"/members")
+
+ pick_field(view, "fee_type")
+
+ assert has_element?(view, "[data-testid='value-control-fee-type'] .join .hero-check-circle")
+ assert has_element?(view, "[data-testid='value-control-fee-type'] .join .hero-x-circle")
+ end
+
+ test "the boolean control offers Yes / No but no 'all' option", %{conn: conn, field: field} do
+ {:ok, view, _html} = live(conn, ~p"/members")
+
+ pick_field(view, field.id)
+
+ assert has_element?(view, "[data-testid='value-control-boolean'] input[value='true']")
+ assert has_element?(view, "[data-testid='value-control-boolean'] input[value='false']")
+ refute has_element?(view, "[data-testid='value-control-boolean'] input[value='all']")
+ end
+
+ test "the boolean control renders a check-icon Yes and an x-icon No join toggle", %{
+ conn: conn,
+ field: field
+ } do
+ {:ok, view, _html} = live(conn, ~p"/members")
+
+ pick_field(view, field.id)
+
+ # Join-toggle with a check icon on Yes and an x-mark icon on No (Task 5).
+ assert has_element?(view, "[data-testid='value-control-boolean'] .join .hero-check-circle")
+ assert has_element?(view, "[data-testid='value-control-boolean'] .join .hero-x-circle")
+ # A checked option shows a visible selected state (btn-primary via has-[:checked]).
+ assert has_element?(
+ view,
+ "[data-testid='value-control-boolean'] label.has-\\[\\:checked\\]\\:btn-primary"
+ )
+ end
+ end
+
+ describe "field picker search (§1.21)" do
+ test "the search input is a live type-ahead hook and narrows the field list", %{conn: conn} do
+ {:ok, view, _html} = live(conn, ~p"/members")
+
+ open_picker(view)
+
+ assert has_element?(view, "[data-testid='field-combobox'][phx-hook='FilterComboboxSearch']")
+ # The search input must live inside a form carrying the change binding —
+ # LiveView rejects `phx-change` on an input that is not inside a