Merge pull request 'Implement filter for has_paid closes #227' (#237) from feature/227_payment_filter into main
All checks were successful
continuous-integration/drone/push Build is passing

Reviewed-on: #237
This commit is contained in:
rafael 2025-12-02 16:12:42 +01:00
commit ac2ad0a0d5
9 changed files with 833 additions and 81 deletions

View file

@ -0,0 +1,146 @@
defmodule MvWeb.Components.PaymentFilterComponent do
@moduledoc """
Provides the PaymentFilter Live-Component.
A dropdown filter for filtering members by payment status (paid/not paid/all).
Uses DaisyUI dropdown styling and sends filter changes to parent LiveView.
## Props
- `:paid_filter` - Current filter state: `nil` (all), `:paid`, or `:not_paid`
- `:id` - Component ID (required)
- `:member_count` - Number of filtered members to display in badge (optional, default: 0)
## Events
- Sends `{:payment_filter_changed, filter}` to parent when filter changes
"""
use MvWeb, :live_component
@impl true
def mount(socket) do
{:ok, assign(socket, :open, false)}
end
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(:id, assigns.id)
|> assign(:paid_filter, assigns[:paid_filter])
|> assign(:member_count, assigns[:member_count] || 0)
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<div
class="relative"
id={@id}
phx-window-keydown={@open && "close_dropdown"}
phx-key="Escape"
phx-target={@myself}
>
<button
type="button"
class={[
"btn btn-ghost gap-2",
@paid_filter && "btn-active"
]}
phx-click="toggle_dropdown"
phx-target={@myself}
aria-haspopup="true"
aria-expanded={to_string(@open)}
aria-label={gettext("Filter by payment status")}
>
<.icon name="hero-funnel" class="h-5 w-5" />
<span class="hidden sm:inline">{filter_label(@paid_filter)}</span>
<span :if={@paid_filter} class="badge badge-primary badge-sm">{@member_count}</span>
</button>
<ul
:if={@open}
class="menu dropdown-content bg-base-100 rounded-box z-10 w-52 p-2 shadow-lg absolute right-0 mt-2"
role="menu"
aria-label={gettext("Payment filter")}
phx-click-away="close_dropdown"
phx-target={@myself}
>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@paid_filter == nil)}
class={@paid_filter == nil && "active"}
phx-click="select_filter"
phx-value-filter=""
phx-target={@myself}
>
<.icon name="hero-users" class="h-4 w-4" />
{gettext("All")}
</button>
</li>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@paid_filter == :paid)}
class={@paid_filter == :paid && "active"}
phx-click="select_filter"
phx-value-filter="paid"
phx-target={@myself}
>
<.icon name="hero-check-circle" class="h-4 w-4 text-success" />
{gettext("Paid")}
</button>
</li>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@paid_filter == :not_paid)}
class={@paid_filter == :not_paid && "active"}
phx-click="select_filter"
phx-value-filter="not_paid"
phx-target={@myself}
>
<.icon name="hero-x-circle" class="h-4 w-4 text-error" />
{gettext("Not paid")}
</button>
</li>
</ul>
</div>
"""
end
@impl true
def handle_event("toggle_dropdown", _params, socket) do
{:noreply, assign(socket, :open, !socket.assigns.open)}
end
@impl true
def handle_event("close_dropdown", _params, socket) do
{:noreply, assign(socket, :open, false)}
end
@impl true
def handle_event("select_filter", %{"filter" => filter_str}, socket) do
filter = parse_filter(filter_str)
# Close dropdown and notify parent
socket = assign(socket, :open, false)
send(self(), {:payment_filter_changed, filter})
{:noreply, socket}
end
# Parse filter string to atom
defp parse_filter("paid"), do: :paid
defp parse_filter("not_paid"), do: :not_paid
defp parse_filter(_), do: nil
# Get display label for current filter
defp filter_label(nil), do: gettext("All")
defp filter_label(:paid), do: gettext("Paid")
defp filter_label(:not_paid), do: gettext("Not paid")
end

View file

@ -46,7 +46,7 @@ defmodule MvWeb.MemberLive.Index do
Initializes the LiveView state. Initializes the LiveView state.
Sets up initial assigns for page title, search query, sort configuration, Sets up initial assigns for page title, search query, sort configuration,
and member selection. Actual data loading happens in `handle_params/3`. payment filter, and member selection. Actual data loading happens in `handle_params/3`.
""" """
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
@ -74,6 +74,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:query, "") |> assign(:query, "")
|> assign_new(:sort_field, fn -> :first_name end) |> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end) |> assign_new(:sort_order, fn -> :asc end)
|> assign(:paid_filter, nil)
|> assign(:selected_members, MapSet.new()) |> assign(:selected_members, MapSet.new())
|> assign(:custom_fields_visible, custom_fields_visible) |> assign(:custom_fields_visible, custom_fields_visible)
|> assign(:member_fields_visible, get_visible_member_fields(settings)) |> assign(:member_fields_visible, get_visible_member_fields(settings))
@ -207,17 +208,17 @@ defmodule MvWeb.MemberLive.Index do
@impl true @impl true
def handle_info({:search_changed, q}, socket) do def handle_info({:search_changed, q}, socket) do
socket = load_members(socket, q) socket =
socket
|> assign(:query, q)
|> load_members()
existing_field_query = socket.assigns.sort_field existing_field_query = socket.assigns.sort_field
existing_sort_query = socket.assigns.sort_order existing_sort_query = socket.assigns.sort_order
# Build the URL with queries # Build the URL with queries
query_params = %{ query_params =
"query" => q, build_query_params(q, existing_field_query, existing_sort_query, socket.assigns.paid_filter)
"sort_field" => existing_field_query,
"sort_order" => existing_sort_query
}
# Set the new path with params # Set the new path with params
new_path = ~p"/members?#{query_params}" new_path = ~p"/members?#{query_params}"
@ -230,13 +231,38 @@ defmodule MvWeb.MemberLive.Index do
)} )}
end end
@impl true
def handle_info({:payment_filter_changed, filter}, socket) do
socket =
socket
|> assign(:paid_filter, filter)
|> load_members()
# Build the URL with all params including new filter
query_params =
build_query_params(
socket.assigns.query,
socket.assigns.sort_field,
socket.assigns.sort_order,
filter
)
new_path = ~p"/members?#{query_params}"
{:noreply,
push_patch(socket,
to: new_path,
replace: true
)}
end
# ----------------------------------------------------------------- # -----------------------------------------------------------------
# Handle Params from the URL # Handle Params from the URL
# ----------------------------------------------------------------- # -----------------------------------------------------------------
@doc """ @doc """
Handles URL parameter changes. Handles URL parameter changes.
Parses query parameters for search query, sort field, and sort order, Parses query parameters for search query, sort field, sort order, and payment filter,
then loads members accordingly. This enables bookmarkable URLs and then loads members accordingly. This enables bookmarkable URLs and
browser back/forward navigation. browser back/forward navigation.
""" """
@ -246,7 +272,9 @@ defmodule MvWeb.MemberLive.Index do
socket socket
|> maybe_update_search(params) |> maybe_update_search(params)
|> maybe_update_sort(params) |> maybe_update_sort(params)
|> load_members(params["query"]) |> maybe_update_paid_filter(params)
|> assign(:query, params["query"])
|> load_members()
|> prepare_dynamic_cols() |> prepare_dynamic_cols()
{:noreply, socket} {:noreply, socket}
@ -337,11 +365,13 @@ defmodule MvWeb.MemberLive.Index do
field field
end end
query_params = %{ query_params =
"query" => socket.assigns.query, build_query_params(
"sort_field" => field_str, socket.assigns.query,
"sort_order" => Atom.to_string(order) field_str,
} Atom.to_string(order),
socket.assigns.paid_filter
)
new_path = ~p"/members?#{query_params}" new_path = ~p"/members?#{query_params}"
@ -352,13 +382,45 @@ defmodule MvWeb.MemberLive.Index do
)} )}
end end
# Loads members from the database with custom field values and applies search/sort filters. # Builds URL query parameters map including all filter/sort state.
# Converts paid_filter atom to string for URL.
defp build_query_params(query, sort_field, sort_order, paid_filter) do
field_str =
if is_atom(sort_field) do
Atom.to_string(sort_field)
else
sort_field
end
order_str =
if is_atom(sort_order) do
Atom.to_string(sort_order)
else
sort_order
end
base_params = %{
"query" => query,
"sort_field" => field_str,
"sort_order" => order_str
}
# Only add paid_filter to URL if it's set
case paid_filter do
nil -> base_params
:paid -> Map.put(base_params, "paid_filter", "paid")
:not_paid -> Map.put(base_params, "paid_filter", "not_paid")
end
end
# Loads members from the database with custom field values and applies search/sort/payment filters.
# #
# Process: # Process:
# 1. Builds base query with selected fields # 1. Builds base query with selected fields
# 2. Loads custom field values for visible custom fields (filtered at database level) # 2. Loads custom field values for visible custom fields (filtered at database level)
# 3. Applies search filter if provided # 3. Applies search filter if provided
# 4. Applies sorting (database-level for regular fields, in-memory for custom fields) # 4. Applies payment status filter if set
# 5. Applies sorting (database-level for regular fields, in-memory for custom fields)
# #
# Performance Considerations: # Performance Considerations:
# - Database-level filtering: Custom field values are filtered directly in the database # - Database-level filtering: Custom field values are filtered directly in the database
@ -370,7 +432,9 @@ defmodule MvWeb.MemberLive.Index do
# consider implementing pagination (see Issue #165). # consider implementing pagination (see Issue #165).
# #
# Returns the socket with `:members` assigned. # Returns the socket with `:members` assigned.
defp load_members(socket, search_query) do defp load_members(socket) do
search_query = socket.assigns.query
query = query =
Mv.Membership.Member Mv.Membership.Member
|> Ash.Query.new() |> Ash.Query.new()
@ -383,6 +447,9 @@ defmodule MvWeb.MemberLive.Index do
# Apply the search filter first # Apply the search filter first
query = apply_search_filter(query, search_query) query = apply_search_filter(query, search_query)
# Apply payment status filter
query = apply_paid_filter(query, socket.assigns.paid_filter)
# Apply sorting based on current socket state # Apply sorting based on current socket state
# For custom fields, we sort after loading # For custom fields, we sort after loading
{query, sort_after_load} = {query, sort_after_load} =
@ -457,6 +524,24 @@ defmodule MvWeb.MemberLive.Index do
end end
end end
# Applies payment status filter to the query.
#
# Filter values:
# - nil: No filter, return all members
# - :paid: Only members with paid == true
# - :not_paid: Members with paid == false or paid == nil (not paid)
defp apply_paid_filter(query, nil), do: query
defp apply_paid_filter(query, :paid) do
Ash.Query.filter(query, expr(paid == true))
end
defp apply_paid_filter(query, :not_paid) do
# Include both false and nil as "not paid"
# Note: paid != true doesn't work correctly with NULL values in SQL
Ash.Query.filter(query, expr(paid == false or is_nil(paid)))
end
# Functions to toggle sorting order # Functions to toggle sorting order
defp toggle_order(:asc), do: :desc defp toggle_order(:asc), do: :desc
defp toggle_order(:desc), do: :asc defp toggle_order(:desc), do: :asc
@ -747,6 +832,29 @@ defmodule MvWeb.MemberLive.Index do
socket socket
end end
# Updates paid filter from URL parameters if present.
#
# Validates the filter value, falling back to nil (no filter) if invalid.
defp maybe_update_paid_filter(socket, %{"paid_filter" => filter_str}) do
filter = determine_paid_filter(filter_str)
assign(socket, :paid_filter, filter)
end
defp maybe_update_paid_filter(socket, _params) do
# Reset filter if not in URL params
assign(socket, :paid_filter, nil)
end
# Determines valid paid filter from URL parameter.
#
# SECURITY: This function whitelists allowed filter values. Only "paid" and "not_paid"
# are accepted - all other input (including malicious strings) falls back to nil.
# This ensures no raw user input is ever passed to Ash.Query.filter/2, following
# Ash's security recommendation to never pass untrusted input directly to filters.
defp determine_paid_filter("paid"), do: :paid
defp determine_paid_filter("not_paid"), do: :not_paid
defp determine_paid_filter(_), do: nil
# ------------------------------------------------------------- # -------------------------------------------------------------
# Helper Functions for Custom Field Values # Helper Functions for Custom Field Values
# ------------------------------------------------------------- # -------------------------------------------------------------

View file

@ -26,12 +26,20 @@
</:actions> </:actions>
</.header> </.header>
<.live_component <div class="flex flex-wrap gap-4 items-center">
module={MvWeb.Components.SearchBarComponent} <.live_component
id="search-bar" module={MvWeb.Components.SearchBarComponent}
query={@query} id="search-bar"
placeholder={gettext("Search...")} query={@query}
/> placeholder={gettext("Search...")}
/>
<.live_component
module={MvWeb.Components.PaymentFilterComponent}
id="payment-filter"
paid_filter={@paid_filter}
member_count={length(@members)}
/>
</div>
<.table <.table
id="members" id="members"
@ -213,6 +221,14 @@
> >
{member.join_date} {member.join_date}
</:col> </:col>
<:col :let={member} label={gettext("Paid")}>
<span class={[
"badge",
if(member.paid == true, do: "badge-success", else: "badge-error")
]}>
{if member.paid == true, do: gettext("Yes"), else: gettext("No")}
</span>
</:col>
<:action :let={member}> <:action :let={member}>
<div class="sr-only"> <div class="sr-only">
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link> <.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>

View file

@ -15,7 +15,7 @@ msgstr ""
msgid "Actions" msgid "Actions"
msgstr "Aktionen" msgstr "Aktionen"
#: lib/mv_web/live/member_live/index.html.heex:227 #: lib/mv_web/live/member_live/index.html.heex:243
#: lib/mv_web/live/user_live/index.html.heex:72 #: lib/mv_web/live/user_live/index.html.heex:72
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Are you sure?" msgid "Are you sure?"
@ -28,19 +28,19 @@ msgid "Attempting to reconnect"
msgstr "Verbindung wird wiederhergestellt" msgstr "Verbindung wird wiederhergestellt"
#: lib/mv_web/live/member_live/form.ex:54 #: lib/mv_web/live/member_live/form.ex:54
#: lib/mv_web/live/member_live/index.html.heex:171 #: lib/mv_web/live/member_live/index.html.heex:179
#: lib/mv_web/live/member_live/show.ex:59 #: lib/mv_web/live/member_live/show.ex:59
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "City" msgid "City"
msgstr "Stadt" msgstr "Stadt"
#: lib/mv_web/live/member_live/index.html.heex:229 #: lib/mv_web/live/member_live/index.html.heex:245
#: lib/mv_web/live/user_live/index.html.heex:74 #: lib/mv_web/live/user_live/index.html.heex:74
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Delete" msgid "Delete"
msgstr "Löschen" msgstr "Löschen"
#: lib/mv_web/live/member_live/index.html.heex:221 #: lib/mv_web/live/member_live/index.html.heex:237
#: lib/mv_web/live/user_live/form.ex:265 #: lib/mv_web/live/user_live/form.ex:265
#: lib/mv_web/live/user_live/index.html.heex:66 #: lib/mv_web/live/user_live/index.html.heex:66
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -54,7 +54,7 @@ msgid "Edit Member"
msgstr "Mitglied bearbeiten" msgstr "Mitglied bearbeiten"
#: lib/mv_web/live/member_live/form.ex:47 #: lib/mv_web/live/member_live/form.ex:47
#: lib/mv_web/live/member_live/index.html.heex:99 #: lib/mv_web/live/member_live/index.html.heex:107
#: lib/mv_web/live/member_live/show.ex:50 #: lib/mv_web/live/member_live/show.ex:50
#: lib/mv_web/live/user_live/form.ex:46 #: lib/mv_web/live/user_live/form.ex:46
#: lib/mv_web/live/user_live/index.html.heex:44 #: lib/mv_web/live/user_live/index.html.heex:44
@ -70,7 +70,7 @@ msgid "First Name"
msgstr "Vorname" msgstr "Vorname"
#: lib/mv_web/live/member_live/form.ex:51 #: lib/mv_web/live/member_live/form.ex:51
#: lib/mv_web/live/member_live/index.html.heex:207 #: lib/mv_web/live/member_live/index.html.heex:215
#: lib/mv_web/live/member_live/show.ex:56 #: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Join Date" msgid "Join Date"
@ -87,7 +87,7 @@ msgstr "Nachname"
msgid "New Member" msgid "New Member"
msgstr "Neues Mitglied" msgstr "Neues Mitglied"
#: lib/mv_web/live/member_live/index.html.heex:218 #: lib/mv_web/live/member_live/index.html.heex:234
#: lib/mv_web/live/user_live/index.html.heex:63 #: lib/mv_web/live/user_live/index.html.heex:63
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Show" msgid "Show"
@ -121,7 +121,7 @@ msgid "Exit Date"
msgstr "Austrittsdatum" msgstr "Austrittsdatum"
#: lib/mv_web/live/member_live/form.ex:56 #: lib/mv_web/live/member_live/form.ex:56
#: lib/mv_web/live/member_live/index.html.heex:135 #: lib/mv_web/live/member_live/index.html.heex:143
#: lib/mv_web/live/member_live/show.ex:61 #: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "House Number" msgid "House Number"
@ -133,21 +133,24 @@ msgstr "Hausnummer"
msgid "Notes" msgid "Notes"
msgstr "Notizen" msgstr "Notizen"
#: lib/mv_web/live/components/payment_filter_component.ex:88
#: lib/mv_web/live/components/payment_filter_component.ex:138
#: lib/mv_web/live/member_live/form.ex:49 #: lib/mv_web/live/member_live/form.ex:49
#: lib/mv_web/live/member_live/index.html.heex:224
#: lib/mv_web/live/member_live/show.ex:52 #: lib/mv_web/live/member_live/show.ex:52
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Paid" msgid "Paid"
msgstr "Bezahlt" msgstr "Bezahlt"
#: lib/mv_web/live/member_live/form.ex:50 #: lib/mv_web/live/member_live/form.ex:50
#: lib/mv_web/live/member_live/index.html.heex:189 #: lib/mv_web/live/member_live/index.html.heex:197
#: lib/mv_web/live/member_live/show.ex:55 #: lib/mv_web/live/member_live/show.ex:55
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Phone Number" msgid "Phone Number"
msgstr "Telefonnummer" msgstr "Telefonnummer"
#: lib/mv_web/live/member_live/form.ex:57 #: lib/mv_web/live/member_live/form.ex:57
#: lib/mv_web/live/member_live/index.html.heex:153 #: lib/mv_web/live/member_live/index.html.heex:161
#: lib/mv_web/live/member_live/show.ex:62 #: lib/mv_web/live/member_live/show.ex:62
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Postal Code" msgid "Postal Code"
@ -168,7 +171,7 @@ msgid "Saving..."
msgstr "Speichern..." msgstr "Speichern..."
#: lib/mv_web/live/member_live/form.ex:55 #: lib/mv_web/live/member_live/form.ex:55
#: lib/mv_web/live/member_live/index.html.heex:117 #: lib/mv_web/live/member_live/index.html.heex:125
#: lib/mv_web/live/member_live/show.ex:60 #: lib/mv_web/live/member_live/show.ex:60
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Street" msgid "Street"
@ -179,6 +182,7 @@ msgstr "Straße"
msgid "Id" msgid "Id"
msgstr "ID" msgstr "ID"
#: lib/mv_web/live/member_live/index.html.heex:229
#: lib/mv_web/live/member_live/index/formatter.ex:61 #: lib/mv_web/live/member_live/index/formatter.ex:61
#: lib/mv_web/live/member_live/show.ex:53 #: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -195,6 +199,7 @@ msgstr "Mitglied anzeigen"
msgid "This is a member record from your database." msgid "This is a member record from your database."
msgstr "Dies ist ein Mitglied aus deiner Datenbank." msgstr "Dies ist ein Mitglied aus deiner Datenbank."
#: lib/mv_web/live/member_live/index.html.heex:229
#: lib/mv_web/live/member_live/index/formatter.ex:60 #: lib/mv_web/live/member_live/index/formatter.ex:60
#: lib/mv_web/live/member_live/show.ex:53 #: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -360,12 +365,12 @@ msgstr "Profil"
msgid "Required" msgid "Required"
msgstr "Erforderlich" msgstr "Erforderlich"
#: lib/mv_web/live/member_live/index.html.heex:55 #: lib/mv_web/live/member_live/index.html.heex:63
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select all members" msgid "Select all members"
msgstr "Alle Mitglieder auswählen" msgstr "Alle Mitglieder auswählen"
#: lib/mv_web/live/member_live/index.html.heex:69 #: lib/mv_web/live/member_live/index.html.heex:77
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select member" msgid "Select member"
msgstr "Mitglied auswählen" msgstr "Mitglied auswählen"
@ -551,7 +556,7 @@ msgid "Toggle dark mode"
msgstr "Dunklen Modus umschalten" msgstr "Dunklen Modus umschalten"
#: lib/mv_web/live/components/search_bar_component.ex:15 #: lib/mv_web/live/components/search_bar_component.ex:15
#: lib/mv_web/live/member_live/index.html.heex:33 #: lib/mv_web/live/member_live/index.html.heex:34
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Search..." msgid "Search..."
msgstr "Suchen..." msgstr "Suchen..."
@ -567,7 +572,7 @@ msgstr "Benutzer*innen"
msgid "Click to sort" msgid "Click to sort"
msgstr "Klicke um zu sortieren" msgstr "Klicke um zu sortieren"
#: lib/mv_web/live/member_live/index.html.heex:81 #: lib/mv_web/live/member_live/index.html.heex:89
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "First name" msgid "First name"
msgstr "Vorname" msgstr "Vorname"
@ -777,7 +782,7 @@ msgstr "Mitglied entverknüpfen"
msgid "Unlinking scheduled" msgid "Unlinking scheduled"
msgstr "Entverknüpfung geplant" msgstr "Entverknüpfung geplant"
#: lib/mv_web/live/member_live/index.ex:164 #: lib/mv_web/live/member_live/index.ex:165
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Copied %{count} email address to clipboard" msgid "Copied %{count} email address to clipboard"
msgid_plural "Copied %{count} email addresses to clipboard" msgid_plural "Copied %{count} email addresses to clipboard"
@ -794,12 +799,12 @@ msgstr "E-Mail-Adressen der ausgewählten Mitglieder kopieren"
msgid "Copy emails" msgid "Copy emails"
msgstr "E-Mails kopieren" msgstr "E-Mails kopieren"
#: lib/mv_web/live/member_live/index.ex:153 #: lib/mv_web/live/member_live/index.ex:154
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No email addresses found" msgid "No email addresses found"
msgstr "Keine E-Mail-Adressen gefunden" msgstr "Keine E-Mail-Adressen gefunden"
#: lib/mv_web/live/member_live/index.ex:150 #: lib/mv_web/live/member_live/index.ex:151
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No members selected" msgid "No members selected"
msgstr "Keine Mitglieder ausgewählt" msgstr "Keine Mitglieder ausgewählt"
@ -814,7 +819,7 @@ msgstr "E-Mail-Programm mit BCC-Empfänger*innen öffnen"
msgid "Open in email program" msgid "Open in email program"
msgstr "Im E-Mail-Programm öffnen" msgstr "Im E-Mail-Programm öffnen"
#: lib/mv_web/live/member_live/index.ex:173 #: lib/mv_web/live/member_live/index.ex:174
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Tip: Paste email addresses into the BCC field for privacy compliance" 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" msgstr "Tipp: E-Mail-Adressen ins BCC-Feld einfügen für Datenschutzkonformität"
@ -831,3 +836,25 @@ msgstr "Felder, die mit einem Sternchen (*) markiert sind, dürfen nicht leer bl
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "This field cannot be empty" msgid "This field cannot be empty"
msgstr "Dieses Feld darf nicht leer bleiben" msgstr "Dieses Feld darf nicht leer bleiben"
#: lib/mv_web/live/components/payment_filter_component.ex:74
#: lib/mv_web/live/components/payment_filter_component.ex:137
#, elixir-autogen, elixir-format
msgid "All"
msgstr "Alle"
#: lib/mv_web/live/components/payment_filter_component.ex:48
#, elixir-autogen, elixir-format
msgid "Filter by payment status"
msgstr "Nach Zahlungsstatus filtern"
#: lib/mv_web/live/components/payment_filter_component.ex:102
#: lib/mv_web/live/components/payment_filter_component.ex:139
#, elixir-autogen, elixir-format
msgid "Not paid"
msgstr "Nicht bezahlt"
#: lib/mv_web/live/components/payment_filter_component.ex:59
#, elixir-autogen, elixir-format
msgid "Payment filter"
msgstr "Zahlungsfilter"

View file

@ -16,7 +16,7 @@ msgstr ""
msgid "Actions" msgid "Actions"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:227 #: lib/mv_web/live/member_live/index.html.heex:243
#: lib/mv_web/live/user_live/index.html.heex:72 #: lib/mv_web/live/user_live/index.html.heex:72
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Are you sure?" msgid "Are you sure?"
@ -29,19 +29,19 @@ msgid "Attempting to reconnect"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:54 #: lib/mv_web/live/member_live/form.ex:54
#: lib/mv_web/live/member_live/index.html.heex:171 #: lib/mv_web/live/member_live/index.html.heex:179
#: lib/mv_web/live/member_live/show.ex:59 #: lib/mv_web/live/member_live/show.ex:59
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "City" msgid "City"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:229 #: lib/mv_web/live/member_live/index.html.heex:245
#: lib/mv_web/live/user_live/index.html.heex:74 #: lib/mv_web/live/user_live/index.html.heex:74
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Delete" msgid "Delete"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:221 #: lib/mv_web/live/member_live/index.html.heex:237
#: lib/mv_web/live/user_live/form.ex:265 #: lib/mv_web/live/user_live/form.ex:265
#: lib/mv_web/live/user_live/index.html.heex:66 #: lib/mv_web/live/user_live/index.html.heex:66
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -55,7 +55,7 @@ msgid "Edit Member"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:47 #: lib/mv_web/live/member_live/form.ex:47
#: lib/mv_web/live/member_live/index.html.heex:99 #: lib/mv_web/live/member_live/index.html.heex:107
#: lib/mv_web/live/member_live/show.ex:50 #: lib/mv_web/live/member_live/show.ex:50
#: lib/mv_web/live/user_live/form.ex:46 #: lib/mv_web/live/user_live/form.ex:46
#: lib/mv_web/live/user_live/index.html.heex:44 #: lib/mv_web/live/user_live/index.html.heex:44
@ -71,7 +71,7 @@ msgid "First Name"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:51 #: lib/mv_web/live/member_live/form.ex:51
#: lib/mv_web/live/member_live/index.html.heex:207 #: lib/mv_web/live/member_live/index.html.heex:215
#: lib/mv_web/live/member_live/show.ex:56 #: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Join Date" msgid "Join Date"
@ -88,7 +88,7 @@ msgstr ""
msgid "New Member" msgid "New Member"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:218 #: lib/mv_web/live/member_live/index.html.heex:234
#: lib/mv_web/live/user_live/index.html.heex:63 #: lib/mv_web/live/user_live/index.html.heex:63
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Show" msgid "Show"
@ -122,7 +122,7 @@ msgid "Exit Date"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:56 #: lib/mv_web/live/member_live/form.ex:56
#: lib/mv_web/live/member_live/index.html.heex:135 #: lib/mv_web/live/member_live/index.html.heex:143
#: lib/mv_web/live/member_live/show.ex:61 #: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "House Number" msgid "House Number"
@ -134,21 +134,24 @@ msgstr ""
msgid "Notes" msgid "Notes"
msgstr "" msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:88
#: lib/mv_web/live/components/payment_filter_component.ex:138
#: lib/mv_web/live/member_live/form.ex:49 #: lib/mv_web/live/member_live/form.ex:49
#: lib/mv_web/live/member_live/index.html.heex:224
#: lib/mv_web/live/member_live/show.ex:52 #: lib/mv_web/live/member_live/show.ex:52
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Paid" msgid "Paid"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:50 #: lib/mv_web/live/member_live/form.ex:50
#: lib/mv_web/live/member_live/index.html.heex:189 #: lib/mv_web/live/member_live/index.html.heex:197
#: lib/mv_web/live/member_live/show.ex:55 #: lib/mv_web/live/member_live/show.ex:55
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Phone Number" msgid "Phone Number"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:57 #: lib/mv_web/live/member_live/form.ex:57
#: lib/mv_web/live/member_live/index.html.heex:153 #: lib/mv_web/live/member_live/index.html.heex:161
#: lib/mv_web/live/member_live/show.ex:62 #: lib/mv_web/live/member_live/show.ex:62
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Postal Code" msgid "Postal Code"
@ -169,7 +172,7 @@ msgid "Saving..."
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:55 #: lib/mv_web/live/member_live/form.ex:55
#: lib/mv_web/live/member_live/index.html.heex:117 #: lib/mv_web/live/member_live/index.html.heex:125
#: lib/mv_web/live/member_live/show.ex:60 #: lib/mv_web/live/member_live/show.ex:60
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Street" msgid "Street"
@ -180,6 +183,7 @@ msgstr ""
msgid "Id" msgid "Id"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:229
#: lib/mv_web/live/member_live/index/formatter.ex:61 #: lib/mv_web/live/member_live/index/formatter.ex:61
#: lib/mv_web/live/member_live/show.ex:53 #: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -196,6 +200,7 @@ msgstr ""
msgid "This is a member record from your database." msgid "This is a member record from your database."
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:229
#: lib/mv_web/live/member_live/index/formatter.ex:60 #: lib/mv_web/live/member_live/index/formatter.ex:60
#: lib/mv_web/live/member_live/show.ex:53 #: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -361,12 +366,12 @@ msgstr ""
msgid "Required" msgid "Required"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:55 #: lib/mv_web/live/member_live/index.html.heex:63
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select all members" msgid "Select all members"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:69 #: lib/mv_web/live/member_live/index.html.heex:77
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select member" msgid "Select member"
msgstr "" msgstr ""
@ -552,7 +557,7 @@ msgid "Toggle dark mode"
msgstr "" msgstr ""
#: lib/mv_web/live/components/search_bar_component.ex:15 #: lib/mv_web/live/components/search_bar_component.ex:15
#: lib/mv_web/live/member_live/index.html.heex:33 #: lib/mv_web/live/member_live/index.html.heex:34
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Search..." msgid "Search..."
msgstr "" msgstr ""
@ -568,7 +573,7 @@ msgstr ""
msgid "Click to sort" msgid "Click to sort"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:81 #: lib/mv_web/live/member_live/index.html.heex:89
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "First name" msgid "First name"
msgstr "" msgstr ""
@ -778,7 +783,7 @@ msgstr ""
msgid "Unlinking scheduled" msgid "Unlinking scheduled"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.ex:164 #: lib/mv_web/live/member_live/index.ex:165
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Copied %{count} email address to clipboard" msgid "Copied %{count} email address to clipboard"
msgid_plural "Copied %{count} email addresses to clipboard" msgid_plural "Copied %{count} email addresses to clipboard"
@ -795,12 +800,12 @@ msgstr ""
msgid "Copy emails" msgid "Copy emails"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.ex:153 #: lib/mv_web/live/member_live/index.ex:154
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No email addresses found" msgid "No email addresses found"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.ex:150 #: lib/mv_web/live/member_live/index.ex:151
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No members selected" msgid "No members selected"
msgstr "" msgstr ""
@ -815,7 +820,7 @@ msgstr ""
msgid "Open in email program" msgid "Open in email program"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.ex:173 #: lib/mv_web/live/member_live/index.ex:174
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Tip: Paste email addresses into the BCC field for privacy compliance" msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
msgstr "" msgstr ""
@ -832,3 +837,25 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "This field cannot be empty" msgid "This field cannot be empty"
msgstr "" msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:74
#: lib/mv_web/live/components/payment_filter_component.ex:137
#, elixir-autogen, elixir-format
msgid "All"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:48
#, elixir-autogen, elixir-format
msgid "Filter by payment status"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:102
#: lib/mv_web/live/components/payment_filter_component.ex:139
#, elixir-autogen, elixir-format
msgid "Not paid"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:59
#, elixir-autogen, elixir-format
msgid "Payment filter"
msgstr ""

View file

@ -16,7 +16,7 @@ msgstr ""
msgid "Actions" msgid "Actions"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:227 #: lib/mv_web/live/member_live/index.html.heex:243
#: lib/mv_web/live/user_live/index.html.heex:72 #: lib/mv_web/live/user_live/index.html.heex:72
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Are you sure?" msgid "Are you sure?"
@ -29,19 +29,19 @@ msgid "Attempting to reconnect"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:54 #: lib/mv_web/live/member_live/form.ex:54
#: lib/mv_web/live/member_live/index.html.heex:171 #: lib/mv_web/live/member_live/index.html.heex:179
#: lib/mv_web/live/member_live/show.ex:59 #: lib/mv_web/live/member_live/show.ex:59
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "City" msgid "City"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:229 #: lib/mv_web/live/member_live/index.html.heex:245
#: lib/mv_web/live/user_live/index.html.heex:74 #: lib/mv_web/live/user_live/index.html.heex:74
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Delete" msgid "Delete"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:221 #: lib/mv_web/live/member_live/index.html.heex:237
#: lib/mv_web/live/user_live/form.ex:265 #: lib/mv_web/live/user_live/form.ex:265
#: lib/mv_web/live/user_live/index.html.heex:66 #: lib/mv_web/live/user_live/index.html.heex:66
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -55,7 +55,7 @@ msgid "Edit Member"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:47 #: lib/mv_web/live/member_live/form.ex:47
#: lib/mv_web/live/member_live/index.html.heex:99 #: lib/mv_web/live/member_live/index.html.heex:107
#: lib/mv_web/live/member_live/show.ex:50 #: lib/mv_web/live/member_live/show.ex:50
#: lib/mv_web/live/user_live/form.ex:46 #: lib/mv_web/live/user_live/form.ex:46
#: lib/mv_web/live/user_live/index.html.heex:44 #: lib/mv_web/live/user_live/index.html.heex:44
@ -71,7 +71,7 @@ msgid "First Name"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:51 #: lib/mv_web/live/member_live/form.ex:51
#: lib/mv_web/live/member_live/index.html.heex:207 #: lib/mv_web/live/member_live/index.html.heex:215
#: lib/mv_web/live/member_live/show.ex:56 #: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Join Date" msgid "Join Date"
@ -88,7 +88,7 @@ msgstr ""
msgid "New Member" msgid "New Member"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:218 #: lib/mv_web/live/member_live/index.html.heex:234
#: lib/mv_web/live/user_live/index.html.heex:63 #: lib/mv_web/live/user_live/index.html.heex:63
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Show" msgid "Show"
@ -122,7 +122,7 @@ msgid "Exit Date"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:56 #: lib/mv_web/live/member_live/form.ex:56
#: lib/mv_web/live/member_live/index.html.heex:135 #: lib/mv_web/live/member_live/index.html.heex:143
#: lib/mv_web/live/member_live/show.ex:61 #: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "House Number" msgid "House Number"
@ -134,21 +134,24 @@ msgstr ""
msgid "Notes" msgid "Notes"
msgstr "" msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:88
#: lib/mv_web/live/components/payment_filter_component.ex:138
#: lib/mv_web/live/member_live/form.ex:49 #: lib/mv_web/live/member_live/form.ex:49
#: lib/mv_web/live/member_live/index.html.heex:224
#: lib/mv_web/live/member_live/show.ex:52 #: lib/mv_web/live/member_live/show.ex:52
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Paid" msgid "Paid"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:50 #: lib/mv_web/live/member_live/form.ex:50
#: lib/mv_web/live/member_live/index.html.heex:189 #: lib/mv_web/live/member_live/index.html.heex:197
#: lib/mv_web/live/member_live/show.ex:55 #: lib/mv_web/live/member_live/show.ex:55
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Phone Number" msgid "Phone Number"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:57 #: lib/mv_web/live/member_live/form.ex:57
#: lib/mv_web/live/member_live/index.html.heex:153 #: lib/mv_web/live/member_live/index.html.heex:161
#: lib/mv_web/live/member_live/show.ex:62 #: lib/mv_web/live/member_live/show.ex:62
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Postal Code" msgid "Postal Code"
@ -169,7 +172,7 @@ msgid "Saving..."
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:55 #: lib/mv_web/live/member_live/form.ex:55
#: lib/mv_web/live/member_live/index.html.heex:117 #: lib/mv_web/live/member_live/index.html.heex:125
#: lib/mv_web/live/member_live/show.ex:60 #: lib/mv_web/live/member_live/show.ex:60
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Street" msgid "Street"
@ -180,6 +183,7 @@ msgstr ""
msgid "Id" msgid "Id"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:229
#: lib/mv_web/live/member_live/index/formatter.ex:61 #: lib/mv_web/live/member_live/index/formatter.ex:61
#: lib/mv_web/live/member_live/show.ex:53 #: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -196,6 +200,7 @@ msgstr ""
msgid "This is a member record from your database." msgid "This is a member record from your database."
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:229
#: lib/mv_web/live/member_live/index/formatter.ex:60 #: lib/mv_web/live/member_live/index/formatter.ex:60
#: lib/mv_web/live/member_live/show.ex:53 #: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -361,12 +366,12 @@ msgstr ""
msgid "Required" msgid "Required"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:55 #: lib/mv_web/live/member_live/index.html.heex:63
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select all members" msgid "Select all members"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:69 #: lib/mv_web/live/member_live/index.html.heex:77
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select member" msgid "Select member"
msgstr "" msgstr ""
@ -552,7 +557,7 @@ msgid "Toggle dark mode"
msgstr "" msgstr ""
#: lib/mv_web/live/components/search_bar_component.ex:15 #: lib/mv_web/live/components/search_bar_component.ex:15
#: lib/mv_web/live/member_live/index.html.heex:33 #: lib/mv_web/live/member_live/index.html.heex:34
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Search..." msgid "Search..."
msgstr "" msgstr ""
@ -568,7 +573,7 @@ msgstr ""
msgid "Click to sort" msgid "Click to sort"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:81 #: lib/mv_web/live/member_live/index.html.heex:89
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "First name" msgid "First name"
msgstr "" msgstr ""
@ -778,7 +783,7 @@ msgstr ""
msgid "Unlinking scheduled" msgid "Unlinking scheduled"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.ex:164 #: lib/mv_web/live/member_live/index.ex:165
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Copied %{count} email address to clipboard" msgid "Copied %{count} email address to clipboard"
msgid_plural "Copied %{count} email addresses to clipboard" msgid_plural "Copied %{count} email addresses to clipboard"
@ -795,12 +800,12 @@ msgstr ""
msgid "Copy emails" msgid "Copy emails"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.ex:153 #: lib/mv_web/live/member_live/index.ex:154
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No email addresses found" msgid "No email addresses found"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.ex:150 #: lib/mv_web/live/member_live/index.ex:151
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "No members selected" msgid "No members selected"
msgstr "" msgstr ""
@ -815,7 +820,7 @@ msgstr ""
msgid "Open in email program" msgid "Open in email program"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.ex:173 #: lib/mv_web/live/member_live/index.ex:174
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Tip: Paste email addresses into the BCC field for privacy compliance" msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
msgstr "" msgstr ""
@ -832,3 +837,25 @@ msgstr ""
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "This field cannot be empty" msgid "This field cannot be empty"
msgstr "" msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:74
#: lib/mv_web/live/components/payment_filter_component.ex:137
#, elixir-autogen, elixir-format
msgid "All"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:48
#, elixir-autogen, elixir-format
msgid "Filter by payment status"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:102
#: lib/mv_web/live/components/payment_filter_component.ex:139
#, elixir-autogen, elixir-format
msgid "Not paid"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:59
#, elixir-autogen, elixir-format
msgid "Payment filter"
msgstr ""

View file

@ -0,0 +1,183 @@
defmodule MvWeb.Components.PaymentFilterComponentTest do
@moduledoc """
Unit tests for the PaymentFilterComponent.
Tests cover:
- Rendering in all 3 filter states (nil, :paid, :not_paid)
- Event emission when selecting options
- ARIA attributes for accessibility
- Dropdown open/close behavior
"""
# async: false to prevent PostgreSQL deadlocks when running LiveView tests against DB
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
describe "rendering" do
test "renders with no filter active (nil)", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Should show "All" text and no badge
assert has_element?(view, "#payment-filter")
refute has_element?(view, "#payment-filter .badge")
end
test "renders with paid filter active", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
# Should show badge when filter is active
assert has_element?(view, "#payment-filter .badge")
end
test "renders with not_paid filter active", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=not_paid")
# Should show badge when filter is active
assert has_element?(view, "#payment-filter .badge")
end
end
describe "dropdown behavior" do
test "dropdown opens on button click", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Initially dropdown is closed
refute has_element?(view, "#payment-filter ul[role='menu']")
# Click to open
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Dropdown should be visible
assert has_element?(view, "#payment-filter ul[role='menu']")
end
test "dropdown closes after selecting an option", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
assert has_element?(view, "#payment-filter ul[role='menu']")
# Select an option - this should close the dropdown
view
|> element("#payment-filter button[phx-value-filter='paid']")
|> render_click()
# After selection, dropdown should be closed
# Note: The dropdown closes via assign, which is reflected in the next render
refute has_element?(view, "#payment-filter ul[role='menu']")
end
end
describe "filter selection" do
test "selecting 'All' clears the filter and updates URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Select "All" option
view
|> element("#payment-filter button[phx-value-filter='']")
|> render_click()
# URL should not contain paid_filter param - wait for patch
assert_patch(view)
end
test "selecting 'Paid' sets the filter and updates URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Select "Paid" option
view
|> element("#payment-filter button[phx-value-filter='paid']")
|> render_click()
# Wait for patch and check URL contains paid_filter=paid
path = assert_patch(view)
assert path =~ "paid_filter=paid"
end
test "selecting 'Not paid' sets the filter and updates URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Select "Not paid" option
view
|> element("#payment-filter button[phx-value-filter='not_paid']")
|> render_click()
# Wait for patch and check URL contains paid_filter=not_paid
path = assert_patch(view)
assert path =~ "paid_filter=not_paid"
end
end
describe "accessibility" do
test "has correct ARIA attributes", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
# Main button should have aria-haspopup and aria-expanded
assert html =~ ~s(aria-haspopup="true")
assert html =~ ~s(aria-expanded="false")
assert html =~ ~s(aria-label=)
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
html = render(view)
# Check aria-expanded is now true
assert html =~ ~s(aria-expanded="true")
# Menu should have role="menu"
assert html =~ ~s(role="menu")
# Options should have role="menuitemradio"
assert html =~ ~s(role="menuitemradio")
end
test "has aria-checked on selected option", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
html = render(view)
# "Paid" option should have aria-checked="true"
# Check both possible orderings of attributes
assert html =~ "aria-checked=\"true\"" and html =~ "phx-value-filter=\"paid\""
end
end
end

View file

@ -9,7 +9,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
- Custom field values are correctly formatted for different types - Custom field values are correctly formatted for different types
- Members without custom field values show empty cell or "-" - Members without custom field values show empty cell or "-"
""" """
use MvWeb.ConnCase, async: true # async: false to prevent PostgreSQL deadlocks when creating members and custom fields
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
require Ash.Query require Ash.Query

View file

@ -469,4 +469,221 @@ defmodule MvWeb.MemberLive.IndexTest do
assert has_element?(view, "#flash-group") assert has_element?(view, "#flash-group")
end end
end end
describe "payment filter integration" do
setup do
# Create members with different payment status
# Use unique names that won't appear elsewhere in the HTML
{:ok, paid_member} =
Mv.Membership.create_member(%{
first_name: "Zahler",
last_name: "Mitglied",
email: "zahler@example.com",
paid: true
})
{:ok, unpaid_member} =
Mv.Membership.create_member(%{
first_name: "Nichtzahler",
last_name: "Mitglied",
email: "nichtzahler@example.com",
paid: false
})
{:ok, nil_paid_member} =
Mv.Membership.create_member(%{
first_name: "Unbestimmt",
last_name: "Mitglied",
email: "unbestimmt@example.com"
# paid is nil by default
})
%{paid_member: paid_member, unpaid_member: unpaid_member, nil_paid_member: nil_paid_member}
end
test "filter shows all members when no filter is active", %{
conn: conn,
paid_member: paid_member,
unpaid_member: unpaid_member,
nil_paid_member: nil_paid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ paid_member.first_name
assert html =~ unpaid_member.first_name
assert html =~ nil_paid_member.first_name
end
test "filter shows only paid members when paid filter is active", %{
conn: conn,
paid_member: paid_member,
unpaid_member: unpaid_member,
nil_paid_member: nil_paid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?paid_filter=paid")
assert html =~ paid_member.first_name
refute html =~ unpaid_member.first_name
refute html =~ nil_paid_member.first_name
end
test "filter shows only unpaid members (including nil) when not_paid filter is active", %{
conn: conn,
paid_member: paid_member,
unpaid_member: unpaid_member,
nil_paid_member: nil_paid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?paid_filter=not_paid")
refute html =~ paid_member.first_name
assert html =~ unpaid_member.first_name
assert html =~ nil_paid_member.first_name
end
test "filter combines with search query (AND)", %{
conn: conn,
paid_member: paid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?query=Zahler&paid_filter=paid")
assert html =~ paid_member.first_name
end
test "filter combines with sorting", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} =
live(conn, "/members?paid_filter=paid&sort_field=first_name&sort_order=asc")
# Click on email sort header
view
|> element("[data-testid='email']")
|> render_click()
# Filter should be preserved in URL
path = assert_patch(view)
assert path =~ "paid_filter=paid"
assert path =~ "sort_field=email"
end
test "URL parameter paid_filter is set when selecting filter", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open filter dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Select "Paid" option
view
|> element("#payment-filter button[phx-value-filter='paid']")
|> render_click()
path = assert_patch(view)
assert path =~ "paid_filter=paid"
end
test "URL parameter is correctly read on page load", %{
conn: conn,
paid_member: paid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?paid_filter=paid")
# Only paid member should be visible
assert html =~ paid_member.first_name
# Filter badge should be visible
assert html =~ "badge"
end
test "invalid URL parameter is ignored", %{
conn: conn,
paid_member: paid_member,
unpaid_member: unpaid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?paid_filter=invalid_value")
# All members should be visible (filter not applied)
assert html =~ paid_member.first_name
assert html =~ unpaid_member.first_name
end
test "search maintains filter state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
# Perform search
view
|> element("[data-testid='search-input']")
|> render_change(%{"query" => "test"})
# Filter state should be maintained in URL
path = assert_patch(view)
assert path =~ "paid_filter=paid"
end
end
describe "paid column in table" do
setup do
{:ok, paid_member} =
Mv.Membership.create_member(%{
first_name: "Paid",
last_name: "Member",
email: "paid.column@example.com",
paid: true
})
{:ok, unpaid_member} =
Mv.Membership.create_member(%{
first_name: "Unpaid",
last_name: "Member",
email: "unpaid.column@example.com",
paid: false
})
%{paid_member: paid_member, unpaid_member: unpaid_member}
end
test "paid column shows green badge for paid members", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Check for success badge (green)
assert html =~ "badge-success"
end
test "paid column shows red badge for unpaid members", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Check for error badge (red)
assert html =~ "badge-error"
end
test "paid column shows 'Yes' for paid members", %{conn: conn} do
conn = conn_with_oidc_user(conn)
Gettext.put_locale(MvWeb.Gettext, "en")
{:ok, _view, html} = live(conn, "/members")
# The table should contain "Yes" text inside badge
assert html =~ "badge-success"
assert html =~ "Yes"
end
test "paid column shows 'No' for unpaid members", %{conn: conn} do
conn = conn_with_oidc_user(conn)
Gettext.put_locale(MvWeb.Gettext, "en")
{:ok, _view, html} = live(conn, "/members")
# The table should contain "No" text inside badge
assert html =~ "badge-error"
assert html =~ "No"
end
end
end end