feat(overview): replace filter panel with add-filter builder and aging column

Swaps the unbounded vertical filter panel for a Polaris-style add-filter
builder (searchable grouped field picker, type-aware value control, applied
chips) and colour-codes the fees column with each member's period-scoped
unpaid-cycle count.
This commit is contained in:
Simon 2026-07-10 16:27:15 +02:00
parent 7d1a71b1fd
commit c547ca19af
35 changed files with 2817 additions and 3149 deletions

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
@ -58,6 +58,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 +175,8 @@ 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(:group_filters, %{})
|> assign(:groups, groups)
|> assign(:fee_type_filters, %{})
@ -203,8 +205,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 +309,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 +426,13 @@ 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
@ -465,6 +442,42 @@ defmodule MvWeb.MemberLive.Index do
{: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(),
group_filters: %{},
fee_type_filters: %{},
boolean_filters: %{},
date_filters: DateFilter.default()
}},
socket
)
end
@impl true
def handle_info({:boolean_filter_changed, custom_field_id_str, filter_value}, socket) do
updated_filters =
@ -573,7 +586,8 @@ 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, %{}))
@ -663,12 +677,12 @@ 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_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 +726,10 @@ 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[: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 +880,21 @@ 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_boolean_filters(base_params, opts.boolean_filters || %{})
add_date_filters(base_params, opts.date_filters)
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 +904,9 @@ 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,
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 +1208,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 +1329,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 +1364,8 @@ 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,
sort_field: socket.assigns.sort_field,
sort_order: socket.assigns.sort_order,
custom_fields: socket.assigns.all_custom_fields
@ -1550,13 +1569,14 @@ 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))
defp maybe_update_group_filters(socket, params) when is_map(params) do
prefix = @group_filter_prefix
@ -1652,10 +1672,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 +1744,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 +1831,23 @@ 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(%{from: nil, to: nil}), do: gettext("Fees")
def payment_column_label(%{from: from, to: to}) do
range = "#{payment_bound(from)}#{payment_bound(to)}"
gettext("Fees · %{range}", range: range)
end
def payment_column_label(_), do: gettext("Fees")
defp payment_bound(%Date{} = d), do: Date.to_iso8601(d)
defp payment_bound(_), do: ""
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 +1967,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,17 +21,38 @@
</:actions>
</.header>
<div class="flex flex-wrap gap-4 items-center">
<%!-- 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}
id="search-bar"
query={@query}
placeholder={gettext("Search...")}
/>
<div class="ml-auto flex items-center gap-4">
<.live_component
module={MvWeb.Components.FieldVisibilityDropdownComponent}
id="field-visibility-dropdown"
all_fields={@all_available_fields}
custom_fields={@all_custom_fields}
selected_fields={@user_field_selection}
/>
<.live_component
module={MvWeb.Components.ViewSettingsDropdownComponent}
id="view-settings-dropdown"
density={@density}
compact_member={@compact_member}
member_include_email={@member_include_email}
compact_address={@compact_address}
/>
</div>
</div>
<.live_component
module={MvWeb.Components.SearchBarComponent}
id="search-bar"
query={@query}
placeholder={gettext("Search...")}
/>
<.live_component
module={MvWeb.Components.MemberFilterComponent}
module={MvWeb.Components.AddFilterBuilderComponent}
id="member-filter"
cycle_status_filter={@cycle_status_filter}
groups={@groups}
group_filters={@group_filters}
fee_types={@fee_types}
@ -40,57 +61,9 @@
boolean_filters={@boolean_custom_field_filters}
date_custom_fields={@date_custom_fields}
date_filters={@date_filters}
member_count={@total_count}
payment_filter={@payment_filter}
payment_period={@payment_period}
/>
<.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}
id="field-visibility-dropdown"
all_fields={@all_available_fields}
custom_fields={@all_custom_fields}
selected_fields={@user_field_selection}
/>
<.live_component
module={MvWeb.Components.ViewSettingsDropdownComponent}
id="view-settings-dropdown"
density={@density}
compact_member={@compact_member}
member_include_email={@member_include_email}
compact_address={@compact_address}
/>
</div>
</div>
<%!-- Polite live region: present on first render (before it is filled) so
@ -131,7 +104,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 +456,52 @@
<:col
:let={member}
:if={:membership_fee_status in @member_fields_visible}
label={gettext("Membership Fee Status")}
label={MvWeb.MemberLive.Index.payment_column_label(@payment_period)}
>
<%= 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}
</.badge>
<% else %>
<.empty_cell sr_text={gettext("No cycle")} />
<% count = member.unpaid_cycle_count || 0 %>
<% badge = PaymentAging.badge(count) %>
<% tip_id = "payment-tip-#{member.id}" %>
<button
type="button"
class={["badge badge-soft badge-md cursor-pointer", badge.color_class]}
data-variant={badge.variant}
phx-hook={count > 0 && "SortTooltip"}
id={"payment-badge-#{member.id}"}
data-tooltip-id={count > 0 && tip_id}
phx-click={JS.navigate(~p"/members/#{member.id}?tab=membership_fees")}
aria-label={gettext("%{label} — open payment history", label: badge.label)}
data-testid="payment-badge"
>
<.icon name={badge.icon} class="size-4" />
{badge.label}
</button>
<%= if count > 0 do %>
<% cycles = PaymentAging.open_cycles(member, @payment_period) %>
<% {shown, overflow} = PaymentAging.tooltip_cycles(cycles) %>
<div
id={tip_id}
popover="manual"
role="tooltip"
class="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">
<li :for={cycle <- shown} class="flex">
<span class={[
"badge badge-soft badge-sm w-full justify-start",
PaymentAging.cycle_color_class(cycle.status)
]}>
<.icon name={PaymentAging.cycle_icon(cycle.status)} class="size-3.5" />
{PaymentAging.short_period(cycle)}
</span>
</li>
<li :if={overflow > 0} class="flex">
<span class="badge badge-soft badge-sm badge-ghost w-full justify-start">
{gettext("+%{count} more", count: overflow)}
</span>
</li>
</ul>
</div>
<% end %>
</:col>
<:col

