Rework Filter using a filter builder #556

Open
simon wants to merge 11 commits from issue/mitgliederverwaltung-548 into issue/mitgliederverwaltung-547
65 changed files with 6588 additions and 3123 deletions

View file

@ -10,12 +10,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Members overview view settings** A new "View" dropdown switches the table between compact and comfortable density and controls how the Member and Address fields are shown: as combined composite cells or split into their underlying columns (Vorname/Nachname/E-Mail resp. Straße/PLZ/Ort). Every choice is remembered per browser.
- **Members overview reset columns to default** The column-visibility menu gained a reset button that restores the curated default column set.
- **Members overview add-filter builder** A searchable, grouped field picker adds filters as editable chips instead of a fixed panel; the active filters are encoded in the URL, so a filtered view is bookmarkable and survives a reload.
- **Members overview more filters** New filters for members active on a chosen date (point-in-time), members with suspended cycles in the period, matching any of several selected groups (OR), and a range on the number of open payment cycles.
- **Members overview payment aging column** The fees column shows how many billing cycles each member has left unpaid in the selected period (cycles are member-relative), is sortable by that count, carries a period indicator on its header, and lists the open — and separately, the suspended — cycles in a hover tooltip.
### Changed
- **Members overview loads on demand** The overview no longer reads every member into memory. Filtering, sorting and pagination now run in the database, and rows stream in via infinite scroll as you reach the bottom of the list, so large member lists open quickly. The exact number of matching members is announced (for example "250 Members").
- **Members overview condensed name and address** By default a member shows as a composite name cell and a two-line address cell (street / postal code + city); both columns are sortable and city and postal code stay findable via search. A clear (×) button was added to the search field.
- **Members overview bulk actions span the full result set** Select-all, copy e-mail addresses, and export now act on the complete filtered set rather than only the rows currently loaded, and the count reflects the full filtered total.
- **Members overview sticky header, pinned column and sortable headers** The table keeps a sticky header and pinned identifier column, sort headers that expose the active sort direction, boolean columns rendered as ✓/✗ icons, larger touch targets in both densities, and result-count and loading changes announced through a polite live region — meeting WCAG 2.2 AA for accessibility.
- **Members overview filter panel replaced** The vertical filter panel is replaced by the add-filter builder, and date ranges now use native date inputs with relative presets, with a quick year jump and manual entry.
### Fixed
- **Members overview keyboard-operable filter toggles** The filter builder's Yes/No and status toggles can now be reached and switched with the keyboard and show a visible focus indicator (WCAG 2.1.1 Keyboard, 2.4.7 Focus Visible).
## [1.3.0] - 2026-06-16

View file

