+ <%!-- Two-row overview toolbar (Option C): row 1 is search + column/view
+ controls; row 2 is the dedicated filter row (add-filter + applied chips). --%>
+
+
+ <.live_component
+ module={MvWeb.Components.SearchBarComponent}
+ id="search-bar"
+ query={@query}
+ placeholder={gettext("Search...")}
+ />
+
+ <.live_component
+ module={MvWeb.Components.FieldVisibilityDropdownComponent}
+ id="field-visibility-dropdown"
+ all_fields={@all_available_fields}
+ custom_fields={@all_custom_fields}
+ selected_fields={@user_field_selection}
+ />
+ <.live_component
+ module={MvWeb.Components.ViewSettingsDropdownComponent}
+ id="view-settings-dropdown"
+ density={@density}
+ compact_member={@compact_member}
+ member_include_email={@member_include_email}
+ compact_address={@compact_address}
+ />
+
+
+
<.live_component
- module={MvWeb.Components.SearchBarComponent}
- id="search-bar"
- query={@query}
- placeholder={gettext("Search...")}
- />
- <.live_component
- module={MvWeb.Components.MemberFilterComponent}
+ module={MvWeb.Components.AddFilterBuilderComponent}
id="member-filter"
- cycle_status_filter={@cycle_status_filter}
groups={@groups}
group_filters={@group_filters}
fee_types={@fee_types}
@@ -40,57 +61,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" />
-
- {if(@show_current_cycle,
- do: gettext("Current payment cycle"),
- else: gettext("Last payment cycle")
- )}
-
-
-
-
- <.live_component
- module={MvWeb.Components.FieldVisibilityDropdownComponent}
- id="field-visibility-dropdown"
- all_fields={@all_available_fields}
- custom_fields={@all_custom_fields}
- selected_fields={@user_field_selection}
- />
- <.live_component
- module={MvWeb.Components.ViewSettingsDropdownComponent}
- id="view-settings-dropdown"
- density={@density}
- compact_member={@compact_member}
- member_include_email={@member_include_email}
- compact_address={@compact_address}
- />
-
<%!-- Polite live region: present on first render (before it is filled) so
@@ -131,7 +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}
-
- <% else %>
- <.empty_cell sr_text={gettext("No cycle")} />
+ <% count = member.unpaid_cycle_count || 0 %>
+ <% badge = PaymentAging.badge(count) %>
+ <% tip_id = "payment-tip-#{member.id}" %>
+
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}
+
+ <%= if count > 0 do %>
+ <% cycles = PaymentAging.open_cycles(member, @payment_period) %>
+ <% {shown, overflow} = PaymentAging.tooltip_cycles(cycles) %>
+
+
+
+
+ <.icon name={PaymentAging.cycle_icon(cycle.status)} class="size-3.5" />
+ {PaymentAging.short_period(cycle)}
+
+
+ 0} class="flex">
+
+ {gettext("+%{count} more", count: overflow)}
+
+
+
+
<% end %>
<:col
diff --git a/lib/mv_web/live/member_live/index/date_filter.ex b/lib/mv_web/live/member_live/index/date_filter.ex
index a26abae2..ca6ed704 100644
--- a/lib/mv_web/live/member_live/index/date_filter.ex
+++ b/lib/mv_web/live/member_live/index/date_filter.ex
@@ -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})
diff --git a/lib/mv_web/live/member_live/index/export_payload.ex b/lib/mv_web/live/member_live/index/export_payload.ex
index 3c15c611..637da8a4 100644
--- a/lib/mv_web/live/member_live/index/export_payload.ex
+++ b/lib/mv_web/live/member_live/index/export_payload.ex
@@ -68,8 +68,12 @@ defmodule MvWeb.MemberLive.Index.ExportPayload do
query: assigns[:query] || nil,
sort_field: sort_field(assigns[:sort_field]),
sort_order: sort_order(assigns[:sort_order]),
- show_current_cycle: assigns[:show_current_cycle] || false,
- cycle_status_filter: cycle_status_filter(assigns[:cycle_status_filter]),
+ # Decoupled from the removed overview current/last cycle toggle (§3.3): the
+ # export's fee-status column always uses the current cycle as its basis and
+ # applies no cycle-status row filter (the overview now filters payment via
+ # the period-scoped aging model, which the export does not mirror).
+ show_current_cycle: true,
+ cycle_status_filter: nil,
boolean_filters: assigns[:boolean_custom_field_filters] || %{}
}
end
@@ -87,11 +91,6 @@ defmodule MvWeb.MemberLive.Index.ExportPayload do
def sort_order(:desc), do: "desc"
def sort_order(o) when is_binary(o), do: o
- defp cycle_status_filter(nil), do: nil
- defp cycle_status_filter(:paid), do: "paid"
- defp cycle_status_filter(:unpaid), do: "unpaid"
- defp cycle_status_filter(_), do: nil
-
defp build_member_fields_list(ordered_db, member_fields_visible) do
member_fields_visible = member_fields_visible || []
diff --git a/lib/mv_web/live/member_live/index/filter_descriptor.ex b/lib/mv_web/live/member_live/index/filter_descriptor.ex
index 96ecd801..0e80dc5a 100644
--- a/lib/mv_web/live/member_live/index/filter_descriptor.ex
+++ b/lib/mv_web/live/member_live/index/filter_descriptor.ex
@@ -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 """
diff --git a/lib/mv_web/live/member_live/index/overview_query.ex b/lib/mv_web/live/member_live/index/overview_query.ex
index 1afe499b..a45dd077 100644
--- a/lib/mv_web/live/member_live/index/overview_query.ex
+++ b/lib/mv_web/live/member_live/index/overview_query.ex
@@ -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)
#
diff --git a/lib/mv_web/live/member_live/index/payment_aging.ex b/lib/mv_web/live/member_live/index/payment_aging.ex
index 9dbe3e0c..e6154aa7 100644
--- a/lib/mv_web/live/member_live/index/payment_aging.ex
+++ b/lib/mv_web/live/member_live/index/payment_aging.ex
@@ -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.yyyy–dd.MM.yyyy` date range when the interval is not
+ loaded/known.
+ """
+ @spec short_period(map()) :: String.t()
+ def short_period(%{cycle_start: %Date{} = start} = cycle) do
+ case cycle_interval(cycle) do
+ :yearly -> Integer.to_string(start.year)
+ :quarterly -> "Q#{quarter(start.month)} #{start.year}"
+ :half_yearly -> "H#{half(start.month)} #{start.year}"
+ :monthly -> "#{month_name(start.month)} #{start.year}"
+ _ -> cycle_date_range(cycle)
+ end
+ end
+
+ def short_period(cycle), do: cycle_date_range(cycle)
+
defp in_period_cycles(member, from, to) do
case Map.get(member, :membership_fee_cycles) do
cycles when is_list(cycles) -> Enum.filter(cycles, &in_period?(&1.cycle_end, from, to))
@@ -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
diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex
index f1748591..d3979f46 100644
--- a/lib/mv_web/live/member_live/show.ex
+++ b/lib/mv_web/live/member_live/show.ex
@@ -424,7 +424,7 @@ defmodule MvWeb.MemberLive.Show do
end
@impl true
- def handle_params(%{"id" => id}, _, socket) do
+ def handle_params(%{"id" => id} = params, _, socket) do
actor = current_actor(socket)
# Load custom fields once using assign_new to avoid repeated queries
@@ -463,9 +463,17 @@ defmodule MvWeb.MemberLive.Show do
{:noreply,
socket
|> Layouts.assign_page_title(content_title)
- |> assign(:member, member)}
+ |> assign(:member, member)
+ |> maybe_assign_tab(params)}
end
+ # Deep-link support: the overview payment badge drills into the fees history
+ # via `?tab=membership_fees` (§1.15).
+ defp maybe_assign_tab(socket, %{"tab" => "membership_fees"}),
+ do: assign(socket, :active_tab, :membership_fees)
+
+ defp maybe_assign_tab(socket, _params), do: socket
+
@impl true
def handle_event("switch_tab", %{"tab" => "contact"}, socket) do
{:noreply, assign(socket, :active_tab, :contact)}
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index 8749a6a5..a8243b50 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -20,13 +20,6 @@ msgstr " (Datenfeld: %{field})"
msgid "%{count} failed"
msgstr "%{count} fehlgeschlagen"
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "%{count} filter active"
-msgid_plural "%{count} filters active"
-msgstr[0] "%{count} Filter aktiv"
-msgstr[1] "%{count} Filter aktiv"
-
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "%{count} member has a value assigned for this datafield."
@@ -39,16 +32,6 @@ msgstr[1] "%{count} Mitglieder haben Werte für dieses benutzerdefinierte Feld z
msgid "%{count} synced"
msgstr "%{count} synchronisiert"
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "%{field} from"
-msgstr "%{field} von"
-
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "%{field} to"
-msgstr "%{field} bis"
-
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "(ISO-8601 format: YYYY-MM-DD)"
@@ -106,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}"
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index 2e1df9f5..9de292e9 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -21,13 +21,6 @@ msgstr ""
msgid "%{count} failed"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "%{count} filter active"
-msgid_plural "%{count} filters active"
-msgstr[0] ""
-msgstr[1] ""
-
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "%{count} member has a value assigned for this datafield."
@@ -40,16 +33,6 @@ msgstr[1] ""
msgid "%{count} synced"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "%{field} from"
-msgstr ""
-
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "%{field} to"
-msgstr ""
-
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "(ISO-8601 format: YYYY-MM-DD)"
@@ -107,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 ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index faec9b03..427fb998 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -21,13 +21,6 @@ msgstr ""
msgid "%{count} failed"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "%{count} filter active"
-msgid_plural "%{count} filters active"
-msgstr[0] "%{count} filter active"
-msgstr[1] "%{count} filters active"
-
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "%{count} member has a value assigned for this datafield."
@@ -40,16 +33,6 @@ msgstr[1] ""
msgid "%{count} synced"
msgstr ""
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "%{field} from"
-msgstr ""
-
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "%{field} to"
-msgstr ""
-
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "(ISO-8601 format: YYYY-MM-DD)"
@@ -107,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 ""
diff --git a/test/mv_web/components/member_filter_component_test.exs b/test/mv_web/components/member_filter_component_test.exs
deleted file mode 100644
index b93acccb..00000000
--- a/test/mv_web/components/member_filter_component_test.exs
+++ /dev/null
@@ -1,660 +0,0 @@
-defmodule MvWeb.Components.MemberFilterComponentTest do
- @moduledoc """
- Unit tests for the MemberFilterComponent.
-
- Tests cover:
- - Rendering Payment Filter and Boolean Custom Fields
- - Boolean filter selection and event emission
- - Button label and badge logic
- - Filtering to show only boolean custom fields
- """
- # Kept async: false. The deferrable-FK migration removed the concurrent
- # create_member deadlock, but this file additionally showed an async-isolation
- # failure under load (filtered members from a parallel test leaking in), so it
- # is not trivially async-safe; resolving that is a separate follow-up.
- use MvWeb.ConnCase, async: false
-
- use Gettext, backend: MvWeb.Gettext
-
- import Phoenix.LiveViewTest
-
- alias Mv.Membership.CustomField
-
- # Helper to create a boolean custom field (uses system_actor - only admin can create)
- defp create_boolean_custom_field(attrs \\ %{}) do
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
-
- default_attrs = %{
- name: "test_boolean_#{System.unique_integer([:positive])}",
- value_type: :boolean
- }
-
- attrs = Map.merge(default_attrs, attrs)
-
- CustomField
- |> Ash.Changeset.for_create(:create, attrs)
- |> Ash.create!(actor: system_actor)
- end
-
- # Helper to create a non-boolean custom field (uses system_actor - only admin can create)
- defp create_string_custom_field(attrs \\ %{}) do
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
-
- default_attrs = %{
- name: "test_string_#{System.unique_integer([:positive])}",
- value_type: :string
- }
-
- attrs = Map.merge(default_attrs, attrs)
-
- CustomField
- |> Ash.Changeset.for_create(:create, attrs)
- |> Ash.create!(actor: system_actor)
- end
-
- describe "rendering" do
- test "trigger carries a trailing chevron affordance", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, _view, html} = live(conn, "/members")
-
- # Mirror the shared dropdown affordance: a trailing chevron inside the
- # bespoke filter trigger button.
- chevron =
- html
- |> LazyHTML.from_fragment()
- |> LazyHTML.query(~s(#member-filter button[aria-haspopup="true"] .hero-chevron-down))
-
- assert Enum.count(chevron) == 1
- end
-
- test "renders boolean custom fields when present", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- boolean_field = create_boolean_custom_field(%{name: "Active Member"})
-
- {:ok, view, _html} = live(conn, "/members")
-
- # Should show the boolean custom field name in the dropdown
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- html = render(view)
- assert html =~ boolean_field.name
- end
-
- test "renders payment and custom fields groups when boolean fields exist", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- _boolean_field = create_boolean_custom_field()
-
- {:ok, view, _html} = live(conn, "/members")
-
- # Open dropdown
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- html = render(view)
- # Should have both "Payments" and "Custom Fields" group labels
- assert html =~ gettext("Payments") || html =~ "Payment"
- assert html =~ gettext("Individual datafields")
- end
-
- test "renders only payment filter when no boolean custom fields exist", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- # Create a non-boolean field to ensure it's not shown
- _string_field = create_string_custom_field()
-
- {:ok, view, _html} = live(conn, "/members")
-
- # Component should exist with correct ID
- assert has_element?(view, "#member-filter")
-
- # Open dropdown
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- html = render(view)
-
- # Should show payment filter options (check both English and translated)
- assert html =~ "All" || html =~ gettext("All")
- assert html =~ "Paid" || html =~ gettext("Paid")
- assert html =~ "Unpaid" || html =~ gettext("Unpaid")
-
- # Should not show any boolean field names (since none exist)
- # We can't easily check this without knowing field names, but the structure should be correct
- end
- end
-
- describe "boolean filter selection" do
- test "selecting boolean filter sends correct event", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- boolean_field = create_boolean_custom_field(%{name: "Newsletter"})
-
- {:ok, view, _html} = live(conn, "/members")
-
- # Open dropdown
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- # Select "True" option for the boolean field using radio input
- # Radio inputs trigger phx-change on the form, so we use render_change on the form
- view
- |> form("#member-filter form", %{
- "custom_boolean" => %{to_string(boolean_field.id) => "true"}
- })
- |> render_change()
-
- # The event should be sent to the parent LiveView
- # We verify this by checking that the URL is updated
- assert_patch(view)
- end
-
- test "payment filter still works after component extension", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- _boolean_field = create_boolean_custom_field()
-
- {:ok, view, _html} = live(conn, "/members")
-
- # Open dropdown
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- # Select "Paid" option using radio input
- # Radio inputs trigger phx-change on the form, so we use render_change on the form
- view
- |> form("#member-filter form", %{"payment_filter" => "paid"})
- |> render_change()
-
- # URL should be updated with cycle_status_filter=paid
- path = assert_patch(view)
- assert path =~ "cycle_status_filter=paid"
- end
- end
-
- describe "button label" do
- test "shows active boolean filter names in button label", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- boolean_field1 = create_boolean_custom_field(%{name: "Active Member"})
- boolean_field2 = create_boolean_custom_field(%{name: "Newsletter"})
-
- # Set filters via URL
- {:ok, view, _html} =
- live(
- conn,
- "/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
- )
-
- # Component should exist
- assert has_element?(view, "#member-filter")
-
- # Button label should contain the custom field names
- # The exact format depends on implementation, but should show active filters
- button_html =
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render()
-
- assert button_html =~ boolean_field1.name || button_html =~ boolean_field2.name
- end
-
- test "truncates long custom field names in button label", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- # Create field with very long name (>30 characters)
- long_name = String.duplicate("A", 50)
- boolean_field = create_boolean_custom_field(%{name: long_name})
-
- # Set filter via URL
- {:ok, view, _html} =
- live(conn, "/members?bf_#{boolean_field.id}=true")
-
- # Component should exist
- assert has_element?(view, "#member-filter")
-
- # Get button label text
- button_html =
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render()
-
- # Button label should be truncated - full name should NOT appear in button
- # (it may appear in dropdown when opened, but not in the button label itself)
- # Check that button doesn't contain the full 50-character name
- refute button_html =~ long_name
-
- # Button should still contain some text (truncated version or indicator)
- assert String.length(button_html) > 0
- end
-
- test "date-only activation (ed_mode=all) replaces the idle label", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?ed_mode=all")
-
- button_html =
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render()
-
- # The idle label must not appear; some non-idle label is shown. This is
- # the same observable contract as the other filter categories — the
- # button visually communicates "a filter is active". The `btn-active`
- # CSS class is set by the parent class= attribute but the `<.button>`
- # core component currently composes its own class string and drops the
- # caller-supplied one — that is a pre-existing component constraint, not
- # specific to date filters.
- refute button_html =~ gettext("Apply filters")
- end
-
- test "date-only activation (jd_from) replaces the idle label", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?jd_from=2024-01-15")
-
- button_html =
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render()
-
- refute button_html =~ gettext("Apply filters")
- end
-
- test "date filter combined with one other filter shows '2 filters active'", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- boolean_field = create_boolean_custom_field(%{name: "Newsletter"})
-
- {:ok, view, _html} =
- live(conn, "/members?ed_mode=all&bf_#{boolean_field.id}=true")
-
- button_html =
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render()
-
- # With two distinct filter categories active, the label switches to the
- # pluralized "N filters active" form. Without counting date filters as
- # a category, this would show only "1 filter active" or the boolean
- # field name.
- assert button_html =~ "2"
- assert button_html =~ gettext("filters active")
- end
- end
-
- describe "badge" do
- test "shows total count of active boolean filters in badge", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- boolean_field1 = create_boolean_custom_field(%{name: "Field1"})
- boolean_field2 = create_boolean_custom_field(%{name: "Field2"})
-
- # Set two filters via URL
- {:ok, view, _html} =
- live(
- conn,
- "/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
- )
-
- # Component should exist
- assert has_element?(view, "#member-filter")
-
- # Badge should be visible when boolean filters are active
- assert has_element?(view, "#member-filter .badge")
-
- # Badge should show count of active boolean filters (2 in this case)
- badge_html =
- view
- |> element("#member-filter .badge")
- |> render()
-
- assert badge_html =~ "2"
- end
- end
-
- describe "filtering" do
- test "only boolean custom fields are displayed, not string or integer fields", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- boolean_field = create_boolean_custom_field(%{name: "Boolean Field"})
- _string_field = create_string_custom_field(%{name: "String Field"})
-
- {:ok, view, _html} = live(conn, "/members")
-
- # Open dropdown
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- # Should show boolean field in the dropdown panel
- # Extract only the dropdown panel HTML to check
- dropdown_html =
- view
- |> element("#member-filter div[role='dialog']")
- |> render()
-
- # Should show boolean field in dropdown
- assert dropdown_html =~ boolean_field.name
-
- # Should not show string field name in the filter dropdown
- # (String fields should not appear in boolean filter section)
- refute dropdown_html =~ "String Field"
- end
-
- test "renders the Dates section with exit_date and join_date controls", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
-
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- dropdown_html =
- view
- |> element("#member-filter div[role='dialog']")
- |> render()
-
- assert dropdown_html =~ gettext("Dates")
- assert dropdown_html =~ gettext("Join date")
- assert dropdown_html =~ gettext("Exit date")
- # Exit-date segmented control modes.
- assert dropdown_html =~ gettext("Active only")
- assert dropdown_html =~ gettext("Inactive only")
- # Built-in date inputs (always present for join_date and the ed_mode selector).
- assert dropdown_html =~ ~s(name="jd_from")
- assert dropdown_html =~ ~s(name="jd_to")
- assert dropdown_html =~ ~s(name="ed_mode")
- end
-
- test "exit_date custom mode reveals ed_from and ed_to inputs", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?ed_mode=custom")
-
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- dropdown_html =
- view
- |> element("#member-filter div[role='dialog']")
- |> render()
-
- assert dropdown_html =~ ~s(name="ed_from")
- assert dropdown_html =~ ~s(name="ed_to")
- end
-
- test "date inputs render via MvWeb.CoreComponents.input (no raw DaisyUI input markup)",
- %{conn: conn} do
- # DESIGN_GUIDELINES §1.1 mandates that LiveViews/HEEX use the project's
- # `<.input>` wrapper rather than emitting raw `
` tags carrying
- # DaisyUI component classes (e.g. `input input-sm input-bordered`)
- # directly in HEEX. `<.input>` is the project's single source of truth
- # for input styling; bypassing it splits styling across many call sites.
- #
- # The recognizable structural fingerprint of `<.input>` is a wrapping
- # `
` `` chain immediately
- # preceding the ` `. The raw inline form has no such wrapper —
- # the input sits directly inside a sibling ``/` ` flex row.
- # We assert that fingerprint on each of the date inputs.
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?ed_mode=custom")
-
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- dropdown_html =
- view
- |> element("#member-filter div[role='dialog']")
- |> render()
-
- for name <- ["jd_from", "jd_to", "ed_from", "ed_to"] do
- # Match `` followed (within a short
- # window of HTML) by an ` ` carrying the expected `name`. The
- # window prevents the regex from spanning unrelated `mb-2` /
- # `fieldset` occurrences scattered across the dropdown. The wrapper
- # is the canonical fingerprint of `MvWeb.CoreComponents.input/1`
- # (see `lib/mv_web/components/core_components.ex` — every input
- # branch starts with ``).
- assert Regex.match?(
- ~r/]*class="mb-2 fieldset"[^>]*>\s*]*>(?:\s*]*>.*?<\/span>)?\s* ]*name="#{name}"/s,
- dropdown_html
- ),
- "expected date input #{name} to be wrapped by MvWeb.CoreComponents.input " <>
- "(class=\"mb-2 fieldset\" fieldset wrapper), not a raw inline " <>
- " element"
- end
- end
-
- test "exit_date defaults to :active_only in the rendered radio", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
-
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- dropdown_html =
- view
- |> element("#member-filter div[role='dialog']")
- |> render()
-
- assert dropdown_html =~
- ~r/name="ed_mode"[^>]*value="active_only"[^>]*checked|checked[^>]*name="ed_mode"[^>]*value="active_only"/
- end
-
- test "Custom date fields section is non-scrollable with 5 or fewer fields (§3.4)", %{
- conn: conn
- } do
- conn = conn_with_oidc_user(conn)
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
-
- for i <- 1..5 do
- CustomField
- |> Ash.Changeset.for_create(:create, %{
- name: "DateField-#{i}-#{System.unique_integer([:positive])}",
- value_type: :date
- })
- |> Ash.create!(actor: system_actor)
- end
-
- {:ok, view, _html} = live(conn, "/members")
-
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- section_html = custom_date_section_html(view)
-
- # With ≤ 5 fields the section must NOT carry the scrollable wrapper.
- refute section_html =~ "max-h-60"
- refute section_html =~ "overflow-y-auto"
- end
-
- test "Custom date fields section becomes scrollable with more than 5 fields (§3.4)", %{
- conn: conn
- } do
- conn = conn_with_oidc_user(conn)
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
-
- for i <- 1..6 do
- CustomField
- |> Ash.Changeset.for_create(:create, %{
- name: "DateField-#{i}-#{System.unique_integer([:positive])}",
- value_type: :date
- })
- |> Ash.create!(actor: system_actor)
- end
-
- {:ok, view, _html} = live(conn, "/members")
-
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- section_html = custom_date_section_html(view)
-
- # With more than 5 fields the section is wrapped in the scrollable container.
- assert section_html =~ "max-h-60"
- assert section_html =~ "overflow-y-auto"
- end
-
- # Extract the HTML of the rendered "Custom date fields" section. Returns
- # "" if the section is not rendered. Used by the threshold tests to avoid
- # picking up scrollable classes from sibling sections.
- defp custom_date_section_html(view) do
- dropdown_html =
- view
- |> element("#member-filter div[role='dialog']")
- |> render()
-
- label = gettext("Custom date fields")
-
- case String.split(dropdown_html, label, parts: 2) do
- [_before, after_label] ->
- # Up to the next group header label, or the footer.
- after_label
- |> String.split(["text-xs font-semibold opacity-70 mb-2 uppercase"], parts: 2)
- |> List.first()
-
- _ ->
- ""
- end
- end
-
- test "Custom date fields section appears only when date custom fields exist", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view_no_field, _} = live(conn, "/members")
-
- view_no_field
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- dropdown_html =
- view_no_field
- |> element("#member-filter div[role='dialog']")
- |> render()
-
- refute dropdown_html =~ gettext("Custom date fields")
-
- # Add a date-typed custom field and re-load: the section appears.
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
-
- {:ok, field} =
- CustomField
- |> Ash.Changeset.for_create(:create, %{
- name: "Birthday-#{System.unique_integer([:positive])}",
- value_type: :date
- })
- |> Ash.create(actor: system_actor)
-
- {:ok, view, _} = live(conn, "/members")
-
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- dropdown_html =
- view
- |> element("#member-filter div[role='dialog']")
- |> render()
-
- assert dropdown_html =~ gettext("Custom date fields")
- assert dropdown_html =~ field.name
- assert dropdown_html =~ "cdf_#{field.id}_from"
- assert dropdown_html =~ "cdf_#{field.id}_to"
- end
-
- test "update_filters event dispatches a date_filters_changed patch with the new jd_from", %{
- conn: conn
- } do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
-
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- view
- |> form("#member-filter form", %{
- "jd_from" => "2024-01-15",
- "payment_filter" => "all"
- })
- |> render_change()
-
- # Parent LiveView receives {:date_filters_changed, ...} and patches the URL.
- path = assert_patch(view)
- assert path =~ "jd_from=2024-01-15"
- end
-
- test "selecting ed_mode=all updates the URL and reveals former members", %{conn: conn} do
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
- today = Date.utc_today()
- unique_name = "Zarquon-#{System.unique_integer([:positive])}"
-
- {:ok, former} =
- Mv.Membership.create_member(
- %{
- first_name: unique_name,
- last_name: "Exited",
- email: "ex-#{System.unique_integer([:positive])}@example.com",
- join_date: Date.add(today, -1000),
- exit_date: Date.add(today, -30)
- },
- actor: system_actor
- )
-
- conn = conn_with_oidc_user(conn)
- {:ok, view, html} = live(conn, "/members")
-
- # Fresh load hides the former member.
- refute html =~ former.first_name
-
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- view
- |> form("#member-filter form", %{
- "ed_mode" => "all",
- "payment_filter" => "all"
- })
- |> render_change()
-
- path = assert_patch(view)
- assert path =~ "ed_mode=all"
-
- # Now Eve appears in the rendered list.
- assert render(view) =~ former.first_name
- end
-
- test "dropdown shows scrollbar when many boolean custom fields exist", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
-
- # Create 15 boolean custom fields (more than typical, should trigger scrollbar)
- boolean_fields =
- Enum.map(1..15, fn i ->
- create_boolean_custom_field(%{name: "Field #{i}"})
- end)
-
- {:ok, view, _html} = live(conn, "/members")
-
- # Open dropdown
- view
- |> element("#member-filter button[aria-haspopup='true']")
- |> render_click()
-
- # Extract dropdown panel HTML
- dropdown_html =
- view
- |> element("#member-filter div[role='dialog']")
- |> render()
-
- # Should have scrollbar classes: max-h-60 overflow-y-auto pr-2
- # Check for the scrollable container (the div with max-h-60 and overflow-y-auto)
- assert dropdown_html =~ "max-h-60"
- assert dropdown_html =~ "overflow-y-auto"
-
- # Verify all fields are present in the dropdown
- Enum.each(boolean_fields, fn field ->
- assert dropdown_html =~ field.name
- end)
- end
- end
-end
diff --git a/test/mv_web/live/member_live/index/export_payload_test.exs b/test/mv_web/live/member_live/index/export_payload_test.exs
index 932816e6..b049dfff 100644
--- a/test/mv_web/live/member_live/index/export_payload_test.exs
+++ b/test/mv_web/live/member_live/index/export_payload_test.exs
@@ -56,8 +56,11 @@ defmodule MvWeb.MemberLive.Index.ExportPayloadTest do
assert payload.query == "ada"
assert payload.sort_field == "last_name"
assert payload.sort_order == "desc"
+ # Decoupled from the removed overview cycle toggle (§3.3): the export basis
+ # is always the current cycle and no cycle-status row filter is applied,
+ # regardless of any legacy cycle assigns.
assert payload.show_current_cycle == true
- assert payload.cycle_status_filter == "unpaid"
+ assert payload.cycle_status_filter == nil
assert payload.boolean_filters == %{"cf-x" => true}
end
@@ -79,7 +82,7 @@ defmodule MvWeb.MemberLive.Index.ExportPayloadTest do
assert payload.query == nil
assert payload.sort_field == nil
assert payload.sort_order == nil
- assert payload.show_current_cycle == false
+ assert payload.show_current_cycle == true
assert payload.cycle_status_filter == nil
assert payload.boolean_filters == %{}
end
diff --git a/test/mv_web/live/member_live/index/overview_query_cycle_status_test.exs b/test/mv_web/live/member_live/index/overview_query_cycle_status_test.exs
deleted file mode 100644
index 1ee235c9..00000000
--- a/test/mv_web/live/member_live/index/overview_query_cycle_status_test.exs
+++ /dev/null
@@ -1,78 +0,0 @@
-defmodule MvWeb.MemberLive.Index.OverviewQueryCycleStatusTest do
- @moduledoc """
- §1.13 — the paid/unpaid cycle-status filter, under the current or last-completed
- cycle view, returns the same members as the previous in-memory classifier,
- computed DB-side via the `cycle_end`-backed aggregates.
- """
- use Mv.DataCase, async: false
-
- import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4]
-
- alias Mv.Membership.Member
- alias MvWeb.MemberLive.Index.MembershipFeeStatus
- alias MvWeb.MemberLive.Index.OverviewQuery
-
- @today ~D[2024-07-15]
-
- setup do
- actor = Mv.Helpers.SystemActor.get_system_actor()
- ft = create_fee_type(%{interval: :yearly}, actor)
-
- # current 2024 paid, last-completed 2023 unpaid
- paid_now = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor)
- create_cycle(paid_now, ft, %{cycle_start: ~D[2024-01-01], status: :paid}, actor)
- create_cycle(paid_now, ft, %{cycle_start: ~D[2023-01-01], status: :unpaid}, actor)
-
- # current 2024 unpaid, last-completed 2023 paid
- unpaid_now = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor)
- create_cycle(unpaid_now, ft, %{cycle_start: ~D[2024-01-01], status: :unpaid}, actor)
- create_cycle(unpaid_now, ft, %{cycle_start: ~D[2023-01-01], status: :paid}, actor)
-
- # no cycles
- _no_cycles = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor)
-
- %{actor: actor, paid_now: paid_now, unpaid_now: unpaid_now}
- end
-
- defp db_ids(status, show_current, actor) do
- OverviewQuery.build(%{
- cycle_status_filter: status,
- show_current_cycle: show_current,
- today: @today
- })
- |> Ash.read!(actor: actor)
- |> MapSet.new(& &1.id)
- end
-
- defp oracle_ids(status, show_current, actor) do
- Member
- |> Ash.Query.load(membership_fee_cycles: [:membership_fee_type])
- |> Ash.Query.load(:membership_fee_type)
- |> Ash.read!(actor: actor)
- |> Enum.filter(fn m ->
- MembershipFeeStatus.get_cycle_status_for_member(m, show_current, @today) == status
- end)
- |> MapSet.new(& &1.id)
- end
-
- # {status, show_current, member-key expected in the result}
- @cases [
- {:paid, true, :paid_now},
- {:unpaid, true, :unpaid_now},
- {:paid, false, :unpaid_now},
- {:unpaid, false, :paid_now}
- ]
-
- for {status, show_current, expected_key} <- @cases do
- test "#{status} under #{if show_current, do: "current", else: "last"} cycle matches oracle",
- %{actor: actor} = ctx do
- status = unquote(status)
- show_current = unquote(show_current)
- expected = Map.fetch!(ctx, unquote(expected_key))
-
- db = db_ids(status, show_current, actor)
- assert db == oracle_ids(status, show_current, actor)
- assert expected.id in db
- end
- end
-end
diff --git a/test/mv_web/member_live/index_add_filter_flow_test.exs b/test/mv_web/member_live/index_add_filter_flow_test.exs
new file mode 100644
index 00000000..acee2755
--- /dev/null
+++ b/test/mv_web/member_live/index_add_filter_flow_test.exs
@@ -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
diff --git a/test/mv_web/member_live/index_column_manager_test.exs b/test/mv_web/member_live/index_column_manager_test.exs
index 723b8f2d..7833d128 100644
--- a/test/mv_web/member_live/index_column_manager_test.exs
+++ b/test/mv_web/member_live/index_column_manager_test.exs
@@ -23,9 +23,9 @@ defmodule MvWeb.MemberLive.IndexColumnManagerTest do
test "column manager trigger sits alongside the filter controls", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
- # Both the filter panel trigger and the column-manager trigger render in the
- # same toolbar.
- assert has_element?(view, ~s(button[aria-label="Filter members"]))
+ # Both the add-filter builder trigger and the column-manager trigger render
+ # in the same toolbar.
+ assert has_element?(view, "[data-testid='add-filter-trigger']")
assert has_element?(view, "button[aria-controls='field-visibility-menu']")
end
diff --git a/test/mv_web/member_live/index_date_presets_test.exs b/test/mv_web/member_live/index_date_presets_test.exs
new file mode 100644
index 00000000..ce30fe35
--- /dev/null
+++ b/test/mv_web/member_live/index_date_presets_test.exs
@@ -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
diff --git a/test/mv_web/member_live/index_default_columns_test.exs b/test/mv_web/member_live/index_default_columns_test.exs
index 5f5545d6..3cac0353 100644
--- a/test/mv_web/member_live/index_default_columns_test.exs
+++ b/test/mv_web/member_live/index_default_columns_test.exs
@@ -40,7 +40,9 @@ defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do
# Fee type, fee status, groups, join date headers.
assert has_element?(view, "[data-testid='membership_fee_type']")
assert has_element?(view, "[data-testid='join_date']")
- assert has_element?(view, "th", "Membership Fee Status")
+ # The former "Membership Fee Status" column is now the period-scoped payment
+ # aging column (§3.3), headed "Fees".
+ assert has_element?(view, "th", "Fees")
assert has_element?(view, "[data-testid='groups']")
end
diff --git a/test/mv_web/member_live/index_filter_chips_test.exs b/test/mv_web/member_live/index_filter_chips_test.exs
new file mode 100644
index 00000000..c5be18a4
--- /dev/null
+++ b/test/mv_web/member_live/index_filter_chips_test.exs
@@ -0,0 +1,92 @@
+defmodule MvWeb.MemberLive.IndexFilterChipsTest do
+ @moduledoc """
+ §1.4, §1.5, §3.2 — applied-filter chips render one per value, each is an
+ independently removable real button, and "Clear all" removes every active
+ filter in a single reload with the URL reset to the unfiltered state.
+ """
+ use MvWeb.ConnCase, async: false
+
+ import Phoenix.LiveViewTest
+
+ alias Mv.Membership.Group
+
+ setup %{conn: conn} do
+ actor = Mv.Helpers.SystemActor.get_system_actor()
+
+ {:ok, member} =
+ Mv.Membership.create_member(
+ %{first_name: "Chip", last_name: "Member", email: "chip@example.com"},
+ actor: actor
+ )
+
+ {:ok, group1} =
+ Group |> Ash.Changeset.for_create(:create, %{name: "Alpha"}) |> Ash.create(actor: actor)
+
+ {:ok, group2} =
+ Group |> Ash.Changeset.for_create(:create, %{name: "Beta"}) |> Ash.create(actor: actor)
+
+ {:ok, _mg} =
+ Mv.Membership.create_member_group(%{member_id: member.id, group_id: group1.id},
+ actor: actor
+ )
+
+ %{conn: conn_with_oidc_user(conn), group1: group1, group2: group2}
+ end
+
+ test "an applied filter renders a removable chip that is a real button (§1.4, §3.2)", %{
+ conn: conn,
+ group1: group1
+ } do
+ {:ok, view, _html} = live(conn, "/members?group_#{group1.id}=in")
+
+ # The chip's remove control is a real button whose accessible name names the
+ # filter it removes (locale-independent: it contains the group name).
+ assert has_element?(
+ view,
+ "button[data-testid='filter-chip-remove'][aria-label*='Alpha']"
+ )
+ end
+
+ test "removing a chip drops its URL param and reloads (§1.4)", %{conn: conn, group1: group1} do
+ {:ok, view, _html} = live(conn, "/members?group_#{group1.id}=in")
+
+ view |> element("[data-testid='filter-chip-remove']") |> render_click()
+
+ path = assert_patch(view)
+ refute path =~ "group_#{group1.id}"
+ end
+
+ test "Clear all removes every active filter in one reload and resets the URL (§1.5)", %{
+ conn: conn,
+ group1: group1,
+ group2: group2
+ } do
+ {:ok, view, _html} =
+ live(conn, "/members?group_#{group1.id}=in&group_#{group2.id}=not_in")
+
+ assert has_element?(view, "[data-testid='clear-all-filters']")
+ view |> element("[data-testid='clear-all-filters']") |> render_click()
+
+ path = assert_patch(view)
+ refute path =~ "group_#{group1.id}"
+ refute path =~ "group_#{group2.id}"
+ refute has_element?(view, "[data-testid='filter-chip']")
+ end
+
+ test "each value renders its own chip (one chip per value)", %{
+ conn: conn,
+ group1: group1,
+ group2: group2
+ } do
+ {:ok, view, _html} =
+ live(conn, "/members?group_#{group1.id}=in&group_#{group2.id}=not_in")
+
+ chips =
+ render(view)
+ |> LazyHTML.from_fragment()
+ |> LazyHTML.query(~s([data-testid="filter-chip"]))
+ |> Enum.count()
+
+ assert chips == 2
+ end
+end
diff --git a/test/mv_web/member_live/index_filter_height_property_test.exs b/test/mv_web/member_live/index_filter_height_property_test.exs
new file mode 100644
index 00000000..1ba96cae
--- /dev/null
+++ b/test/mv_web/member_live/index_filter_height_property_test.exs
@@ -0,0 +1,50 @@
+defmodule MvWeb.MemberLive.IndexFilterHeightPropertyTest do
+ @moduledoc """
+ §2.3 — the resting filter UI's size depends only on the number of active
+ filters, never on the size of the available-field catalog. The builder renders
+ one chip per active filter regardless of how many groups/fee types/custom
+ fields are pickable, so the chip count is a function of the active-filter count
+ alone.
+ """
+ use ExUnit.Case, async: true
+ use ExUnitProperties
+
+ import Phoenix.LiveViewTest
+
+ alias MvWeb.Components.AddFilterBuilderComponent
+
+ defp group(i), do: %{id: "g#{i}", name: "Group #{i}"}
+
+ defp chip_count(html) do
+ html
+ |> LazyHTML.from_fragment()
+ |> LazyHTML.query(~s([data-testid="filter-chip"]))
+ |> Enum.count()
+ end
+
+ property "resting chip count depends only on active-filter count, not catalog size" do
+ check all(
+ catalog_size <- integer(0..8),
+ active_count <- integer(0..5)
+ ) do
+ # active filters must reference real catalog ids, so ensure enough groups.
+ total = max(catalog_size, active_count)
+ groups = Enum.map(1..max(total, 1)//1, &group/1)
+ groups = if total == 0, do: [], else: groups
+
+ group_filters =
+ groups
+ |> Enum.take(active_count)
+ |> Map.new(&{&1.id, :in})
+
+ html =
+ render_component(AddFilterBuilderComponent,
+ id: "member-filter",
+ groups: groups,
+ group_filters: group_filters
+ )
+
+ assert chip_count(html) == active_count
+ end
+ end
+end
diff --git a/test/mv_web/member_live/index_filter_panel_test.exs b/test/mv_web/member_live/index_filter_panel_test.exs
deleted file mode 100644
index 770c0944..00000000
--- a/test/mv_web/member_live/index_filter_panel_test.exs
+++ /dev/null
@@ -1,79 +0,0 @@
-defmodule MvWeb.MemberLive.IndexFilterPanelTest do
- @moduledoc """
- §1.18 — The existing "Apply filters" panel is unchanged: same fieldset/legend
- form structure and payment-status controls, no chip/add-filter-builder
- paradigm (that is #548). Its filters now resolve DB-side via the `:overview`
- read action.
- """
- use MvWeb.ConnCase, async: false
-
- import Phoenix.LiveViewTest
-
- alias Mv.Helpers.SystemActor
-
- setup %{conn: conn} do
- {:ok, _} =
- Mv.Membership.create_member(
- %{first_name: "Panel", last_name: "Member", email: "panel@example.com"},
- actor: SystemActor.get_system_actor()
- )
-
- %{conn: conn_with_oidc_user(conn)}
- end
-
- defp open_filter(view) do
- view
- |> element(~s(button[phx-click="toggle_dropdown"][aria-label="Filter members"]))
- |> render_click()
- end
-
- test "the filter panel keeps its fieldset form structure and payment controls", %{conn: conn} do
- {:ok, view, _html} = live(conn, ~p"/members")
- open_filter(view)
-
- # Semantic form structure (fieldset/legend, radio controls) is preserved.
- assert has_element?(view, ~s(form[data-testid="member-filter-form"]))
- assert has_element?(view, "#payment-filter-all")
- assert has_element?(view, "#payment-filter-paid")
- assert has_element?(view, "#payment-filter-unpaid")
- end
-
- test "no chip / add-filter-builder paradigm is present (#548 is out of scope)", %{conn: conn} do
- {:ok, view, html} = live(conn, ~p"/members")
- open_filter(view)
- html = html <> render(view)
-
- refute html =~ ~s(data-testid="add-filter")
- refute html =~ ~s(data-testid="filter-chip")
- refute html =~ "filter-builder"
- end
-
- test "the panel's payment filter resolves DB-side via :overview", %{conn: conn} do
- system_actor = SystemActor.get_system_actor()
- fee_type = Mv.Fixtures.create_fee_type(%{interval: :yearly}, system_actor)
- last_year_start = Date.new!(Date.utc_today().year - 1, 1, 1)
-
- {:ok, paid} =
- Mv.Membership.create_member(
- %{
- first_name: "PaidPanel",
- last_name: "X",
- email: "paidpanel@example.com",
- membership_fee_type_id: fee_type.id
- },
- actor: system_actor
- )
-
- Mv.Fixtures.create_cycle(
- paid,
- fee_type,
- %{cycle_start: last_year_start, status: :paid, replace_existing: true},
- system_actor
- )
-
- {:ok, _view, html} = live(conn, ~p"/members?cycle_status_filter=paid")
-
- assert html =~ "PaidPanel"
- refute html =~ "Panel Member"
- end
-end
diff --git a/test/mv_web/member_live/index_filter_parity_property_test.exs b/test/mv_web/member_live/index_filter_parity_property_test.exs
index 8cd17ce8..3568e1b4 100644
--- a/test/mv_web/member_live/index_filter_parity_property_test.exs
+++ b/test/mv_web/member_live/index_filter_parity_property_test.exs
@@ -8,8 +8,8 @@ defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do
The unchanged DB filters (group, fee-type) are applied identically in both
arms; the property isolates the filters that moved from memory to the DB
- (boolean custom fields, date custom fields, paid/unpaid cycle status) by
- applying them DB-side in one arm and in-memory in the other.
+ (boolean custom fields, date custom fields) plus the period-scoped payment
+ aging filter, by applying them DB-side in one arm and in-memory in the other.
"""
use Mv.DataCase, async: false
use ExUnitProperties
@@ -21,10 +21,8 @@ defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do
alias Mv.Membership.Member
alias MvWeb.MemberLive.Index
alias MvWeb.MemberLive.Index.DateFilter
- alias MvWeb.MemberLive.Index.MembershipFeeStatus
alias MvWeb.MemberLive.Index.OverviewQuery
- @today ~D[2024-07-15]
@cycle_starts [~D[2023-01-01], ~D[2024-01-01], ~D[2025-01-01]]
@date_values [~D[2023-06-01], ~D[2024-06-15], ~D[2025-06-01]]
@@ -74,8 +72,14 @@ defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do
defp filter_params_gen do
StreamData.fixed_map(%{
- cycle_status: StreamData.member_of([nil, :paid, :unpaid]),
- show_current: StreamData.boolean(),
+ payment:
+ StreamData.member_of([
+ nil,
+ :fully_paid,
+ {:has_unpaid, 1},
+ {:has_unpaid, 2},
+ {:has_unpaid, 3}
+ ]),
bool: StreamData.member_of([nil, true, false]),
date_range: StreamData.member_of([nil, {~D[2024-01-01], ~D[2024-12-31]}]),
group: StreamData.member_of([nil, :in, :not_in]),
@@ -144,16 +148,16 @@ defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do
Map.merge(opts, %{fee_type_filters: %{to_string(ctx.ft1.id) => dir}, fee_types: [ctx.ft1]})
defp moved_opts(params, ctx) do
- %{today: @today}
- |> put_cycle(params.cycle_status, params.show_current)
+ %{}
+ |> put_payment(params.payment)
|> put_bool(params.bool, ctx)
|> put_date(params.date_range, ctx)
end
- defp put_cycle(opts, nil, _show), do: opts
+ defp put_payment(opts, nil), do: opts
- defp put_cycle(opts, status, show),
- do: Map.merge(opts, %{cycle_status_filter: status, show_current_cycle: show})
+ defp put_payment(opts, filter),
+ do: Map.merge(opts, %{payment_filter: filter, payment_period: %{from: nil, to: nil}})
defp put_bool(opts, nil, _ctx), do: opts
@@ -195,7 +199,7 @@ defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do
base_members
|> apply_oracle_bool(params, ctx)
|> apply_oracle_date(params, ctx)
- |> apply_oracle_cycle(params)
+ |> apply_oracle_payment(params)
|> MapSet.new(& &1.id)
end
@@ -219,14 +223,22 @@ defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do
)
end
- defp apply_oracle_cycle(members, %{cycle_status: nil}), do: members
+ defp apply_oracle_payment(members, %{payment: nil}), do: members
- defp apply_oracle_cycle(members, %{cycle_status: status, show_current: show}) do
- Enum.filter(members, fn m ->
- MembershipFeeStatus.get_cycle_status_for_member(m, show, @today) == status
- end)
+ defp apply_oracle_payment(members, %{payment: filter}) do
+ Enum.filter(members, fn m -> payment_match?(unpaid_count(m), filter) end)
end
+ # Member-relative count of unpaid cycles (all-time period, so every unpaid
+ # cycle counts); suspended and paid are excluded, mirroring the calculation.
+ defp unpaid_count(member) do
+ member.membership_fee_cycles
+ |> Enum.count(&(&1.status == :unpaid))
+ end
+
+ defp payment_match?(count, :fully_paid), do: count == 0
+ defp payment_match?(count, {:has_unpaid, n}), do: count >= n
+
property "DB-backed filter result set equals the in-memory oracle", ctx do
check all(
specs <- StreamData.list_of(member_spec_gen(), min_length: 1, max_length: 5),
diff --git a/test/mv_web/member_live/index_groups_accessibility_test.exs b/test/mv_web/member_live/index_groups_accessibility_test.exs
index bfe47c43..34cea277 100644
--- a/test/mv_web/member_live/index_groups_accessibility_test.exs
+++ b/test/mv_web/member_live/index_groups_accessibility_test.exs
@@ -65,21 +65,22 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do
end
@tag :ui
- test "filter dropdown has group presence section with legend", %{
+ test "field picker exposes a combobox with a Group field option", %{
conn: conn
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
- # Open filter dropdown
- view
- |> element("button[aria-label='Filter members']")
- |> render_click()
+ # Open the add-filter picker (combobox + listbox of grouped fields)
+ open_picker(view)
- html = render(view)
- # Groups section: legend "Member has groups" and radios (Any / Yes / No)
- assert html =~ ~r/[Gg]roups/
- assert has_element?(view, "[data-testid='member-filter-form']")
+ assert has_element?(view, "[data-testid='field-combobox'][role='combobox']")
+ assert has_element?(view, "#member-filter-field-listbox[role='listbox']")
+ assert has_element?(view, "#member-filter-field-opt-group[role='option']")
+
+ # Picking the Group field opens its value control with per-group legends.
+ view |> element("#member-filter-field-opt-group") |> render_click()
+ assert has_element?(view, "[data-testid='value-control-group']")
end
@tag :ui
@@ -102,13 +103,7 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
- view
- |> element("button[aria-label='Filter members']")
- |> render_click()
-
- view
- |> element("[data-testid='member-filter-form']")
- |> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
+ apply_group_filter(view, group1.id, "in")
html = render(view)
assert html =~ member1.first_name
@@ -140,13 +135,7 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
- view
- |> element("button[aria-label='Filter members']")
- |> render_click()
-
- view
- |> element("[data-testid='member-filter-form']")
- |> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
+ apply_group_filter(view, group1.id, "in")
html = render(view)
assert html =~ member1.first_name
diff --git a/test/mv_web/member_live/index_groups_filter_test.exs b/test/mv_web/member_live/index_groups_filter_test.exs
index c8d5160a..639b3992 100644
--- a/test/mv_web/member_live/index_groups_filter_test.exs
+++ b/test/mv_web/member_live/index_groups_filter_test.exs
@@ -62,20 +62,7 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
end
defp open_filter_and_set_group(view, group_id, value) do
- view
- |> element("button[aria-label='Filter members']")
- |> render_click()
-
- key = "group_#{group_id}"
-
- view
- |> element("[data-testid='member-filter-form']")
- |> render_change(%{key => value, "payment_filter" => "all"})
-
- # Force LiveView to process {:group_filter_changed, ...} (render triggers mailbox processing)
- _ = render(view)
- # Wait for patch; return path so callers can assert URL contains expected filter param
- path = assert_patch(view)
+ path = apply_group_filter(view, group_id, value)
{view, path}
end
diff --git a/test/mv_web/member_live/index_groups_integration_test.exs b/test/mv_web/member_live/index_groups_integration_test.exs
index cae9ce16..0e150a57 100644
--- a/test/mv_web/member_live/index_groups_integration_test.exs
+++ b/test/mv_web/member_live/index_groups_integration_test.exs
@@ -103,13 +103,7 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
- view
- |> element("button[aria-label='Filter members']")
- |> render_click()
-
- view
- |> element("[data-testid='member-filter-form']")
- |> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
+ apply_group_filter(view, group1.id, "in")
html = render(view)
assert html =~ member1.first_name
@@ -174,10 +168,10 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} =
- live(conn, "/members?group_#{group1.id}=in&cycle_status_filter=paid")
+ live(conn, "/members?group_#{group1.id}=in&pay_filter=fully_paid")
assert html =~ "Members"
- # member1 has a group and a paid cycle; page should load with both filters
+ # member1 has a group and no unpaid cycles; both filters keep it visible.
assert html =~ member1.first_name
end
@@ -191,13 +185,7 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
{:ok, view, _html} = live(conn, "/members")
# Apply group filter
- view
- |> element("button[aria-label='Filter members']")
- |> render_click()
-
- view
- |> element("[data-testid='member-filter-form']")
- |> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
+ apply_group_filter(view, group1.id, "in")
# Apply search (this tests that filter and search work together;
# search form is in SearchBarComponent with phx-submit="search")
@@ -242,13 +230,7 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
{:ok, view, _html} = live(conn, "/members")
# Apply group filter
- view
- |> element("button[aria-label='Filter members']")
- |> render_click()
-
- view
- |> element("[data-testid='member-filter-form']")
- |> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
+ apply_group_filter(view, group1.id, "in")
# Apply sorting
view
diff --git a/test/mv_web/member_live/index_groups_performance_test.exs b/test/mv_web/member_live/index_groups_performance_test.exs
index 6d05908b..3634b172 100644
--- a/test/mv_web/member_live/index_groups_performance_test.exs
+++ b/test/mv_web/member_live/index_groups_performance_test.exs
@@ -102,13 +102,7 @@ defmodule MvWeb.MemberLive.IndexGroupsPerformanceTest do
{:ok, view, _html} = live(conn, "/members")
# Open filter and apply "Yes" for group1 (even-indexed members are in group1)
- view
- |> element("button[aria-label='Filter members']")
- |> render_click()
-
- view
- |> element("[data-testid='member-filter-form']")
- |> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
+ apply_group_filter(view, group1.id, "in")
# Force LiveView to process {:group_filter_changed, ...}
html = render(view)
diff --git a/test/mv_web/member_live/index_groups_url_params_test.exs b/test/mv_web/member_live/index_groups_url_params_test.exs
index 3de60923..22329ab3 100644
--- a/test/mv_web/member_live/index_groups_url_params_test.exs
+++ b/test/mv_web/member_live/index_groups_url_params_test.exs
@@ -73,13 +73,7 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
- view
- |> element("button[aria-label='Filter members']")
- |> render_click()
-
- view
- |> element("[data-testid='member-filter-form']")
- |> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
+ apply_group_filter(view, group1.id, "in")
html = render(view)
assert html =~ member1.first_name
diff --git a/test/mv_web/member_live/index_membership_fee_status_test.exs b/test/mv_web/member_live/index_membership_fee_status_test.exs
index 5ee23afb..4dd92c73 100644
--- a/test/mv_web/member_live/index_membership_fee_status_test.exs
+++ b/test/mv_web/member_live/index_membership_fee_status_test.exs
@@ -1,16 +1,15 @@
defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
@moduledoc """
- Tests for membership fee status column in member list view.
+ Tests for the period-scoped payment aging column in the member overview
+ (§1.13, §3.3). The column shows, per member, the count of unpaid cycles whose
+ `cycle_end` falls in the active period (default: all outstanding); 0 → "Paid";
+ suspended cycles are excluded from the count.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import Mv.Fixtures, only: [create_fee_type: 1, create_cycle: 3]
- alias Mv.MembershipFees.MembershipFeeCycle
-
- require Ash.Query
-
# Helper to create a member
defp create_member(attrs) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
@@ -26,253 +25,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
diff --git a/test/mv_web/member_live/index_payment_period_test.exs b/test/mv_web/member_live/index_payment_period_test.exs
index 1cf7c0ce..7752b307 100644
--- a/test/mv_web/member_live/index_payment_period_test.exs
+++ b/test/mv_web/member_live/index_payment_period_test.exs
@@ -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
diff --git a/test/mv_web/member_live/index_payment_wiring_test.exs b/test/mv_web/member_live/index_payment_wiring_test.exs
new file mode 100644
index 00000000..8991ca08
--- /dev/null
+++ b/test/mv_web/member_live/index_payment_wiring_test.exs
@@ -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
diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs
index fbff9f47..d8946529 100644
--- a/test/mv_web/member_live/index_test.exs
+++ b/test/mv_web/member_live/index_test.exs
@@ -473,17 +473,26 @@ defmodule MvWeb.MemberLive.IndexTest do
end
end
- # Opens the member-filter dropdown so its boolean filter controls are rendered.
- defp open_member_filter(view) do
- view
- |> element(~s(button[phx-click="toggle_dropdown"][aria-label="Filter members"]))
- |> render_click()
+ # Number of active add-filter chips currently rendered. The builder shows one
+ # chip per active filter value; this replaces the removed panel's active-count
+ # badge as the observable carrier of the filter count.
+ defp chip_count(view) do
+ render(view)
+ |> LazyHTML.from_fragment()
+ |> LazyHTML.query(~s([data-testid="filter-chip"]))
+ |> Enum.count()
end
- # Returns the rendered HTML of the member-filter dropdown (with it open).
- defp member_filter_html(view) do
- open_member_filter(view)
- render(view)
+ # Whether a boolean custom-field filter chip for `field` in the given state
+ # ("Yes"/"No") is rendered.
+ defp boolean_chip?(view, field, state) do
+ render(view) =~ "#{field.name}: #{state}"
+ end
+
+ # Whether the field picker offers a pickable option for the given custom field.
+ defp picker_has_field?(view, field_id) do
+ open_picker(view)
+ has_element?(view, "#member-filter-field-opt-#{field_id}")
end
describe "copy_emails feature" do
@@ -797,10 +806,8 @@ defmodule MvWeb.MemberLive.IndexTest do
assert html =~ "hero-chevron-down"
- # The bulk-actions trigger and the bespoke member-filter trigger each
- # carry their own chevron; assert the filter trigger's chevron is pinned
- # independently, so removing it from the filter component fails this test.
- assert has_element?(view, ".member-filter-dropdown .hero-chevron-down")
+ # The bulk-actions trigger carries its own trailing chevron.
+ assert has_element?(view, "[data-testid='bulk-actions-button'] .hero-chevron-down")
end
test "dropdown opens and closes on click", %{conn: conn} do
@@ -1055,7 +1062,7 @@ defmodule MvWeb.MemberLive.IndexTest do
test "scope is :filtered when a non-search filter is active", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid")
+ {:ok, _view, html} = live(conn, "/members?pay_filter=fully_paid")
badge = scope_badge(html)
assert badge |> LazyHTML.text() |> String.trim() == "filtered"
@@ -1162,230 +1169,6 @@ defmodule MvWeb.MemberLive.IndexTest do
end
end
- describe "cycle status filter" do
- # Helper to create a member (only used in this describe block)
- defp create_member(attrs, actor) do
- default_attrs = %{
- first_name: "Test",
- last_name: "Member",
- email: "test.member.#{System.unique_integer([:positive])}@example.com"
- }
-
- attrs = Map.merge(default_attrs, attrs)
- {:ok, member} = Membership.create_member(attrs, actor: actor)
- member
- end
-
- test "filter shows only members with paid status in last cycle", %{conn: conn} do
- system_actor = SystemActor.get_system_actor()
- conn = conn_with_oidc_user(conn)
- fee_type = create_fee_type(%{interval: :yearly}, system_actor)
- today = Date.utc_today()
- last_year_start = Date.new!(today.year - 1, 1, 1)
-
- # Member with paid last cycle
- paid_member =
- create_member(
- %{
- first_name: "PaidLast",
- membership_fee_type_id: fee_type.id
- },
- system_actor
- )
-
- create_cycle(
- paid_member,
- fee_type,
- %{cycle_start: last_year_start, status: :paid, replace_existing: true},
- system_actor
- )
-
- # Member with unpaid last cycle
- unpaid_member =
- create_member(
- %{
- first_name: "UnpaidLast",
- membership_fee_type_id: fee_type.id
- },
- system_actor
- )
-
- create_cycle(
- unpaid_member,
- fee_type,
- %{cycle_start: last_year_start, status: :unpaid, replace_existing: true},
- system_actor
- )
-
- {:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid")
-
- assert html =~ "PaidLast"
- refute html =~ "UnpaidLast"
- end
-
- test "filter shows only members with unpaid status in last cycle", %{conn: conn} do
- system_actor = SystemActor.get_system_actor()
- conn = conn_with_oidc_user(conn)
- fee_type = create_fee_type(%{interval: :yearly}, system_actor)
- today = Date.utc_today()
- last_year_start = Date.new!(today.year - 1, 1, 1)
-
- # Member with paid last cycle
- paid_member =
- create_member(
- %{
- first_name: "PaidLast",
- membership_fee_type_id: fee_type.id
- },
- system_actor
- )
-
- create_cycle(
- paid_member,
- fee_type,
- %{cycle_start: last_year_start, status: :paid, replace_existing: true},
- system_actor
- )
-
- # Member with unpaid last cycle
- unpaid_member =
- create_member(
- %{
- first_name: "UnpaidLast",
- membership_fee_type_id: fee_type.id
- },
- system_actor
- )
-
- create_cycle(
- unpaid_member,
- fee_type,
- %{cycle_start: last_year_start, status: :unpaid, replace_existing: true},
- system_actor
- )
-
- {:ok, _view, html} = live(conn, "/members?cycle_status_filter=unpaid")
-
- refute html =~ "PaidLast"
- assert html =~ "UnpaidLast"
- end
-
- test "filter shows only members with paid status in current cycle", %{conn: conn} do
- system_actor = SystemActor.get_system_actor()
- conn = conn_with_oidc_user(conn)
- fee_type = create_fee_type(%{interval: :yearly}, system_actor)
- today = Date.utc_today()
- current_year_start = Date.new!(today.year, 1, 1)
-
- # Member with paid current cycle
- paid_member =
- create_member(
- %{
- first_name: "PaidCurrent",
- membership_fee_type_id: fee_type.id
- },
- system_actor
- )
-
- create_cycle(
- paid_member,
- fee_type,
- %{cycle_start: current_year_start, status: :paid, replace_existing: true},
- system_actor
- )
-
- # Member with unpaid current cycle
- unpaid_member =
- create_member(
- %{
- first_name: "UnpaidCurrent",
- membership_fee_type_id: fee_type.id
- },
- system_actor
- )
-
- create_cycle(
- unpaid_member,
- fee_type,
- %{cycle_start: current_year_start, status: :unpaid, replace_existing: true},
- system_actor
- )
-
- {:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid&show_current_cycle=true")
-
- assert html =~ "PaidCurrent"
- refute html =~ "UnpaidCurrent"
- end
-
- test "filter shows only members with unpaid status in current cycle", %{conn: conn} do
- system_actor = SystemActor.get_system_actor()
- conn = conn_with_oidc_user(conn)
- fee_type = create_fee_type(%{interval: :yearly}, system_actor)
- today = Date.utc_today()
- current_year_start = Date.new!(today.year, 1, 1)
-
- # Member with paid current cycle
- paid_member =
- create_member(
- %{
- first_name: "PaidCurrent",
- membership_fee_type_id: fee_type.id
- },
- system_actor
- )
-
- create_cycle(
- paid_member,
- fee_type,
- %{cycle_start: current_year_start, status: :paid, replace_existing: true},
- system_actor
- )
-
- # Member with unpaid current cycle
- unpaid_member =
- create_member(
- %{
- first_name: "UnpaidCurrent",
- membership_fee_type_id: fee_type.id
- },
- system_actor
- )
-
- create_cycle(
- unpaid_member,
- fee_type,
- %{cycle_start: current_year_start, status: :unpaid, replace_existing: true},
- system_actor
- )
-
- {:ok, _view, html} =
- live(conn, "/members?cycle_status_filter=unpaid&show_current_cycle=true")
-
- refute html =~ "PaidCurrent"
- assert html =~ "UnpaidCurrent"
- end
-
- test "toggle cycle view updates URL and preserves filter", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
-
- # Start with last cycle view and paid filter
- {:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
-
- # Toggle to current cycle - this should update URL and preserve filter
- # Use the button in the toolbar
- view
- |> element("button[phx-click='toggle_cycle_view']")
- |> render_click()
-
- # Wait for patch to complete
- path = assert_patch(view)
-
- # URL should contain both filter and show_current_cycle
- assert path =~ "cycle_status_filter=paid"
- assert path =~ "show_current_cycle=true"
- end
- end
-
describe "boolean custom field filters" do
# Helper to create a boolean custom field (uses system actor for authorization)
defp create_boolean_custom_field(attrs \\ %{}) do
@@ -1454,12 +1237,13 @@ defmodule MvWeb.MemberLive.IndexTest do
string_field = create_string_custom_field(%{name: "Phone Number"})
{:ok, view, _html} = live(conn, "/members")
- open_member_filter(view)
+ open_picker(view)
- # Only the boolean fields render a tri-state filter control; the string field does not.
- assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field1.id}-all"}")
- assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field2.id}-all"}")
- refute has_element?(view, "##{"custom-boolean-filter-#{string_field.id}-all"}")
+ # Only the boolean fields are offered as pickable filter fields; the string
+ # field is not filterable on the overview.
+ assert has_element?(view, "#member-filter-field-opt-#{boolean_field1.id}")
+ assert has_element?(view, "#member-filter-field-opt-#{boolean_field2.id}")
+ refute has_element?(view, "#member-filter-field-opt-#{string_field.id}")
end
test "mount sorts boolean custom fields by name ascending", %{conn: conn} do
@@ -1471,19 +1255,16 @@ defmodule MvWeb.MemberLive.IndexTest do
field_m = create_boolean_custom_field(%{name: "Middle Field"})
{:ok, view, _html} = live(conn, "/members")
- html = member_filter_html(view)
+ open_picker(view)
- # The rendered boolean filter controls appear in name-ascending order.
+ # The picker lists the boolean custom fields in name-ascending order.
rendered_order =
- html
+ render(view)
|> LazyHTML.from_fragment()
- |> LazyHTML.query(~s(input[id$="-all"][name^="custom_boolean"]))
+ |> LazyHTML.query(~s(#member-filter-field-listbox [role="option"]))
|> LazyHTML.attribute("id")
- |> Enum.map(
- &(&1
- |> String.replace_prefix("custom-boolean-filter-", "")
- |> String.replace_suffix("-all", ""))
- )
+ |> Enum.map(&String.replace_prefix(&1, "member-filter-field-opt-", ""))
+ |> Enum.filter(&(&1 in [field_a.id, field_m.id, field_z.id]))
assert rendered_order == [field_a.id, field_m.id, field_z.id]
end
@@ -1492,17 +1273,14 @@ defmodule MvWeb.MemberLive.IndexTest do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
- # Test true value: the "Yes" radio is checked (the boolean true, not the string "true").
+ # Test true value: a "Yes" chip for the field (the boolean true, not "true").
{:ok, view1, _html} = live(conn, "/members?bf_#{boolean_field.id}=true")
- open_member_filter(view1)
- assert has_element?(view1, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]")
- refute has_element?(view1, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]")
+ assert boolean_chip?(view1, boolean_field, "Yes")
+ refute boolean_chip?(view1, boolean_field, "No")
- # Test false value: the "No" radio is checked.
+ # Test false value: a "No" chip for the field.
{:ok, view2, _html} = live(conn, "/members?bf_#{boolean_field.id}=false")
- open_member_filter(view2)
- assert has_element?(view2, "##{"custom-boolean-filter-#{boolean_field.id}-false"}[checked]")
- refute has_element?(view2, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]")
+ assert boolean_chip?(view2, boolean_field, "No")
end
test "handle_params ignores non-existent custom field IDs", %{conn: conn} do
@@ -1510,11 +1288,10 @@ defmodule MvWeb.MemberLive.IndexTest do
fake_id = Ecto.UUID.generate()
{:ok, view, _html} = live(conn, "/members?bf_#{fake_id}=true")
- open_member_filter(view)
- # No filter control exists for a non-existent field, and no active-filter badge appears.
- refute has_element?(view, "##{"custom-boolean-filter-#{fake_id}-true"}")
- refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active))
+ # A non-existent field is not offered in the picker and produces no chip.
+ refute picker_has_field?(view, fake_id)
+ assert chip_count(view) == 0
end
test "handle_params ignores non-boolean custom fields", %{conn: conn} do
@@ -1522,11 +1299,10 @@ defmodule MvWeb.MemberLive.IndexTest do
string_field = create_string_custom_field()
{:ok, view, _html} = live(conn, "/members?bf_#{string_field.id}=true")
- open_member_filter(view)
- # A string field is never rendered as a boolean filter, and no filter becomes active.
- refute has_element?(view, "##{"custom-boolean-filter-#{string_field.id}-true"}")
- refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active))
+ # A string field is never a filterable field, and no filter becomes active.
+ refute picker_has_field?(view, string_field.id)
+ assert chip_count(view) == 0
end
test "handle_params ignores invalid filter values", %{conn: conn} do
@@ -1538,11 +1314,10 @@ defmodule MvWeb.MemberLive.IndexTest do
for invalid_value <- invalid_values do
{:ok, view, _html} = live(conn, "/members?bf_#{boolean_field.id}=#{invalid_value}")
- open_member_filter(view)
- # An invalid value leaves the field's filter at "All" (no filter applied).
- assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]"),
- "Invalid value '#{invalid_value}' should leave the filter at All"
+ # An invalid value applies no filter, so no chip is rendered.
+ assert chip_count(view) == 0,
+ "Invalid value '#{invalid_value}' should leave the filter unset"
end
end
@@ -1557,16 +1332,11 @@ defmodule MvWeb.MemberLive.IndexTest do
"/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false"
)
- open_member_filter(view)
-
- # Both filters are reflected: field1 at "Yes", field2 at "No", and the
- # active-filter count badge shows 2.
- assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field1.id}-true"}[checked]")
- assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field2.id}-false"}[checked]")
-
- assert view
- |> element(~s(button[aria-label="Filter members"] .badge), "2")
- |> has_element?()
+ # Both filters are reflected as chips: field1 at "Yes", field2 at "No", for
+ # a total of two active-filter chips.
+ assert boolean_chip?(view, boolean_field1, "Yes")
+ assert boolean_chip?(view, boolean_field2, "No")
+ assert chip_count(view) == 2
end
test "build_query_params includes active boolean filters and excludes nil filters", %{
@@ -1630,22 +1400,20 @@ defmodule MvWeb.MemberLive.IndexTest do
assert path2 =~ "bf_#{boolean_field.id}=true"
end
- test "boolean filters work together with cycle_status_filter", %{conn: conn} do
+ test "boolean filters work together with the payment filter", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
{:ok, view, _html} =
live(
conn,
- "/members?cycle_status_filter=paid&bf_#{boolean_field.id}=true"
+ "/members?pay_filter=fully_paid&bf_#{boolean_field.id}=true"
)
- open_member_filter(view)
-
- # Both filters are reflected in the rendered controls: the boolean field at
- # "Yes" and the payment-status filter at "paid".
- assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-true"}[checked]")
- assert has_element?(view, "#payment-filter-paid[checked]")
+ # Both filters are reflected as chips: the boolean field at "Yes" and the
+ # period-scoped payment filter as "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_)", %{conn: conn} do
@@ -1708,12 +1471,8 @@ defmodule MvWeb.MemberLive.IndexTest do
{:ok, view, _html} =
live(conn, "/members?bf_bf_#{boolean_field.id}=true")
- open_member_filter(view)
-
- # The double-prefixed param is not a valid filter: the field stays at "All"
- # and no filter is active.
- assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]")
- refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active))
+ # The double-prefixed param is not a valid filter: no chip is active.
+ assert chip_count(view) == 0
end
test "handle_params limits number of boolean filters to prevent DoS", %{conn: conn} do
@@ -1728,11 +1487,9 @@ defmodule MvWeb.MemberLive.IndexTest do
{:ok, view, _html} = live(conn, "/members?#{filter_params}")
- # The active-filter count badge is the observable carrier of the filter count.
+ # The active-filter chips are the observable carrier of the filter count.
# With 60 requested filters, the DoS cap clamps it to exactly 50.
- assert view
- |> element(~s(button[aria-label="Filter members"] .badge), "50")
- |> has_element?()
+ assert chip_count(view) == 50
end
test "handle_params ignores extremely long custom field IDs", %{conn: conn} do
@@ -1745,12 +1502,10 @@ defmodule MvWeb.MemberLive.IndexTest do
{:ok, view, _html} =
live(conn, "/members?bf_#{fake_long_id}=true")
- open_member_filter(view)
-
- # The over-long ID is rejected: the real field stays at "All" and no filter
- # is active.
- assert has_element?(view, "##{"custom-boolean-filter-#{boolean_field.id}-all"}[checked]")
- refute has_element?(view, ~s(button[aria-label="Filter members"].btn-active))
+ # The over-long ID is rejected: no filter is active. (`boolean_field` is
+ # created only to prove a real field is unaffected.)
+ assert boolean_field.id
+ assert chip_count(view) == 0
end
# Helper to create a member with a boolean custom field value
@@ -2092,7 +1847,7 @@ defmodule MvWeb.MemberLive.IndexTest do
refute html_false =~ "NoValue"
end
- test "boolean filter integration works together with cycle_status_filter", %{conn: conn} do
+ test "boolean filter integration works together with the payment filter", %{conn: conn} do
system_actor = SystemActor.get_system_actor()
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field()
@@ -2156,11 +1911,12 @@ defmodule MvWeb.MemberLive.IndexTest do
system_actor
)
- # Test both filters together
+ # Test both filters together: fully-paid (0 unpaid cycles in the period)
+ # plus the boolean field at "Yes".
{:ok, _view, html} =
- live(conn, "/members?cycle_status_filter=paid&bf_#{boolean_field.id}=true")
+ live(conn, "/members?pay_filter=fully_paid&bf_#{boolean_field.id}=true")
- # Only member_paid_true should match both filters
+ # Only member_paid_true has zero unpaid cycles and the boolean value true.
assert html =~ "PaidTrue"
refute html =~ "UnpaidTrue"
end
@@ -2246,25 +2002,25 @@ defmodule MvWeb.MemberLive.IndexTest do
end
@tag :ui
- test "boolean custom field appears in filter dropdown after being added", %{conn: conn} do
+ test "boolean custom field appears in the field picker after being added", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Start with no boolean custom fields
{:ok, view, _html} = live(conn, "/members")
- open_member_filter(view)
+ open_picker(view)
- # No boolean field control is rendered yet.
- refute has_element?(view, ~s(input[name^="custom_boolean"]))
+ # With no custom fields, the picker renders no "Custom fields" group.
+ refute has_element?(view, "[data-testid='field-group-header']", "Custom fields")
# Create a new boolean custom field
new_boolean_field = create_boolean_custom_field(%{name: "Newly Added Field"})
- # Navigate again - the new field should appear in the filter dropdown.
+ # Navigate again - the new field should appear in the picker.
{:ok, view2, _html} = live(conn, "/members")
- html_after = member_filter_html(view2)
+ open_picker(view2)
- assert has_element?(view2, "##{"custom-boolean-filter-#{new_boolean_field.id}-all"}")
- assert html_after =~ "Newly Added Field"
+ assert has_element?(view2, "#member-filter-field-opt-#{new_boolean_field.id}")
+ assert render(view2) =~ "Newly Added Field"
end
@tag :slow
diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex
index f924a55f..7fb6dfec 100644
--- a/test/support/conn_case.ex
+++ b/test/support/conn_case.ex
@@ -36,6 +36,7 @@ defmodule MvWeb.ConnCase do
import Plug.Conn
import Phoenix.ConnTest
import MvWeb.ConnCase
+ import MvWeb.FilterBuilderHelpers
end
end
diff --git a/test/support/filter_builder_helpers.ex b/test/support/filter_builder_helpers.ex
new file mode 100644
index 00000000..54a88953
--- /dev/null
+++ b/test/support/filter_builder_helpers.ex
@@ -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