View file

@ -100,7 +100,14 @@ defmodule MvWeb.MemberLive.Index.DateFilter do
`exit_date` slice is touched; `join_date` and custom-date entries are left
intact, so the quick filter and the detailed control share one source.
"""
@spec set_quick_state(map(), :active | :former | :all) :: map()
@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})

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

@ -65,7 +65,7 @@ defmodule MvWeb.MemberLive.Index.FilterDescriptor do
def group_for(%{group: group}), do: group
@doc "The fixed display order of the picker groups."
@spec group_order() :: [group()]
@spec group_order() :: [group(), ...]
def group_order, do: @group_order
@doc """

View file

@ -47,17 +47,10 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
)
|> apply_date_filters(opts[:date_filters])
|> 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_sort(opts[:sort_field], opts[:sort_order], opts[:custom_fields] || [])
end
defp today(opts), do: opts[:today] || Date.utc_today()
# ---------------------------------------------------------------------------
# Search
# ---------------------------------------------------------------------------
@ -184,55 +177,6 @@ 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)
#
# 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.
# ---------------------------------------------------------------------------
defp apply_cycle_status_filter(query, status, _show_current, _today)
when status not in [:paid, :unpaid],
do: query
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`.
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)
)
)
)
)
end
defp apply_cycle_status_filter(query, status, _show_current, today) do
# Last completed cycle: most recent cycle that has ended (cycle_end < today).
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)
)
)
)
)
end
# ---------------------------------------------------------------------------
# Payment aging filter (period-scoped unpaid-cycle count)
#

View file

@ -24,6 +24,7 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
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}
@ -99,40 +100,111 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
do: parse_filter(Map.get(params, @payment_filter_param))
@doc """
Badge descriptor for an unpaid-cycle count. A count of 0 reads "Paid"
(success); a positive count reads "N unpaid" (error).
Badge descriptor for an unpaid-cycle count. A count of 0 reads "Alle bezahlt"
(success/green); a positive count reads "N offen" (error/red). Reuses the
established cycle-status color and hero-icon codes so the overview badge reads
consistently with the member show page.
"""
@spec badge(non_neg_integer()) :: %{
variant: atom(),
color_class: String.t(),
icon: String.t(),
label: String.t(),
count: non_neg_integer()
}
def badge(0), do: %{variant: :success, label: gettext("Paid"), count: 0}
def badge(0) do
%{
variant: :success,
color_class: cycle_color_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
%{
variant: MembershipFeeHelpers.status_variant(:unpaid),
label: gettext("%{count} unpaid", count: count),
color_class: cycle_color_class(:unpaid),
icon: MembershipFeeHelpers.status_icon(:unpaid),
label: gettext("%{count} open", count: count),
count: count
}
end
@doc """
Splits a member's loaded cycles into the open (unpaid) and suspended cycles
whose `cycle_end` falls inside `period`, both sorted by `cycle_end`. Paid
cycles and cycles outside the period are dropped. Used to render the badge
tooltip. Expects `membership_fee_cycles` to be loaded on the member.
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()) :: %{unpaid: [map()], suspended: [map()]}
@spec open_cycles(map(), period()) :: [map()]
def open_cycles(member, %{from: from, to: to}) do
cycles = in_period_cycles(member, from, to)
%{
unpaid: cycles |> Enum.filter(&(&1.status == :unpaid)) |> sort_by_end(),
suspended: cycles |> Enum.filter(&(&1.status == :suspended)) |> sort_by_end()
}
member
|> in_period_cycles(from, to)
|> Enum.filter(&(&1.status in [:unpaid, :suspended]))
|> Enum.sort_by(& &1.cycle_start, Date)
end
@tooltip_slots 5
@doc """
Partitions open cycles into the tooltip's fixed five-slot budget: at most
five badges total. Five or fewer 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 tooltip renders four cycle badges and one
grey overflow badge five items.
"""
@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 """
Static daisyUI badge color token for a cycle status, matching the codes used
on the member show page. Literal strings so Tailwind's content scanner keeps
them in the bundle (a runtime `badge-\#{status}` would be tree-shaken).
"""
@spec cycle_color_class(:paid | :unpaid | :suspended) :: String.t()
def cycle_color_class(:paid), do: "badge-success"
def cycle_color_class(:unpaid), do: "badge-error"
def cycle_color_class(:suspended), do: "badge-warning"
@doc """
Hero-icon name for a cycle status (reuses the established status icons).
"""
@spec cycle_icon(:paid | :unpaid | :suspended) :: String.t()
def cycle_icon(status), do: MembershipFeeHelpers.status_icon(status)
@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 `"März 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))
@ -147,7 +219,35 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do
defp in_period?(_, _, _), do: false
defp sort_by_end(cycles), do: Enum.sort_by(cycles, & &1.cycle_end, Date)
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

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,7 +89,7 @@ msgstr "Aktionen"
msgid "Active members"
msgstr "Aktive Mitglieder"
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/components/add_filter_builder_component.ex
#, elixir-autogen, elixir-format
msgid "Active only"
msgstr "Nur aktive"
@ -149,7 +132,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 +193,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 +467,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 +477,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 +518,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 +779,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 +789,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 +868,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,23 +1244,13 @@ msgstr "Beispiele"
msgid "Exit Date"
msgstr "Austrittsdatum"
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/components/add_filter_builder_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"
@ -1487,11 +1425,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."
@ -1508,11 +1441,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
@ -1532,7 +1460,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"
@ -1659,7 +1587,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
@ -1774,11 +1701,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"
@ -1814,7 +1736,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"
@ -1907,22 +1828,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"
@ -2012,11 +1922,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:"
@ -2111,6 +2016,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"
@ -2157,11 +2063,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."
@ -2247,7 +2148,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"
@ -2411,7 +2311,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
@ -2433,11 +2333,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"
@ -2697,9 +2592,7 @@ 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/index/payment_aging.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
@ -2734,16 +2627,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"
@ -2842,7 +2725,7 @@ 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
#: lib/mv_web/live/components/add_filter_builder_component.ex
#, elixir-autogen, elixir-format
msgid "Range"
msgstr "Zeitraum"
@ -3266,11 +3149,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."
@ -3600,7 +3478,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"
@ -3700,7 +3578,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
@ -3906,7 +3783,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
@ -4129,11 +4006,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"
@ -4218,22 +4090,284 @@ msgstr "Klicke, um nach Nachname zu sortieren"
msgid "No address"
msgstr "Keine Adresse"
#: lib/mv_web/live/member_live/index/payment_aging.ex
#, elixir-autogen, elixir-format
msgid "%{count} unpaid"
msgstr "%{count} unbezahlt"
#: 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, fuzzy
#, elixir-autogen, elixir-format
msgid "Group"
msgstr "Gruppen"
msgstr "Gruppe"
#: lib/mv_web/live/components/add_filter_builder_component.ex
#: lib/mv_web/live/member_live/index/filter_descriptor.ex
#, elixir-autogen, elixir-format, fuzzy
#, elixir-autogen, elixir-format
msgid "Payment"
msgstr "Zahlungen"
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 "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 "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 "Has unpaid ≥ 1"
msgstr "Offen ≥ 1"
#: lib/mv_web/live/components/add_filter_builder_component.ex
#, elixir-autogen, elixir-format
msgid "Has unpaid ≥ 2"
msgstr "Offen ≥ 2"
#: 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 "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 "Last year"
msgstr "Letztes Jahr"
#: 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 "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 "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/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "+%{count} more"
msgstr "+%{count} weitere"
#: 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.ex
#, elixir-autogen, elixir-format
msgid "Fees · %{range}"
msgstr "Beiträge · %{range}"
#: lib/mv_web/live/member_live/index/payment_aging.ex
#, elixir-autogen, elixir-format
msgid "January"
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 for member details"
#~ msgstr "Klicke für Mitglieds-Details"
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "No open cycles for this period"
#~ msgstr "Keine offenen Zyklen in diesem Zeitraum"
#~ #: lib/mv_web/live/member_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Payment · %{range}"
#~ msgstr "Zahlung · %{range}"
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Suspended: %{date}"
#~ msgstr "Ausgesetzt: %{date}"
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Unpaid until %{date}"
#~ msgstr "Offen bis %{date}"

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,7 +90,7 @@ msgstr ""
msgid "Active members"
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 "Active only"
msgstr ""
@ -150,7 +133,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 +194,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 +468,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 +478,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 +519,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 +780,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 +790,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 +869,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,23 +1245,13 @@ msgstr ""
msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/components/add_filter_builder_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"
@ -1488,11 +1426,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."
@ -1509,11 +1442,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
@ -1533,7 +1461,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 ""
@ -1660,7 +1588,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
@ -1775,11 +1702,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"
@ -1815,7 +1737,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"
@ -1908,22 +1829,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"
@ -2013,11 +1923,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:"
@ -2112,6 +2017,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"
@ -2158,11 +2064,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."
@ -2248,7 +2149,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"
@ -2412,7 +2312,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
@ -2434,11 +2334,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"
@ -2698,9 +2593,7 @@ 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/index/payment_aging.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
@ -2735,16 +2628,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"
@ -2843,7 +2726,7 @@ msgstr ""
msgid "Quarterly Interval - Joining Cycle Excluded"
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 "Range"
msgstr ""
@ -3267,11 +3150,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."
@ -3601,7 +3479,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 ""
@ -3701,7 +3579,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
@ -3906,7 +3783,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
@ -4129,11 +4006,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"
@ -4218,11 +4090,7 @@ msgstr ""
msgid "No address"
msgstr ""
#: lib/mv_web/live/member_live/index/payment_aging.ex
#, elixir-autogen, elixir-format
msgid "%{count} unpaid"
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"
@ -4233,7 +4101,248 @@ msgstr ""
msgid "Group"
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 "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 "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
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 "Has unpaid ≥ 1"
msgstr ""
#: lib/mv_web/live/components/add_filter_builder_component.ex
#, elixir-autogen, elixir-format
msgid "Has unpaid ≥ 2"
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 "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 "Last year"
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 "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 "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/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "+%{count} more"
msgstr ""
#: 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.ex
#, elixir-autogen, elixir-format
msgid "Fees · %{range}"
msgstr ""
#: lib/mv_web/live/member_live/index/payment_aging.ex
#, elixir-autogen, elixir-format
msgid "January"
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 ""

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,7 +90,7 @@ msgstr ""
msgid "Active members"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/components/add_filter_builder_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Active only"
msgstr ""
@ -150,7 +133,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 +194,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 +468,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 +478,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 +519,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 +780,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 +790,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 +869,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,23 +1245,13 @@ msgstr ""
msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/components/add_filter_builder_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"
@ -1488,11 +1426,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."
@ -1509,11 +1442,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
@ -1533,7 +1461,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 ""
@ -1660,7 +1588,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
@ -1775,11 +1702,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"
@ -1815,7 +1737,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"
@ -1908,22 +1829,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"
@ -2013,11 +1923,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:"
@ -2112,6 +2017,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"
@ -2158,11 +2064,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."
@ -2248,7 +2149,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"
@ -2412,7 +2312,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
@ -2434,11 +2334,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"
@ -2698,9 +2593,7 @@ 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/index/payment_aging.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
@ -2735,16 +2628,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"
@ -2843,7 +2726,7 @@ msgstr ""
msgid "Quarterly Interval - Joining Cycle Excluded"
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 "Range"
msgstr ""
@ -3267,11 +3150,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."
@ -3601,7 +3479,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 ""
@ -3701,7 +3579,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
@ -3906,7 +3783,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
@ -4129,11 +4006,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"
@ -4218,22 +4090,284 @@ msgstr ""
msgid "No address"
msgstr ""
#: lib/mv_web/live/member_live/index/payment_aging.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "%{count} unpaid"
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, fuzzy
#, elixir-autogen, elixir-format
msgid "Group"
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, fuzzy
#, 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 "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
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 "Has unpaid ≥ 1"
msgstr ""
#: lib/mv_web/live/components/add_filter_builder_component.ex
#, elixir-autogen, elixir-format
msgid "Has unpaid ≥ 2"
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 "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 "Last year"
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 "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 "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/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "+%{count} more"
msgstr ""
#: 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.ex
#, elixir-autogen, elixir-format
msgid "Fees · %{range}"
msgstr ""
#: lib/mv_web/live/member_live/index/payment_aging.ex
#, elixir-autogen, elixir-format
msgid "January"
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
#~ msgid "Click for member details"
#~ 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/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 ""

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

@ -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,88 @@
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
defp refute_patched(view) do
assert_raise ArgumentError, fn -> assert_patch(view, 50) end
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,39 @@
defmodule MvWeb.MemberLive.IndexDatePresetsTest do
@moduledoc """
§1.8 a date field's value control offers presets; picking "This year"
applies the Jan 1 Dec 31 range of the current year and writes the equivalent
range params to the URL.
"""
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 current-year 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)
year = Date.utc_today().year
assert path =~ "jd_from=#{year}-01-01"
assert path =~ "jd_to=#{year}-12-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,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

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

@ -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,90 @@ 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 focusable control 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"
assert has_element?(
view,
"button[data-testid='payment-badge']##{"payment-badge-#{member.id}"}"
)
# A 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 "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})
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
})
{: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()
html = render(view)
# Should show suspended status (current cycle)
assert html =~ "hero-pause-circle" || html =~ "suspended"
# The toggle button exposes its pressed state to assistive tech.
assert has_element?(view, ~s([data-testid=toggle-cycle-view][aria-pressed="true"]))
end
test "shows correct color coding for paid 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: :paid,
replace_existing: true
})
{:ok, view, _html} = live(conn, "/members")
html = render(view)
assert html =~ "text-success" || html =~ "hero-check-circle"
end
test "shows correct color coding for unpaid 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: :unpaid,
replace_existing: true
})
{:ok, view, _html} = live(conn, "/members")
html = render(view)
assert html =~ "text-error" || html =~ "hero-x-circle"
end
test "shows correct color coding for suspended status", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member, fee_type, %{
cycle_start: ~D[2023-01-01],
status: :suspended,
replace_existing: true
})
{:ok, view, _html} = live(conn, "/members")
html = render(view)
assert html =~ "text-base-content/60" || html =~ "hero-pause-circle"
end
test "handles members without cycles gracefully", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = create_member(%{membership_fee_type_id: fee_type.id})
# No cycles created
{:ok, view, _html} = live(conn, "/members")
html = render(view)
# Should not crash, may show empty or default state
{:ok, _view, html} = live(conn, "/members")
assert html =~ member.first_name
end
end
describe "filters" do
test "filter unpaid in last cycle works", %{conn: conn} do
describe "payment filter" do
setup do
fee_type = create_fee_type(%{interval: :yearly})
# Member with unpaid last cycle
member1 = create_member(%{first_name: "UnpaidMember", membership_fee_type_id: fee_type.id})
unpaid = create_member(%{first_name: "HasUnpaid", membership_fee_type_id: fee_type.id})
create_cycle(unpaid, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
create_cycle(member1, fee_type, %{
cycle_start: ~D[2023-01-01],
status: :unpaid,
replace_existing: true
})
paid = create_member(%{first_name: "FullyPaid", membership_fee_type_id: fee_type.id})
create_cycle(paid, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
# Member with paid last cycle
member2 = create_member(%{first_name: "PaidMember", membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{
cycle_start: ~D[2023-01-01],
status: :paid,
replace_existing: true
})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Verify cycles exist in database
cycles1 =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member1.id)
|> Ash.read!(actor: system_actor)
cycles2 =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member2.id)
|> Ash.read!(actor: system_actor)
refute Enum.empty?(cycles1)
refute Enum.empty?(cycles2)
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=unpaid")
assert html =~ "UnpaidMember"
refute html =~ "PaidMember"
%{}
end
test "filter unpaid in current cycle works", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
test "has-unpaid ≥ 1 selects only members with unpaid cycles", %{conn: conn} do
{:ok, _view, html} = live(conn, "/members?pay_filter=unpaid_1")
today = Date.utc_today()
current_year_start = %{today | month: 1, day: 1}
# Member with unpaid current cycle
member1 = create_member(%{first_name: "UnpaidCurrent", membership_fee_type_id: fee_type.id})
create_cycle(member1, fee_type, %{
cycle_start: current_year_start,
status: :unpaid,
replace_existing: true
})
# Member with paid current cycle
member2 = create_member(%{first_name: "PaidCurrent", membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{
cycle_start: current_year_start,
status: :paid,
replace_existing: true
})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Verify cycles exist in database
cycles1 =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member1.id)
|> Ash.read!(actor: system_actor)
cycles2 =
MembershipFeeCycle
|> Ash.Query.filter(member_id == ^member2.id)
|> Ash.read!(actor: system_actor)
refute Enum.empty?(cycles1)
refute Enum.empty?(cycles2)
{:ok, _view, html} =
live(conn, "/members?cycle_status_filter=unpaid&show_current_cycle=true")
assert html =~ "UnpaidCurrent"
refute html =~ "PaidCurrent"
assert html =~ "HasUnpaid"
refute html =~ "FullyPaid"
end
end
@moduletag :slow
describe "performance" do
test "loads cycles efficiently without N+1 queries", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
test "fully-paid selects only members with zero unpaid cycles", %{conn: conn} do
{:ok, _view, html} = live(conn, "/members?pay_filter=fully_paid")
# Create multiple members with cycles
Enum.each(1..5, fn _ ->
member = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member, fee_type, %{
cycle_start: ~D[2023-01-01],
status: :paid,
replace_existing: true
})
end)
{:ok, _view, html} = live(conn, "/members")
# Should render without errors (N+1 would cause performance issues)
assert html =~ "Members" || html =~ "Mitglieder"
assert html =~ "FullyPaid"
refute html =~ "HasUnpaid"
end
end
end

View file

@ -93,17 +93,19 @@ defmodule MvWeb.MemberLive.IndexPaymentPeriodTest do
end
describe "badge/1" do
test "0 renders as Paid (success)" do
test "0 renders as All paid (success/green)" do
badge = PaymentAging.badge(0)
assert badge.variant == :success
assert badge.label == "Paid"
assert badge.color_class == "badge-success"
assert badge.label == "All paid"
end
test "N > 0 renders as N unpaid (error)" do
test "N > 0 renders as N open (error/red)" do
badge = PaymentAging.badge(2)
assert badge.variant == :error
assert badge.color_class == "badge-error"
assert badge.label =~ "2"
assert badge.label =~ "unpaid"
assert badge.label =~ "open"
end
end
@ -114,11 +116,11 @@ defmodule MvWeb.MemberLive.IndexPaymentPeriodTest do
%{actor: actor, ft: ft}
end
test "lists in-period unpaid cycles and flags suspended separately; paid excluded",
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-01-01], status: :unpaid}, 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
@ -127,10 +129,13 @@ defmodule MvWeb.MemberLive.IndexPaymentPeriodTest do
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 length(result.unpaid) == 2
assert length(result.suspended) == 1
assert Enum.all?(result.unpaid, &(&1.status == :unpaid))
assert Enum.all?(result.suspended, &(&1.status == :suspended))
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
@ -141,8 +146,22 @@ defmodule MvWeb.MemberLive.IndexPaymentPeriodTest do
member = Ash.load!(member, :membership_fee_cycles, actor: actor)
result = PaymentAging.open_cycles(member, PaymentAging.default_period())
assert length(result.unpaid) == 1
assert length(result.suspended) == 1
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,72 @@
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
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 "Fully paid".
assert boolean_chip?(view, boolean_field, "Yes")
assert render(view) =~ "Fully 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

@ -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,83 @@
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-count filter (and optionally a period) through the builder.
`filter` is one of `"all"`, `"fully_paid"`, `"unpaid_1"`, `"unpaid_2"`,
`"unpaid_3"`; `from`/`to` are ISO-8601 strings or `""`.
"""
def apply_payment_filter(view, filter, from \\ "", to \\ "") do
pick_field(view, "payment")
view
|> element("[data-testid='value-control-payment']")
|> render_change(%{"pay" => filter, "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