@ -941,6 +941,21 @@
pointer-events: none;
}
/*
* Payment-badge tooltip. A native Popover-API element (opened by the
* HoverPopover hook), so it lives in the browser TOP LAYER and is not clipped by
* the members table's overflow. Placement is pure CSS anchor positioning: the
* badge carries `anchor-name`, this popover carries `position-anchor`, and
* `position-area` drops it just below the badge, left-aligned. A `position-try`
* fallback flips it above the badge when there is no room below. No JS
* coordinate math is involved (unlike the older SortTooltip).
*/
.payment-tip {
margin: 0;
position-area: bottom span-right;
position-try-fallbacks: flip-block;
}
/*
* Vertically center the row checkbox in the sticky first column using the
* table-native vertical-align: middle. This keeps display: table-cell intact so

View file

@ -22,6 +22,8 @@ import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar"
import Sortable from "../vendor/sortable"
import FilterComboboxSearch from "./hooks/filter_combobox_search"
import HoverPopover from "./hooks/hover_popover"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
@ -36,6 +38,11 @@ function getBrowserTimezone() {
// Hooks for LiveView components
let Hooks = {}
// Add-filter builder hooks (member overview field-picker search).
Hooks.FilterComboboxSearch = FilterComboboxSearch
// Top-layer hover/focus popover (member overview payment badge tooltip).
Hooks.HoverPopover = HoverPopover
// IndeterminateCheckbox: the `indeterminate` state of a checkbox is a JS
// property, not an HTML attribute, so it cannot be set from the server render.
// Mirror it from the data-indeterminate attribute on mount and every update

View file

@ -0,0 +1,33 @@
// FilterComboboxSearch hook: the type-ahead search input of the add-filter
// field picker (§1.21).
//
// Responsibilities:
// * Autofocus the input when the picker opens, so the user can type
// immediately (the cursor lands in the search field on mount). Programmatic
// .focus() does NOT trigger the browser's :focus-visible state, so no focus
// ring is drawn on open — the ring appears only during keyboard navigation,
// which is exactly the desired behaviour (focus-visible on keyboard only).
// * Guard Enter: while the listbox is open, Enter must not submit the
// surrounding form (this replaces the old standalone ComboBox hook).
//
// The actual field-list filtering is done server-side (phx-change pushes the
// query; the component narrows the descriptor list), so this hook only manages
// focus and the Enter guard.
const FilterComboboxSearch = {
mounted() {
// Defer to the next frame so focus lands after the dropdown is laid out.
requestAnimationFrame(() => this.el.focus())
this.handleKeyDown = (e) => {
const open = this.el.getAttribute("aria-expanded") === "true"
if (e.key === "Enter" && open) e.preventDefault()
}
this.el.addEventListener("keydown", this.handleKeyDown)
},
destroyed() {
this.el.removeEventListener("keydown", this.handleKeyDown)
}
}
export default FilterComboboxSearch

View file

@ -0,0 +1,76 @@
// HoverPopover hook: opens a native Popover-API element on hover/keyboard focus
// of its trigger, so the popover renders in the browser TOP LAYER and escapes
// any ancestor `overflow` clipping (the members table forces overflow-y:auto,
// which clips an absolutely-positioned daisyUI tooltip/dropdown-content). Unlike
// the older SortTooltip, placement is handed to CSS anchor positioning
// (`anchor-name` on the trigger, `position-anchor`/`position-area` on the
// popover) — this hook does no coordinate math, it only toggles visibility.
//
// WCAG 2.2 AA / SC 1.4.13 (Content on Hover or Focus): the popover is shown on
// both hover and focus, is dismissible via Escape, and is hoverable/persistent —
// a short close delay plus the popover's own hover keep it open while the pointer
// travels from the trigger onto the popover content.
//
// Expected DOM:
// <a phx-hook="HoverPopover" data-popover-target="tip-id" style="anchor-name:--x">…</a>
// <div id="tip-id" popover style="position-anchor:--x">…</div>
const HoverPopover = {
mounted() {
this.tip = document.getElementById(this.el.dataset.popoverTarget)
// Feature-detect: without Popover API support fall back to no visual popover
// (the trigger's aria-label still conveys the summary to assistive tech).
if (!this.tip || typeof this.tip.showPopover !== "function") return
this.open = () => {
try {
if (!this.tip.matches(":popover-open")) this.tip.showPopover()
} catch (_e) {}
}
this.close = () => {
try {
if (this.tip.matches(":popover-open")) this.tip.hidePopover()
} catch (_e) {}
}
this.cancelClose = () => {
if (this.closeTimer) {
clearTimeout(this.closeTimer)
this.closeTimer = null
}
}
this.show = () => {
this.cancelClose()
this.open()
}
this.scheduleClose = () => {
this.cancelClose()
this.closeTimer = setTimeout(() => this.close(), 120)
}
this.onKey = (e) => {
if (e.key === "Escape") this.close()
}
this.el.addEventListener("mouseenter", this.show)
this.el.addEventListener("focus", this.show)
this.el.addEventListener("mouseleave", this.scheduleClose)
this.el.addEventListener("blur", this.scheduleClose)
this.el.addEventListener("keydown", this.onKey)
// Keep it open while the pointer is over the popover itself (hoverable).
this.tip.addEventListener("mouseenter", this.show)
this.tip.addEventListener("mouseleave", this.scheduleClose)
},
destroyed() {
if (!this.tip) return
this.cancelClose()
this.close()
this.el.removeEventListener("mouseenter", this.show)
this.el.removeEventListener("focus", this.show)
this.el.removeEventListener("mouseleave", this.scheduleClose)
this.el.removeEventListener("blur", this.scheduleClose)
this.el.removeEventListener("keydown", this.onKey)
this.tip.removeEventListener("mouseenter", this.show)
this.tip.removeEventListener("mouseleave", this.scheduleClose)
}
}
export default HoverPopover

View file

@ -717,6 +717,25 @@ defmodule Mv.Membership.Member do
constraints one_of: [:unpaid, :paid, :suspended]
end
calculate :unpaid_cycle_count,
:integer,
expr(
count(membership_fee_cycles,
query: [
filter:
expr(
status == :unpaid and
(is_nil(^arg(:period_from)) or cycle_end >= ^arg(:period_from)) and
(is_nil(^arg(:period_to)) or cycle_end <= ^arg(:period_to))
)
]
)
) do
description "Count of the member's unpaid cycles whose cycle_end is within the given period"
argument :period_from, :date, allow_nil?: true
argument :period_to, :date, allow_nil?: true
end
calculate :overdue_count, :integer do
description "Count of unpaid cycles that have already ended (cycle_end < today)"
# Automatically load cycles with all attributes and membership_fee_type

View file

@ -36,6 +36,16 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
postgres do
table "membership_fee_cycles"
repo Mv.Repo
custom_indexes do
# Serves the period-scoped unpaid-cycle count on the member overview:
# count of a member's cycles with status = :unpaid and cycle_end in the
# active period. The composite (member_id, status, cycle_end) lets the
# aggregate seek the per-member unpaid slice and range-scan cycle_end
# without touching paid/suspended rows.
index [:member_id, :status, :cycle_end],
name: "membership_fee_cycles_member_status_end_index"
end
end
resource do

View file

@ -38,6 +38,20 @@ defmodule Mv.Constants do
@custom_date_filter_prefix "cdf_"
@payment_period_from_param "pay_from"
@payment_period_to_param "pay_to"
@payment_filter_param "pay_filter"
@payment_count_min_param "pay_min"
@payment_count_max_param "pay_max"
@suspended_param "suspended"
@as_of_date_param "as_of_date"
@max_boolean_filters 50
@max_mailto_bulk_recipients 50
@ -162,6 +176,81 @@ defmodule Mv.Constants do
"""
def custom_date_filter_prefix, do: @custom_date_filter_prefix
@doc """
Returns the URL parameter name for the payment-aging period lower bound
(ISO-8601 date; scopes both the payment column and the payment filter).
## Examples
iex> Mv.Constants.payment_period_from_param()
"pay_from"
"""
def payment_period_from_param, do: @payment_period_from_param
@doc """
Returns the URL parameter name for the payment-aging period upper bound.
## Examples
iex> Mv.Constants.payment_period_to_param()
"pay_to"
"""
def payment_period_to_param, do: @payment_period_to_param
@doc """
Returns the URL parameter name for the payment-count filter
(`fully_paid` | `unpaid_1` | `unpaid_2` | `unpaid_3`).
## Examples
iex> Mv.Constants.payment_filter_param()
"pay_filter"
"""
def payment_filter_param, do: @payment_filter_param
@doc """
Returns the URL parameter name for the payment open-cycle count lower bound
(used with `pay_filter=has_unpaid` for the two-sided count range).
## Examples
iex> Mv.Constants.payment_count_min_param()
"pay_min"
"""
def payment_count_min_param, do: @payment_count_min_param
@doc """
Returns the URL parameter name for the payment open-cycle count upper bound.
## Examples
iex> Mv.Constants.payment_count_max_param()
"pay_max"
"""
def payment_count_max_param, do: @payment_count_max_param
@doc """
Returns the URL parameter name for the suspended-status filter flag.
## Examples
iex> Mv.Constants.suspended_param()
"suspended"
"""
def suspended_param, do: @suspended_param
@doc """
Returns the URL parameter name for the point-in-time "active on X" membership
filter: keeps members who were active on the given ISO-8601 date X, i.e.
`join_date <= X and (exit_date is nil or exit_date > X)`.
## Examples
iex> Mv.Constants.as_of_date_param()
"as_of_date"
"""
def as_of_date_param, do: @as_of_date_param
@doc """
Returns the maximum number of boolean custom field filters allowed per request.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -24,14 +24,14 @@ defmodule MvWeb.MemberLive.Index do
- `select_row_and_navigate` - open a member's show page
- `load_more` - fetch and append the next keyset page (infinite scroll)
- `sort` - change the active sort column/direction
- `toggle_cycle_view` - switch the payment-status view between current and last cycle
- `copy_emails` - copy emails of the selected members, or of the whole filtered set when none are selected
## Messages (`handle_info`, from the filter/search/view-settings components)
- `search_changed`, `view_setting_toggled`, `field_toggled`, `fields_selected`,
`fields_reset`, `reset_all_filters`, and the per-filter change messages
(`group_filter_changed`, `fee_type_filter_changed`, `boolean_filter_changed`,
`date_filters_changed`, `payment_filter_changed`)
`fields_reset`, `reset_all_filters`, `clear_all_filters`, and the per-filter
change messages (`group_filter_changed`, `fee_type_filter_changed`,
`boolean_filter_changed`, `date_filters_changed`, `payment_filter_changed`,
`payment_period_changed`)
## Implementation Notes
- Filtering/sorting/pagination run in PostgreSQL via Ash; the socket holds only
@ -49,6 +49,8 @@ defmodule MvWeb.MemberLive.Index do
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.DateFormatter
alias MvWeb.Helpers.MembershipFeeHelpers
alias MvWeb.MemberLive.Index.AsOfDate
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
alias MvWeb.MemberLive.Index.DateFilter
alias MvWeb.MemberLive.Index.ExportPayload
@ -58,6 +60,7 @@ defmodule MvWeb.MemberLive.Index do
alias MvWeb.MemberLive.Index.Formatter
alias MvWeb.MemberLive.Index.MembershipFeeStatus
alias MvWeb.MemberLive.Index.OverviewQuery
alias MvWeb.MemberLive.Index.PaymentAging
alias MvWeb.MemberLive.Index.ViewSettings
require Ash.Query
@ -174,7 +177,10 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:query, "")
|> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end)
|> assign(:cycle_status_filter, nil)
|> assign(:payment_filter, nil)
|> assign(:payment_period, PaymentAging.default_period())
|> assign(:suspended, false)
|> assign(:as_of_date, nil)
|> assign(:group_filters, %{})
|> assign(:groups, groups)
|> assign(:fee_type_filters, %{})
@ -203,8 +209,6 @@ defmodule MvWeb.MemberLive.Index do
:member_fields_visible_computed,
FieldVisibility.get_visible_member_fields_computed(initial_selection)
)
|> assign(:show_current_cycle, false)
|> assign(:membership_fee_status_filter, nil)
|> assign(:page, nil)
|> assign(:after_cursor, nil)
|> assign(:more?, false)
@ -309,29 +313,6 @@ defmodule MvWeb.MemberLive.Index do
|> update_selection_assigns()}
end
@impl true
def handle_event("toggle_cycle_view", _params, socket) do
new_show_current = !socket.assigns.show_current_cycle
socket =
socket
|> assign(:show_current_cycle, new_show_current)
|> load_members()
|> scroll_list_to_top()
|> update_selection_assigns()
query_params =
build_query_params(opts_for_query_params(socket, %{show_current_cycle: new_show_current}))
|> maybe_add_field_selection(
socket.assigns[:user_field_selection],
socket.assigns[:fields_in_url?] || false
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_reload(socket, new_path)}
end
@impl true
def handle_event("copy_emails", _params, socket) do
# Recipients follow the current scope, re-queried from the DB so a no-selection
@ -449,13 +430,91 @@ defmodule MvWeb.MemberLive.Index do
def handle_info({:payment_filter_changed, filter}, socket) do
socket =
socket
|> assign(:cycle_status_filter, filter)
|> assign(:payment_filter, filter)
|> load_members()
|> scroll_list_to_top()
|> update_selection_assigns()
query_params =
build_query_params(opts_for_query_params(socket, %{cycle_status_filter: filter}))
build_query_params(opts_for_query_params(socket, %{payment_filter: filter}))
|> maybe_add_field_selection(
socket.assigns[:user_field_selection],
socket.assigns[:fields_in_url?] || false
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_reload(socket, new_path)}
end
@impl true
def handle_info({:payment_period_changed, period}, socket) do
socket =
socket
|> assign(:payment_period, period)
|> load_members()
|> scroll_list_to_top()
|> update_selection_assigns()
query_params =
build_query_params(opts_for_query_params(socket, %{payment_period: period}))
|> maybe_add_field_selection(
socket.assigns[:user_field_selection],
socket.assigns[:fields_in_url?] || false
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_reload(socket, new_path)}
end
@impl true
def handle_info({:clear_all_filters}, socket) do
handle_info(
{:reset_all_filters,
%{
payment_filter: nil,
payment_period: PaymentAging.default_period(),
suspended: false,
as_of_date: nil,
group_filters: %{},
fee_type_filters: %{},
boolean_filters: %{},
date_filters: DateFilter.default()
}},
socket
)
end
@impl true
def handle_info({:suspended_changed, suspended}, socket) do
socket =
socket
|> assign(:suspended, suspended)
|> load_members()
|> scroll_list_to_top()
|> update_selection_assigns()
query_params =
build_query_params(opts_for_query_params(socket, %{suspended: suspended}))
|> maybe_add_field_selection(
socket.assigns[:user_field_selection],
socket.assigns[:fields_in_url?] || false
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_reload(socket, new_path)}
end
@impl true
def handle_info({:as_of_date_changed, as_of_date}, socket) do
socket =
socket
|> assign(:as_of_date, as_of_date)
|> load_members()
|> scroll_list_to_top()
|> update_selection_assigns()
query_params =
build_query_params(opts_for_query_params(socket, %{as_of_date: as_of_date}))
|> maybe_add_field_selection(
socket.assigns[:user_field_selection],
socket.assigns[:fields_in_url?] || false
@ -573,11 +632,14 @@ defmodule MvWeb.MemberLive.Index do
def handle_info({:reset_all_filters, %{} = opts}, socket) do
socket =
socket
|> assign(:cycle_status_filter, Map.get(opts, :cycle_status_filter))
|> assign(:payment_filter, Map.get(opts, :payment_filter))
|> assign(:payment_period, Map.get(opts, :payment_period, PaymentAging.default_period()))
|> assign(:group_filters, Map.get(opts, :group_filters, %{}))
|> assign(:fee_type_filters, Map.get(opts, :fee_type_filters, %{}))
|> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{}))
|> assign(:date_filters, Map.get(opts, :date_filters, DateFilter.default()))
|> assign(:suspended, Map.get(opts, :suspended, false))
|> assign(:as_of_date, Map.get(opts, :as_of_date))
|> load_members()
|> scroll_list_to_top()
|> update_selection_assigns()
@ -663,12 +725,14 @@ defmodule MvWeb.MemberLive.Index do
socket
|> maybe_update_search(params)
|> maybe_update_sort(params)
|> maybe_update_cycle_status_filter(params)
|> maybe_update_payment_filter(params)
|> maybe_update_payment_period(params)
|> maybe_update_suspended(params)
|> maybe_update_as_of_date(params)
|> maybe_update_group_filters(params)
|> maybe_update_fee_type_filters(params)
|> maybe_update_boolean_filters(params)
|> maybe_update_date_filters(params)
|> maybe_update_show_current_cycle(params)
|> assign(:fields_in_url?, fields_in_url?)
|> assign(:query, params["query"])
|> assign_visibility_derivations(final_selection)
@ -712,10 +776,12 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.query,
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
socket.assigns.payment_filter,
socket.assigns.payment_period,
socket.assigns[:suspended],
socket.assigns[:as_of_date],
socket.assigns[:group_filters],
socket.assigns[:fee_type_filters],
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters,
socket.assigns.user_field_selection,
socket.assigns[:visible_custom_field_ids] || [],
@ -866,14 +932,30 @@ defmodule MvWeb.MemberLive.Index do
defp build_query_params(opts) when is_map(opts) do
base_params = build_base_params(opts.query, opts.sort_field, opts.sort_order)
base_params = add_cycle_status_filter(base_params, opts.cycle_status_filter)
base_params = add_group_filters(base_params, opts.group_filters || %{})
base_params = add_fee_type_filters(base_params, opts.fee_type_filters || %{})
base_params = add_show_current_cycle(base_params, opts.show_current_cycle)
base_params = add_payment_params(base_params, opts.payment_filter, opts.payment_period)
base_params = add_status_params(base_params, opts[:suspended], opts[:as_of_date])
base_params = add_boolean_filters(base_params, opts.boolean_filters || %{})
add_date_filters(base_params, opts.date_filters)
end
# Suspended-status flag and as-of date serialize additively into the flat
# URL contract (suspended=1 / as_of_date=<iso>), omitted when inactive (§2.1).
defp add_status_params(params, suspended, as_of_date) do
params
|> Map.merge(PaymentAging.suspended_to_params(suspended))
|> Map.merge(AsOfDate.to_params(as_of_date))
end
# Period-scoped payment model (§3.3): the payment-count filter and the active
# period both serialize into the flat URL contract (pay_filter/pay_from/pay_to).
defp add_payment_params(params, payment_filter, payment_period) do
params
|> Map.merge(PaymentAging.filter_to_params(payment_filter))
|> Map.merge(PaymentAging.to_params(payment_period || PaymentAging.default_period()))
end
defp add_date_filters(params, date_filters) do
Map.merge(params, DateFilter.to_params(date_filters))
end
@ -883,9 +965,11 @@ defmodule MvWeb.MemberLive.Index do
query: socket.assigns.query,
sort_field: socket.assigns.sort_field,
sort_order: socket.assigns.sort_order,
cycle_status_filter: socket.assigns.cycle_status_filter,
payment_filter: socket.assigns.payment_filter,
payment_period: socket.assigns.payment_period,
suspended: socket.assigns[:suspended] || false,
as_of_date: socket.assigns[:as_of_date],
group_filters: socket.assigns[:group_filters] || %{},
show_current_cycle: socket.assigns.show_current_cycle,
boolean_filters: socket.assigns.boolean_custom_field_filters || %{},
fee_type_filters: socket.assigns[:fee_type_filters] || %{},
date_filters: socket.assigns.date_filters
@ -1187,17 +1271,6 @@ defmodule MvWeb.MemberLive.Index do
end)
end
defp add_cycle_status_filter(params, nil), do: params
defp add_cycle_status_filter(params, :paid), do: Map.put(params, "cycle_status_filter", "paid")
defp add_cycle_status_filter(params, :unpaid),
do: Map.put(params, "cycle_status_filter", "unpaid")
defp add_cycle_status_filter(params, _), do: params
defp add_show_current_cycle(params, true), do: Map.put(params, "show_current_cycle", "true")
defp add_show_current_cycle(params, _), do: params
defp add_boolean_filters(params, boolean_filters) do
Enum.reduce(boolean_filters, params, &add_boolean_filter/2)
end
@ -1319,10 +1392,19 @@ defmodule MvWeb.MemberLive.Index do
|> Ash.Query.select(@overview_fields)
|> load_custom_field_values(compute_ids_to_load(socket))
|> MembershipFeeStatus.load_cycles_for_members()
|> load_unpaid_cycle_count(socket)
|> Ash.Query.load(groups: [:id, :name, :slug])
|> maybe_load_fee_type(socket)
end
# Loads the period-scoped unpaid-cycle count for the overview payment column
# (§1.13, §3.3). The count is a DB aggregate over `membership_fee_cycles`
# (denormalized `cycle_end`), scoped to the active period.
defp load_unpaid_cycle_count(query, socket) do
%{from: from, to: to} = socket.assigns.payment_period || PaymentAging.default_period()
Ash.Query.load(query, unpaid_cycle_count: %{period_from: from, period_to: to})
end
defp maybe_load_fee_type(query, socket) do
if :membership_fee_type in socket.assigns.member_fields_visible or
socket.assigns.sort_field in [:membership_fee_type, "membership_fee_type"] do
@ -1345,8 +1427,10 @@ defmodule MvWeb.MemberLive.Index do
boolean_custom_fields: socket.assigns.boolean_custom_fields,
date_filters: socket.assigns.date_filters,
date_custom_fields: socket.assigns[:date_custom_fields],
cycle_status_filter: socket.assigns.cycle_status_filter,
show_current_cycle: socket.assigns.show_current_cycle,
payment_filter: socket.assigns.payment_filter,
payment_period: socket.assigns.payment_period,
suspended: socket.assigns[:suspended] || false,
as_of_date: socket.assigns[:as_of_date],
sort_field: socket.assigns.sort_field,
sort_order: socket.assigns.sort_order,
custom_fields: socket.assigns.all_custom_fields
@ -1429,7 +1513,7 @@ defmodule MvWeb.MemberLive.Index do
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
field in valid_fields or custom_field_sort?(field) or
field in [:groups, :membership_fee_type, :name, :address]
field in [:groups, :membership_fee_type, :name, :address, :payment]
end
defp valid_sort_field_db_or_custom?(field) when is_binary(field) do
@ -1439,6 +1523,7 @@ defmodule MvWeb.MemberLive.Index do
field == "membership_fee_type" -> :membership_fee_type
field == "name" -> :name
field == "address" -> :address
field == "payment" -> :payment
true -> safe_member_field_atom_only(field)
end
@ -1488,9 +1573,10 @@ defmodule MvWeb.MemberLive.Index do
defp determine_field(default, nil), do: default
# Computed/pseudo fields that are nonetheless sortable (they resolve to DB
# sort keys in OverviewQuery): groups aggregate and the composite Name/Address
# columns. Other computed fields (e.g. membership_fee_status) are not sortable.
@sortable_computed_fields [:groups, :name, :address]
# sort keys in OverviewQuery): groups aggregate, the composite Name/Address
# columns, and the period-scoped payment (unpaid-cycle-count) column. Other
# computed fields (e.g. membership_fee_status) are not sortable.
@sortable_computed_fields [:groups, :name, :address, :payment]
defp determine_field(default, sf) when is_binary(sf) do
sortable_strings = Enum.map(@sortable_computed_fields, &Atom.to_string/1)
@ -1550,13 +1636,23 @@ defmodule MvWeb.MemberLive.Index do
defp maybe_update_search(socket, _params), do: socket
defp maybe_update_cycle_status_filter(socket, %{"cycle_status_filter" => filter_str}) do
filter = determine_cycle_status_filter(filter_str)
assign(socket, :cycle_status_filter, filter)
end
# Period-scoped payment model (§3.3): a payment-count filter plus the active
# period, both driven purely from the URL so `handle_params` stays the source
# of truth. Absent params fall back to no filter / the all-outstanding period.
defp maybe_update_payment_filter(socket, params),
do: assign(socket, :payment_filter, PaymentAging.parse_filter_params(params))
defp maybe_update_cycle_status_filter(socket, _params),
do: assign(socket, :cycle_status_filter, nil)
defp maybe_update_payment_period(socket, params),
do: assign(socket, :payment_period, PaymentAging.parse_period(params))
# Suspended-status filter (§1.23) and point-in-time as-of date (§1.24/§2.6):
# both decoded straight from the URL so `handle_params` remains the single
# source of truth. Absent params fall back to no filter.
defp maybe_update_suspended(socket, params),
do: assign(socket, :suspended, PaymentAging.parse_suspended(params))
defp maybe_update_as_of_date(socket, params),
do: assign(socket, :as_of_date, AsOfDate.parse(params))
defp maybe_update_group_filters(socket, params) when is_map(params) do
prefix = @group_filter_prefix
@ -1652,10 +1748,6 @@ defmodule MvWeb.MemberLive.Index do
defp normalize_uuid_string(_), do: nil
defp determine_cycle_status_filter("paid"), do: :paid
defp determine_cycle_status_filter("unpaid"), do: :unpaid
defp determine_cycle_status_filter(_), do: nil
defp maybe_update_boolean_filters(socket, params) do
boolean_custom_fields =
socket.assigns.all_custom_fields
@ -1728,11 +1820,6 @@ defmodule MvWeb.MemberLive.Index do
defp determine_boolean_filter("false"), do: false
defp determine_boolean_filter(_), do: nil
defp maybe_update_show_current_cycle(socket, %{"show_current_cycle" => "true"}),
do: assign(socket, :show_current_cycle, true)
defp maybe_update_show_current_cycle(socket, _params), do: socket
# URL params are the source of truth for filter state on every navigation.
# When no date filter params are present, this falls through to the
# active_only default — exactly the spec behavior for fresh load (§1.1).
@ -1820,6 +1907,22 @@ defmodule MvWeb.MemberLive.Index do
def format_date(date), do: DateFormatter.format_date(date)
@doc """
The payment column header, naming the active aging period (§1.17). The
all-outstanding default (both bounds nil) reads just "Payment"; a bounded
period appends the range so the scope is always legible.
"""
def payment_column_label(_period), do: gettext("Fees")
@doc """
Whether a payment period is actively scoped (at least one bound set), used to
decide whether the compact period indicator badge renders (§1.28). The
all-outstanding default (both bounds nil) shows no badge.
"""
def payment_period_active?(%{from: nil, to: nil}), do: false
def payment_period_active?(%{from: _, to: _}), do: true
def payment_period_active?(_), do: false
defp update_selection_assigns(socket) do
selected_members = socket.assigns.selected_members
# The selection may span members beyond the loaded page (after select-all),
@ -1939,7 +2042,7 @@ defmodule MvWeb.MemberLive.Index do
end
defp selection_filters_active?(assigns) do
not is_nil(assigns[:cycle_status_filter]) or
not is_nil(assigns[:payment_filter]) or
map_size(assigns[:group_filters] || %{}) > 0 or
map_size(assigns[:fee_type_filters] || %{}) > 0 or
map_size(assigns[:boolean_custom_field_filters] || %{}) > 0

View file

@ -21,6 +21,9 @@
</:actions>
</.header>
<%!-- Two-row overview toolbar (Option C): row 1 is search + column/view
controls; row 2 is the dedicated filter row (add-filter + applied chips). --%>
<div class="flex flex-col gap-3">
<div class="flex flex-wrap gap-4 items-center">
<.live_component
module={MvWeb.Components.SearchBarComponent}
@ -28,52 +31,6 @@
query={@query}
placeholder={gettext("Search...")}
/>
<.live_component
module={MvWeb.Components.MemberFilterComponent}
id="member-filter"
cycle_status_filter={@cycle_status_filter}
groups={@groups}
group_filters={@group_filters}
fee_types={@fee_types}
fee_type_filters={@fee_type_filters}
boolean_custom_fields={@boolean_custom_fields}
boolean_filters={@boolean_custom_field_filters}
date_custom_fields={@date_custom_fields}
date_filters={@date_filters}
member_count={@total_count}
/>
<.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" />
<span class="hidden sm:inline">
{if(@show_current_cycle,
do: gettext("Current payment cycle"),
else: gettext("Last payment cycle")
)}
</span>
</.button>
</.tooltip>
<div class="ml-auto flex items-center gap-4">
<.live_component
module={MvWeb.Components.FieldVisibilityDropdownComponent}
@ -93,6 +50,24 @@
</div>
</div>
<.live_component
module={MvWeb.Components.AddFilterBuilderComponent}
id="member-filter"
groups={@groups}
group_filters={@group_filters}
fee_types={@fee_types}
fee_type_filters={@fee_type_filters}
boolean_custom_fields={@boolean_custom_fields}
boolean_filters={@boolean_custom_field_filters}
date_custom_fields={@date_custom_fields}
date_filters={@date_filters}
payment_filter={@payment_filter}
payment_period={@payment_period}
suspended={@suspended}
as_of_date={@as_of_date}
/>
</div>
<%!-- Polite live region: present on first render (before it is filled) so
screen readers announce the exact total matching count on every filter change
(WCAG 4.1.3). --%>
@ -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"""
<div class="flex items-center gap-1.5">
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_payment}
field={:payment}
label={MvWeb.MemberLive.Index.payment_column_label(@payment_period)}
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")}
</.badge>
</div>
"""
}
>
<% 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"
>
<%= 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}
</.link>
<%= if count > 0 do %>
<% %{unpaid: unpaid, suspended: suspended} =
PaymentAging.partition_open_cycles(member, @payment_period) %>
<% {shown, overflow} = PaymentAging.tooltip_cycles(unpaid) %>
<div
id={tip_id}
popover="manual"
role="tooltip"
style={"position-anchor: #{anchor}"}
class="payment-tip m-0 w-fit rounded-box border border-base-300 bg-base-100 p-2 text-sm shadow-xl"
data-testid="payment-tooltip"
>
<ul
class="m-0 flex list-none flex-col items-stretch gap-1 p-0"
data-testid="payment-tooltip-unpaid"
>
<li :for={cycle <- shown} class="flex">
<.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" />
</:icon>
{PaymentAging.short_period(cycle)}
</.badge>
<% else %>
<.empty_cell sr_text={gettext("No cycle")} />
</li>
<li :if={overflow > 0} class="flex">
<span class="badge badge-soft badge-sm badge-ghost w-full justify-start">
<.icon name="hero-plus-circle" class="size-3.5" />
{gettext("%{count} more", count: overflow)}
</span>
</li>
</ul>
<%!-- Suspended cycles are shown separately under their own label so
the status distinction is not color-only (§1.14). --%>
<div :if={suspended != []} class="mt-2" data-testid="payment-tooltip-suspended">
<p class="mb-1 text-xs font-medium opacity-70">{gettext("Suspended")}</p>
<ul class="m-0 flex list-none flex-col items-stretch gap-1 p-0">
<li :for={cycle <- suspended} class="flex">
<.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"
/>
</:icon>
{PaymentAging.short_period(cycle)}
</.badge>
</li>
</ul>
</div>
</div>
<% end %>
</:col>
<:col

View file

@ -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

View file

@ -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.

View file

@ -0,0 +1,70 @@
defmodule MvWeb.MemberLive.Index.DatePresets do
@moduledoc """
Relative date-range presets for the date value controls (§1.25/§2.8).
Each preset resolves against a reference date "today" into a concrete
`%{from: Date.t(), to: Date.t()}` range. Rolling "last N days/months" presets
end on today; the "this month/quarter/year" presets run period-to-date from
the respective period start up to today. Resolving is pure and clock-free
the caller passes `today` (defaulting to `Date.utc_today/0`), so the resolved
bounds are testable and reproducible.
The resolved bounds feed the same `jd_*` / `ed_*` / `cdf_*` URL params as an
absolute custom range; presets are purely an input convenience and add no new
persistence dimension.
"""
@type preset ::
:last_7_days
| :last_30_days
| :last_3_months
| :last_12_months
| :this_month
| :this_quarter
| :this_year
@type range :: %{from: Date.t(), to: Date.t()}
@presets [
:last_7_days,
:last_30_days,
:last_3_months,
:last_12_months,
:this_month,
:this_quarter,
:this_year
]
@doc """
Returns every preset key in display order (rolling windows first, then the
period-to-date presets), for rendering the preset radio list.
"""
@spec all() :: [preset(), ...]
def all, do: @presets
@doc """
Whether `key` names a known preset.
"""
@spec preset?(term()) :: boolean()
def preset?(key), do: key in @presets
@doc """
Resolves a preset to a concrete `{from, to}` range relative to `today`
(default `Date.utc_today/0`).
"""
@spec resolve(preset(), Date.t()) :: range()
def resolve(preset, today \\ Date.utc_today())
def resolve(:last_7_days, today), do: %{from: Date.add(today, -6), to: today}
def resolve(:last_30_days, today), do: %{from: Date.add(today, -29), to: today}
def resolve(:last_3_months, today), do: %{from: Date.shift(today, month: -3), to: today}
def resolve(:last_12_months, today), do: %{from: Date.shift(today, month: -12), to: today}
def resolve(:this_month, today), do: %{from: %{today | day: 1}, to: today}
def resolve(:this_quarter, today) do
first_month = div(today.month - 1, 3) * 3 + 1
%{from: Date.new!(today.year, first_month, 1), to: today}
end
def resolve(:this_year, today), do: %{from: Date.new!(today.year, 1, 1), to: today}
end

View file

@ -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 || []

View file

@ -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

View file

@ -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}])

View file

@ -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.yyyydd.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

View file

@ -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)}

View file

@ -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"

View file

@ -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 ""

View file

@ -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 ""

View file

@ -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

View file

@ -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"
}

View file

@ -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 `<input>` 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
# `<fieldset class="mb-2 fieldset">` `<label>` chain immediately
# preceding the `<input>`. The raw inline form has no such wrapper —
# the input sits directly inside a sibling `<label>`/`<input>` 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 `<fieldset class="mb-2 fieldset">` followed (within a short
# window of HTML) by an `<input>` 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 `<fieldset class="mb-2 fieldset">`).
assert Regex.match?(
~r/<fieldset[^>]*class="mb-2 fieldset"[^>]*>\s*<label[^>]*>(?:\s*<span[^>]*>.*?<\/span>)?\s*<input[^>]*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 " <>
"<input type=\"date\"> 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

View file

@ -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(),

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,66 @@
defmodule MvWeb.MemberLive.IndexDatePresetsPropertyTest do
@moduledoc """
§2.8 Relative date-range preset resolution.
For any preset evaluated against any reference date "today", the resolved
`{from, to}` matches the preset's definition: rolling "last N days" windows end
on today, and the "this month/quarter/year" presets run period-to-date from the
respective period start to today.
"""
use ExUnit.Case, async: true
use ExUnitProperties
alias MvWeb.MemberLive.Index.DatePresets
defp date_gen do
gen all(
year <- StreamData.integer(2000..2100),
month <- StreamData.integer(1..12),
day <- StreamData.integer(1..28)
) do
Date.new!(year, month, day)
end
end
property "last-N-days presets end on today and span N days inclusive" do
check all(today <- date_gen()) do
assert DatePresets.resolve(:last_7_days, today) == %{from: Date.add(today, -6), to: today}
assert DatePresets.resolve(:last_30_days, today) == %{from: Date.add(today, -29), to: today}
end
end
property "last-N-months presets shift the start back N months and end on today" do
check all(today <- date_gen()) do
assert DatePresets.resolve(:last_3_months, today) ==
%{from: Date.shift(today, month: -3), to: today}
assert DatePresets.resolve(:last_12_months, today) ==
%{from: Date.shift(today, month: -12), to: today}
end
end
property "this-period presets run from the period start to today" do
check all(today <- date_gen()) do
month_start = %{today | day: 1}
q_first_month = div(today.month - 1, 3) * 3 + 1
quarter_start = Date.new!(today.year, q_first_month, 1)
year_start = Date.new!(today.year, 1, 1)
assert DatePresets.resolve(:this_month, today) == %{from: month_start, to: today}
assert DatePresets.resolve(:this_quarter, today) == %{from: quarter_start, to: today}
assert DatePresets.resolve(:this_year, today) == %{from: year_start, to: today}
end
end
test "all/0 lists every resolvable preset key in display order" do
assert DatePresets.all() == [
:last_7_days,
:last_30_days,
:last_3_months,
:last_12_months,
:this_month,
:this_quarter,
:this_year
]
end
end

View file

@ -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

View file

@ -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 `<input type="date">` 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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),

View file

@ -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

View file

@ -0,0 +1,38 @@
defmodule MvWeb.MemberLive.IndexFilterUrlRoundtripTest do
@moduledoc """
§2.1 (extended) the v2 filter keys survive the LiveView decodeencode 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

View file

@ -0,0 +1,72 @@
defmodule MvWeb.MemberLive.IndexGroupOrTest do
@moduledoc """
§1.29 / §2.7 Multi-select group semantics is OR.
Two "is" group selections match members in *either* group (union). Two
"is not" selections exclude members in *any* of them. Verified at the
`OverviewQuery` layer where the semantics live.
"""
use Mv.DataCase, async: false
import Mv.Fixtures, only: [member_fixture_with_actor: 2, group_fixture: 1]
alias Mv.Membership.Member
alias Mv.Membership.MemberGroup
alias MvWeb.MemberLive.Index.OverviewQuery
setup do
actor = Mv.Helpers.SystemActor.get_system_actor()
Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor))
g1 = group_fixture(%{name: "G1-#{System.unique_integer([:positive])}"})
g2 = group_fixture(%{name: "G2-#{System.unique_integer([:positive])}"})
m1 = member_fixture_with_actor(%{}, actor)
m2 = member_fixture_with_actor(%{}, actor)
m3 = member_fixture_with_actor(%{}, actor)
add_to_group(m1, g1, actor)
add_to_group(m2, g2, actor)
%{actor: actor, g1: g1, g2: g2, m1: m1, m2: m2, m3: m3}
end
defp add_to_group(member, group, actor) do
MemberGroup
|> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id})
|> Ash.create!(actor: actor)
end
defp ids(opts, actor) do
opts
|> OverviewQuery.build()
|> Ash.read!(actor: actor)
|> MapSet.new(& &1.id)
end
test "two 'is' group selections match members in either group (OR)", ctx do
result =
ids(
%{
group_filters: %{to_string(ctx.g1.id) => :in, to_string(ctx.g2.id) => :in},
groups: [ctx.g1, ctx.g2]
},
ctx.actor
)
assert MapSet.equal?(result, MapSet.new([ctx.m1.id, ctx.m2.id]))
end
test "two 'is not' group selections exclude members in any of them", ctx do
result =
ids(
%{
group_filters: %{to_string(ctx.g1.id) => :not_in, to_string(ctx.g2.id) => :not_in},
groups: [ctx.g1, ctx.g2]
},
ctx.actor
)
assert MapSet.equal?(result, MapSet.new([ctx.m3.id]))
end
end

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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 <a> 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"
end
assert html =~ "HasUnpaid"
refute html =~ "FullyPaid"
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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,41 @@
defmodule MvWeb.MemberLive.IndexPaymentCountCodecTest do
@moduledoc """
§1.22 / §3.14 URL codec for the two-sided payment open-cycle count range.
The `{:unpaid_range, min, max}` filter round-trips through the flat URL
contract: encoding to params then decoding yields the canonical filter, with
the "has open" default (`min=1`, `max=nil`) and both-bounded ranges covered.
"""
use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.PaymentAging
defp roundtrip(filter),
do: filter |> PaymentAging.filter_to_params() |> PaymentAging.parse_filter_params()
test "default has-open range (min 1, unbounded above) round-trips" do
assert roundtrip({:unpaid_range, 1, nil}) == {:unpaid_range, 1, nil}
end
test "two-sided range round-trips" do
assert roundtrip({:unpaid_range, 2, 5}) == {:unpaid_range, 2, 5}
end
test "exact single-value range round-trips" do
assert roundtrip({:unpaid_range, 3, 3}) == {:unpaid_range, 3, 3}
end
test "has_unpaid params decode into an unpaid_range" do
params = %{"pay_filter" => "has_unpaid", "pay_min" => "2", "pay_max" => "4"}
assert PaymentAging.parse_filter_params(params) == {:unpaid_range, 2, 4}
end
test "has_unpaid with no bounds defaults to min 1, max nil" do
assert PaymentAging.parse_filter_params(%{"pay_filter" => "has_unpaid"}) ==
{:unpaid_range, 1, nil}
end
test "fully_paid still round-trips" do
assert roundtrip(:fully_paid) == :fully_paid
end
end

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,85 @@
defmodule MvWeb.MemberLive.IndexPaymentSortTest do
@moduledoc """
§1.27 The Beiträge (payment) column sorts DB-side by the period-scoped
unpaid-cycle count, ascending and descending, with the unique id tie-breaker
preserving keyset stability. Verified at the `OverviewQuery` layer.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4]
alias Mv.Membership.Member
alias MvWeb.MemberLive.Index.OverviewQuery
@period %{from: ~D[2024-01-01], to: ~D[2024-12-31]}
setup do
actor = Mv.Helpers.SystemActor.get_system_actor()
ft = create_fee_type(%{interval: :monthly}, actor)
Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor))
%{actor: actor, ft: ft}
end
defp member_with_unpaid(n, ctx) do
member = member_fixture_with_actor(%{}, ctx.actor)
Enum.each(1..max(n, 0)//1, fn i ->
create_cycle(
member,
ctx.ft,
%{cycle_start: Date.new!(2024, i, 1), status: :unpaid},
ctx.actor
)
end)
member
end
defp sorted_ids(order, ctx) do
%{sort_field: :payment, sort_order: order, payment_period: @period}
|> OverviewQuery.build()
|> Ash.read!(actor: ctx.actor)
|> Enum.map(& &1.id)
end
test "sorts ascending by in-period unpaid count", ctx do
zero = member_with_unpaid(0, ctx)
one = member_with_unpaid(1, ctx)
two = member_with_unpaid(2, ctx)
assert sorted_ids(:asc, ctx) == [zero.id, one.id, two.id]
end
test "sorts descending by in-period unpaid count", ctx do
zero = member_with_unpaid(0, ctx)
one = member_with_unpaid(1, ctx)
two = member_with_unpaid(2, ctx)
assert sorted_ids(:desc, ctx) == [two.id, one.id, zero.id]
end
describe "payment sort header (LiveView)" do
setup %{conn: conn} do
%{conn: conn_with_oidc_user(conn)}
end
@tag :ui
test "clicking the payment header reflects the sort in the URL", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
view |> element("[data-testid='payment']") |> render_click()
path = assert_patch(view)
assert path =~ "sort_field=payment"
assert path =~ "sort_order=asc"
end
@tag :ui
test "the payment th exposes aria-sort when active", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members?sort_field=payment&sort_order=desc")
assert has_element?(view, "th[aria-sort='descending']")
end
end
end

View file

@ -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

View file

@ -0,0 +1,40 @@
defmodule MvWeb.MemberLive.IndexStatusParamsCodecTest do
@moduledoc """
§2.1 (extended) URL codec round-trip for the new v2 filter keys: the
point-in-time 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

View file

@ -0,0 +1,58 @@
defmodule MvWeb.MemberLive.IndexSuspendedFilterTest do
@moduledoc """
§1.23 Filter by `suspended` status.
With the suspended filter active, the overview keeps members that have at
least one `suspended` cycle whose `cycle_end` falls in the active period.
Suspended is filterable as its own status, independent of the unpaid count
(which excludes suspended, §2.5). Verified at the `OverviewQuery` layer.
"""
use Mv.DataCase, async: false
import Mv.Fixtures, only: [member_fixture_with_actor: 2, create_fee_type: 2, create_cycle: 4]
alias Mv.Membership.Member
alias MvWeb.MemberLive.Index.OverviewQuery
setup do
actor = Mv.Helpers.SystemActor.get_system_actor()
Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor))
ft = create_fee_type(%{interval: :yearly}, actor)
%{actor: actor, ft: ft}
end
defp member_with_cycle(ctx, status, cycle_start) do
m = member_fixture_with_actor(%{}, ctx.actor)
create_cycle(m, ctx.ft, %{cycle_start: cycle_start, status: status}, ctx.actor)
m
end
defp ids(opts, actor) do
opts |> OverviewQuery.build() |> Ash.read!(actor: actor) |> MapSet.new(& &1.id)
end
test "keeps members with a suspended cycle whose cycle_end is in the period", ctx do
suspended_in = member_with_cycle(ctx, :suspended, ~D[2025-01-01])
_unpaid = member_with_cycle(ctx, :unpaid, ~D[2025-01-01])
_paid = member_with_cycle(ctx, :paid, ~D[2025-01-01])
_suspended_out = member_with_cycle(ctx, :suspended, ~D[2023-01-01])
result =
ids(
%{suspended: true, payment_period: %{from: ~D[2025-01-01], to: ~D[2025-12-31]}},
ctx.actor
)
assert MapSet.equal?(result, MapSet.new([suspended_in.id]))
end
test "all-time period keeps every member with any suspended cycle", ctx do
s1 = member_with_cycle(ctx, :suspended, ~D[2025-01-01])
s2 = member_with_cycle(ctx, :suspended, ~D[2023-01-01])
_unpaid = member_with_cycle(ctx, :unpaid, ~D[2025-01-01])
result = ids(%{suspended: true, payment_period: %{from: nil, to: nil}}, ctx.actor)
assert MapSet.equal?(result, MapSet.new([s1.id, s2.id]))
end
end

View file

@ -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_<uuid>)", %{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

View file

@ -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: <field>' 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 <form>
# ("form events require the input to be inside a form"), which silently
# broke the live filtering in the browser (§1.21).
assert has_element?(view, "form[phx-change='filter_fields'] [data-testid='field-combobox']")
# A non-matching query removes the Group option from the listbox.
view
|> form("[data-testid='field-search-form']", %{"picker_query" => "zzzznomatch"})
|> render_change()
refute has_element?(view, "#member-filter-field-opt-group")
end
end
describe "payment status labels" do
test "the toggle reads 'All paid' / 'Open contributions'", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
pick_field(view, "payment")
assert has_element?(view, "[data-testid='value-control-payment']", "All paid")
assert has_element?(view, "[data-testid='value-control-payment']", "Open contributions")
refute has_element?(view, "[data-testid='value-control-payment']", "Fully paid")
refute has_element?(view, "[data-testid='value-control-payment']", "Has unpaid")
end
test "the applied fully-paid chip reads 'All paid'", %{conn: conn} do
{:ok, view, _html} = live(conn, "/members?pay_filter=fully_paid")
assert has_element?(view, "[data-testid='filter-chip']", "All paid")
refute render(view) =~ "Fully paid"
end
end
describe "payment control (§1.22, §1.23)" do
test "'Has unpaid' reveals the two-sided open-cycle count range", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
pick_field(view, "payment")
refute has_element?(view, "[data-testid='payment-count-range']")
view
|> element("[data-testid='value-control-payment']")
|> render_change(%{
"__payment" => "1",
"pay" => "has_unpaid",
"pay_min" => "1",
"pay_max" => ""
})
_ = render(view)
assert_patch(view)
assert has_element?(view, "[data-testid='payment-count-range'] input[name='pay_min']")
assert has_element?(view, "[data-testid='payment-count-range'] input[name='pay_max']")
end
test "the suspended option activates the suspended-status filter in the URL", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
pick_field(view, "payment")
assert has_element?(view, "[data-testid='payment-suspended'] input[name='suspended']")
view
|> element("[data-testid='value-control-payment']")
|> render_change(%{"__payment" => "1", "suspended" => "1"})
_ = render(view)
path = assert_patch(view)
assert path =~ "suspended=1"
end
end
describe "unified date-range controls" do
# Every date-range value control (payment period, join date, exit date, and
# custom date fields) renders the same shared native Von/Bis row: a
# non-wrapping flex row whose two `<input type="date">` are each wrapped in
# `min-w-0 flex-1` so they share width equally and stay on one line.
test "payment period, join, exit and custom date all render the shared row", %{conn: conn} do
actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, date_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "birthday_#{System.unique_integer([:positive])}",
value_type: :date,
show_in_overview: true
})
|> Ash.create(actor: actor)
{:ok, view, _html} = live(conn, ~p"/members")
assert_shared_range(view, "payment", "payment-period-range", "pay_from", "pay_to")
assert_shared_range(view, "join_date", "custom-range", "jd_from", "jd_to")
assert_shared_range(view, "active_former", "exit-custom-range", "ed_from", "ed_to")
cd_from = "cdf_#{date_field.id}_from"
cd_to = "cdf_#{date_field.id}_to"
assert_shared_range(view, date_field.id, "custom-range", cd_from, cd_to)
end
end
# Opens the given field's value control and asserts its date-range row is the
# shared native Von/Bis layout: a `flex` (non-wrapping) row whose two date
# inputs are each wrapped in `min-w-0 flex-1` (equal width, single line).
defp assert_shared_range(view, field, testid, from_name, to_name) do
pick_field(view, field)
assert has_element?(view, "[data-testid='#{testid}'].flex")
refute has_element?(view, "[data-testid='#{testid}'].flex-wrap")
assert has_element?(
view,
"[data-testid='#{testid}'] .min-w-0.flex-1 input[type='date'][name='#{from_name}']"
)
assert has_element?(
view,
"[data-testid='#{testid}'] .min-w-0.flex-1 input[type='date'][name='#{to_name}']"
)
end
end

View file

@ -36,6 +36,7 @@ defmodule MvWeb.ConnCase do
import Plug.Conn
import Phoenix.ConnTest
import MvWeb.ConnCase
import MvWeb.FilterBuilderHelpers
end
end

View file

@ -0,0 +1,90 @@
defmodule MvWeb.FilterBuilderHelpers do
@moduledoc """
Test helpers for driving the member-overview `AddFilterBuilderComponent`.
The add-filter flow is: open the picker pick a field change the focused
value control (which commits the filter and re-loads the list). These helpers
encapsulate that sequence so tests read at the intent level and stay robust to
the builder's internal markup.
Each `apply_*` returns the patched path (the new `/members?` URL) so callers
can assert the URL-encoded filter contract.
"""
import Phoenix.LiveViewTest
@builder_id "member-filter"
@doc "Opens the field picker."
def open_picker(view) do
view |> element("[data-testid='add-filter-trigger']") |> render_click()
end
@doc "Opens the picker and selects the field with the given descriptor key."
def pick_field(view, key) do
open_picker(view)
view |> element("##{@builder_id}-field-opt-#{key}") |> render_click()
end
@doc "Applies a group in/not_in/all filter through the builder."
def apply_group_filter(view, group_id, value) do
pick_field(view, "group")
view
|> element("[data-testid='value-control-group']")
|> render_change(%{"group_#{group_id}" => to_string(value)})
flush_and_patch(view)
end
@doc "Applies a fee-type in/not_in/all filter through the builder."
def apply_fee_type_filter(view, fee_type_id, value) do
pick_field(view, "fee_type")
view
|> element("[data-testid='value-control-fee-type']")
|> render_change(%{"fee_type_#{fee_type_id}" => to_string(value)})
flush_and_patch(view)
end
@doc "Applies a boolean custom-field filter (true/false/all) through the builder."
def apply_boolean_filter(view, field_id, value) do
pick_field(view, field_id)
view
|> element("[data-testid='value-control-boolean']")
|> render_change(%{"custom_boolean" => %{"#{field_id}" => to_string(value)}})
flush_and_patch(view)
end
@doc """
Applies a payment status filter (and optionally a period) through the builder.
`status` is `"fully_paid"` or `"has_unpaid"`; `from`/`to` are ISO-8601 strings
or `""`. For `"has_unpaid"` the open-cycle range defaults to `min` 1 / no max.
"""
def apply_payment_filter(view, status, from \\ "", to \\ "", min \\ "1", max \\ "") do
pick_field(view, "payment")
view
|> element("[data-testid='value-control-payment']")
|> render_change(%{
"__payment" => "1",
"pay" => status,
"pay_min" => min,
"pay_max" => max,
"pay_from" => from,
"pay_to" => to
})
flush_and_patch(view)
end
# Forces the LiveView to process the queued filter message(s) and returns the
# resulting patched path.
defp flush_and_patch(view) do
_ = render(view)
assert_patch(view)
end
end