diff --git a/lib/membership/member.ex b/lib/membership/member.ex
index da69861..bcd505e 100644
--- a/lib/membership/member.ex
+++ b/lib/membership/member.ex
@@ -42,6 +42,10 @@ defmodule Mv.Membership.Member do
@member_search_limit 10
@default_similarity_threshold 0.2
+ # Use constants from Mv.Constants for member fields
+ # This ensures consistency across the codebase
+ @member_fields Mv.Constants.member_fields()
+
postgres do
table "members"
repo Mv.Repo
@@ -58,21 +62,7 @@ defmodule Mv.Membership.Member do
# user_id is NOT in accept list to prevent direct foreign key manipulation
argument :user, :map, allow_nil?: true
- accept [
- :first_name,
- :last_name,
- :email,
- :birth_date,
- :paid,
- :phone_number,
- :join_date,
- :exit_date,
- :notes,
- :city,
- :street,
- :house_number,
- :postal_code
- ]
+ accept @member_fields
change manage_relationship(:custom_field_values, type: :create)
@@ -105,21 +95,7 @@ defmodule Mv.Membership.Member do
# user_id is NOT in accept list to prevent direct foreign key manipulation
argument :user, :map, allow_nil?: true
- accept [
- :first_name,
- :last_name,
- :email,
- :birth_date,
- :paid,
- :phone_number,
- :join_date,
- :exit_date,
- :notes,
- :city,
- :street,
- :house_number,
- :postal_code
- ]
+ accept @member_fields
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)
diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex
index cb3691b..f5a708b 100644
--- a/lib/membership/membership.ex
+++ b/lib/membership/membership.ex
@@ -53,6 +53,7 @@ defmodule Mv.Membership do
# It's only used internally as fallback in get_settings/0
# Settings should be created via seed script
define :update_settings, action: :update
+ define :update_member_field_visibility, action: :update_member_field_visibility
end
end
@@ -123,4 +124,37 @@ defmodule Mv.Membership do
|> Ash.Changeset.for_update(:update, attrs)
|> Ash.update(domain: __MODULE__)
end
+
+ @doc """
+ Updates the member field visibility configuration.
+
+ This is a specialized action for updating only the member field visibility settings.
+ It validates that all keys are valid member fields and all values are booleans.
+
+ ## Parameters
+
+ - `settings` - The settings record to update
+ - `visibility_config` - A map of member field names (strings) to boolean visibility values
+ (e.g., `%{"street" => false, "house_number" => false}`)
+
+ ## Returns
+
+ - `{:ok, updated_settings}` - Successfully updated settings
+ - `{:error, error}` - Validation or update error
+
+ ## Examples
+
+ iex> {:ok, settings} = Mv.Membership.get_settings()
+ iex> {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
+ iex> updated.member_field_visibility
+ %{"street" => false, "house_number" => false}
+
+ """
+ def update_member_field_visibility(settings, visibility_config) do
+ settings
+ |> Ash.Changeset.for_update(:update_member_field_visibility, %{
+ member_field_visibility: visibility_config
+ })
+ |> Ash.update(domain: __MODULE__)
+ end
end
diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex
index 38624dc..52c0328 100644
--- a/lib/membership/setting.ex
+++ b/lib/membership/setting.ex
@@ -9,6 +9,8 @@ defmodule Mv.Membership.Setting do
## Attributes
- `club_name` - The name of the association/club (required, cannot be empty)
+ - `member_field_visibility` - JSONB map storing visibility configuration for member fields
+ (e.g., `%{"street" => false, "house_number" => false}`). Fields not in the map default to `true`.
## Singleton Pattern
This resource uses a singleton pattern - there should only be one settings record.
@@ -28,6 +30,9 @@ defmodule Mv.Membership.Setting do
# Update club name
{:ok, updated} = Mv.Membership.update_settings(settings, %{club_name: "New Name"})
+
+ # Update member field visibility
+ {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
"""
use Ash.Resource,
domain: Mv.Membership,
@@ -49,18 +54,65 @@ defmodule Mv.Membership.Setting do
# Used only as fallback in get_settings/0 if settings don't exist
# Settings should normally be created via seed script
create :create do
- accept [:club_name]
+ accept [:club_name, :member_field_visibility]
end
update :update do
primary? true
- accept [:club_name]
+ require_atomic? false
+ accept [:club_name, :member_field_visibility]
+ end
+
+ update :update_member_field_visibility do
+ description "Updates the visibility configuration for member fields in the overview"
+ require_atomic? false
+ accept [:member_field_visibility]
end
end
validations do
validate present(:club_name), on: [:create, :update]
validate string_length(:club_name, min: 1), on: [:create, :update]
+
+ # Validate member_field_visibility map structure and content
+ validate fn changeset, _context ->
+ visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility)
+
+ if visibility && is_map(visibility) do
+ # Validate all values are booleans
+ invalid_values =
+ Enum.filter(visibility, fn {_key, value} ->
+ not is_boolean(value)
+ end)
+
+ # Validate all keys are valid member fields
+ valid_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
+
+ invalid_keys =
+ Enum.filter(visibility, fn {key, _value} ->
+ key not in valid_field_strings
+ end)
+ |> Enum.map(fn {key, _value} -> key end)
+
+ cond do
+ not Enum.empty?(invalid_values) ->
+ {:error,
+ field: :member_field_visibility,
+ message: "All values in member_field_visibility must be booleans"}
+
+ not Enum.empty?(invalid_keys) ->
+ {:error,
+ field: :member_field_visibility,
+ message: "Invalid member field keys: #{inspect(invalid_keys)}"}
+
+ true ->
+ :ok
+ end
+ else
+ :ok
+ end
+ end,
+ on: [:create, :update]
end
attributes do
@@ -75,6 +127,12 @@ defmodule Mv.Membership.Setting do
min_length: 1
]
+ attribute :member_field_visibility, :map,
+ allow_nil?: true,
+ public?: true,
+ description:
+ "Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans."
+
timestamps()
end
end
diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex
new file mode 100644
index 0000000..cd8d3a4
--- /dev/null
+++ b/lib/mv/constants.ex
@@ -0,0 +1,23 @@
+defmodule Mv.Constants do
+ @moduledoc """
+ Module for defining constants and atoms.
+ """
+
+ @member_fields [
+ :first_name,
+ :last_name,
+ :email,
+ :birth_date,
+ :paid,
+ :phone_number,
+ :join_date,
+ :exit_date,
+ :notes,
+ :city,
+ :street,
+ :house_number,
+ :postal_code
+ ]
+
+ def member_fields, do: @member_fields
+end
diff --git a/lib/mv_web/live/components/payment_filter_component.ex b/lib/mv_web/live/components/payment_filter_component.ex
new file mode 100644
index 0000000..c9dc731
--- /dev/null
+++ b/lib/mv_web/live/components/payment_filter_component.ex
@@ -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"""
+
")
@custom_field_prefix "custom_field_"
+ # Member fields that are loaded for the overview
+ # Uses constants from Mv.Constants to ensure consistency
+ # Note: :id is always included for member identification
+ # All member fields are loaded, but visibility is controlled via settings
+ @overview_fields [:id | Mv.Constants.member_fields()]
+
@doc """
Initializes the LiveView state.
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
def mount(_params, _session, socket) do
@@ -53,14 +60,24 @@ defmodule MvWeb.MemberLive.Index do
|> Ash.Query.sort(name: :asc)
|> Ash.read!()
+ # Load settings once to avoid N+1 queries
+ settings =
+ case Membership.get_settings() do
+ {:ok, s} -> s
+ # Fallback if settings can't be loaded
+ {:error, _} -> %{member_field_visibility: %{}}
+ end
+
socket =
socket
|> assign(:page_title, gettext("Members"))
|> assign(:query, "")
|> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end)
+ |> assign(:paid_filter, nil)
|> assign(:selected_members, MapSet.new())
|> assign(:custom_fields_visible, custom_fields_visible)
+ |> assign(:member_fields_visible, get_visible_member_fields(settings))
# We call handle params to use the query from the URL
{:ok, socket}
@@ -197,11 +214,8 @@ defmodule MvWeb.MemberLive.Index do
existing_sort_query = socket.assigns.sort_order
# Build the URL with queries
- query_params = %{
- "query" => q,
- "sort_field" => existing_field_query,
- "sort_order" => existing_sort_query
- }
+ query_params =
+ build_query_params(q, existing_field_query, existing_sort_query, socket.assigns.paid_filter)
# Set the new path with params
new_path = ~p"/members?#{query_params}"
@@ -214,13 +228,38 @@ defmodule MvWeb.MemberLive.Index do
)}
end
+ @impl true
+ def handle_info({:payment_filter_changed, filter}, socket) do
+ socket =
+ socket
+ |> assign(:paid_filter, filter)
+ |> load_members(socket.assigns.query)
+
+ # 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
# -----------------------------------------------------------------
@doc """
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
browser back/forward navigation.
"""
@@ -230,6 +269,7 @@ defmodule MvWeb.MemberLive.Index do
socket
|> maybe_update_search(params)
|> maybe_update_sort(params)
+ |> maybe_update_paid_filter(params)
|> load_members(params["query"])
|> prepare_dynamic_cols()
@@ -321,11 +361,13 @@ defmodule MvWeb.MemberLive.Index do
field
end
- query_params = %{
- "query" => socket.assigns.query,
- "sort_field" => field_str,
- "sort_order" => Atom.to_string(order)
- }
+ query_params =
+ build_query_params(
+ socket.assigns.query,
+ field_str,
+ Atom.to_string(order),
+ socket.assigns.paid_filter
+ )
new_path = ~p"/members?#{query_params}"
@@ -336,13 +378,45 @@ defmodule MvWeb.MemberLive.Index do
)}
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:
# 1. Builds base query with selected fields
# 2. Loads custom field values for visible custom fields (filtered at database level)
# 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:
# - Database-level filtering: Custom field values are filtered directly in the database
@@ -358,18 +432,7 @@ defmodule MvWeb.MemberLive.Index do
query =
Mv.Membership.Member
|> Ash.Query.new()
- |> Ash.Query.select([
- :id,
- :first_name,
- :last_name,
- :email,
- :street,
- :house_number,
- :postal_code,
- :city,
- :phone_number,
- :join_date
- ])
+ |> Ash.Query.select(@overview_fields)
# Load custom field values for visible custom fields
custom_field_ids_list = Enum.map(socket.assigns.custom_fields_visible, & &1.id)
@@ -378,6 +441,9 @@ defmodule MvWeb.MemberLive.Index do
# Apply the search filter first
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
# For custom fields, we sort after loading
{query, sort_after_load} =
@@ -452,6 +518,24 @@ defmodule MvWeb.MemberLive.Index do
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
defp toggle_order(:asc), do: :desc
defp toggle_order(:desc), do: :asc
@@ -478,18 +562,13 @@ defmodule MvWeb.MemberLive.Index do
defp maybe_sort(query, _, _, _), do: {query, false}
# Validate that a field is sortable
+ # Uses member fields from constants, but excludes fields that don't make sense to sort
+ # (e.g., :notes is too long, :paid is boolean and not very useful for sorting)
defp valid_sort_field?(field) when is_atom(field) do
- valid_fields = [
- :first_name,
- :last_name,
- :email,
- :street,
- :house_number,
- :postal_code,
- :city,
- :phone_number,
- :join_date
- ]
+ # All member fields are sortable, but we exclude some that don't make sense
+ # :id is not in member_fields, but we don't want to sort by it anyway
+ non_sortable_fields = [:notes, :paid]
+ valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
field in valid_fields or custom_field_sort?(field)
end
@@ -747,6 +826,29 @@ defmodule MvWeb.MemberLive.Index do
socket
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
# -------------------------------------------------------------
@@ -796,4 +898,32 @@ defmodule MvWeb.MemberLive.Index do
"#{name} <#{member.email}>"
end
end
+
+ # Gets the list of member fields that should be visible in the overview.
+ #
+ # Reads the visibility configuration from Settings and returns only the fields
+ # where show_in_overview is true. Fields not configured in settings default to true.
+ #
+ # Performance: This function uses the already-loaded settings to avoid N+1 queries.
+ # Settings should be loaded once in mount/3 and passed to this function.
+ #
+ # Parameters:
+ # - `settings` - The settings struct loaded from the database
+ #
+ # Returns a list of atoms representing visible member field names.
+ #
+ # Fields are read from the global Constants module.
+ @spec get_visible_member_fields(map()) :: [atom()]
+ defp get_visible_member_fields(settings) do
+ # Get all eligible fields from the global constants
+ all_fields = Mv.Constants.member_fields()
+
+ # JSONB stores keys as strings
+ visibility_config = settings.member_field_visibility || %{}
+
+ # Filter to only return visible fields
+ Enum.filter(all_fields, fn field ->
+ Map.get(visibility_config, Atom.to_string(field), true)
+ end)
+ end
end
diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex
index 633dd9c..58e22b6 100644
--- a/lib/mv_web/live/member_live/index.html.heex
+++ b/lib/mv_web/live/member_live/index.html.heex
@@ -26,12 +26,20 @@
- <.live_component
- module={MvWeb.Components.SearchBarComponent}
- id="search-bar"
- query={@query}
- placeholder={gettext("Search...")}
- />
+
+ <.live_component
+ module={MvWeb.Components.SearchBarComponent}
+ id="search-bar"
+ query={@query}
+ placeholder={gettext("Search...")}
+ />
+ <.live_component
+ module={MvWeb.Components.PaymentFilterComponent}
+ id="payment-filter"
+ paid_filter={@paid_filter}
+ member_count={length(@members)}
+ />
+
<.table
id="members"
@@ -89,6 +97,7 @@
<:col
:let={member}
+ :if={:email in @member_fields_visible}
label={
~H"""
<.live_component
@@ -106,6 +115,7 @@
<:col
:let={member}
+ :if={:street in @member_fields_visible}
label={
~H"""
<.live_component
@@ -123,6 +133,7 @@
<:col
:let={member}
+ :if={:house_number in @member_fields_visible}
label={
~H"""
<.live_component
@@ -140,6 +151,7 @@
<:col
:let={member}
+ :if={:postal_code in @member_fields_visible}
label={
~H"""
<.live_component
@@ -157,6 +169,7 @@
<:col
:let={member}
+ :if={:city in @member_fields_visible}
label={
~H"""
<.live_component
@@ -174,6 +187,7 @@
<:col
:let={member}
+ :if={:phone_number in @member_fields_visible}
label={
~H"""
<.live_component
@@ -191,6 +205,7 @@
<:col
:let={member}
+ :if={:join_date in @member_fields_visible}
label={
~H"""
<.live_component
@@ -206,6 +221,14 @@
>
{member.join_date}
+ <:col :let={member} label={gettext("Paid")}>
+
+ {if member.paid == true, do: gettext("Yes"), else: gettext("No")}
+
+
<:action :let={member}>
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index 770cc09..df31d0a 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -10,12 +10,12 @@ msgid ""
msgstr ""
"Language: en\n"
-#: lib/mv_web/components/core_components.ex:360
+#: lib/mv_web/components/core_components.ex:362
#, elixir-autogen, elixir-format
msgid "Actions"
msgstr "Aktionen"
-#: lib/mv_web/live/member_live/index.html.heex:220
+#: lib/mv_web/live/member_live/index.html.heex:235
#: lib/mv_web/live/user_live/index.html.heex:72
#, elixir-autogen, elixir-format
msgid "Are you sure?"
@@ -28,19 +28,19 @@ msgid "Attempting to reconnect"
msgstr "Verbindung wird wiederhergestellt"
#: lib/mv_web/live/member_live/form.ex:54
-#: lib/mv_web/live/member_live/index.html.heex:166
+#: lib/mv_web/live/member_live/index.html.heex:173
#: lib/mv_web/live/member_live/show.ex:59
#, elixir-autogen, elixir-format
msgid "City"
msgstr "Stadt"
-#: lib/mv_web/live/member_live/index.html.heex:222
+#: lib/mv_web/live/member_live/index.html.heex:237
#: lib/mv_web/live/user_live/index.html.heex:74
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr "Löschen"
-#: lib/mv_web/live/member_live/index.html.heex:214
+#: lib/mv_web/live/member_live/index.html.heex:229
#: lib/mv_web/live/user_live/form.ex:265
#: lib/mv_web/live/user_live/index.html.heex:66
#, elixir-autogen, elixir-format
@@ -54,7 +54,7 @@ msgid "Edit Member"
msgstr "Mitglied bearbeiten"
#: lib/mv_web/live/member_live/form.ex:47
-#: lib/mv_web/live/member_live/index.html.heex:98
+#: lib/mv_web/live/member_live/index.html.heex:105
#: lib/mv_web/live/member_live/show.ex:50
#: lib/mv_web/live/user_live/form.ex:46
#: lib/mv_web/live/user_live/index.html.heex:44
@@ -70,7 +70,7 @@ msgid "First Name"
msgstr "Vorname"
#: lib/mv_web/live/member_live/form.ex:51
-#: lib/mv_web/live/member_live/index.html.heex:200
+#: lib/mv_web/live/member_live/index.html.heex:207
#: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Join Date"
@@ -87,7 +87,7 @@ msgstr "Nachname"
msgid "New Member"
msgstr "Neues Mitglied"
-#: lib/mv_web/live/member_live/index.html.heex:211
+#: lib/mv_web/live/member_live/index.html.heex:226
#: lib/mv_web/live/user_live/index.html.heex:63
#, elixir-autogen, elixir-format
msgid "Show"
@@ -103,7 +103,7 @@ msgstr "Etwas ist schiefgelaufen!"
msgid "We can't find the internet"
msgstr "Keine Internetverbindung gefunden"
-#: lib/mv_web/components/core_components.ex:78
+#: lib/mv_web/components/core_components.ex:81
#, elixir-autogen, elixir-format
msgid "close"
msgstr "schließen"
@@ -121,7 +121,7 @@ msgid "Exit Date"
msgstr "Austrittsdatum"
#: lib/mv_web/live/member_live/form.ex:56
-#: lib/mv_web/live/member_live/index.html.heex:132
+#: lib/mv_web/live/member_live/index.html.heex:139
#: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format
msgid "House Number"
@@ -133,21 +133,24 @@ msgstr "Hausnummer"
msgid "Notes"
msgstr "Notizen"
+#: lib/mv_web/live/components/payment_filter_component.ex:86
+#: lib/mv_web/live/components/payment_filter_component.ex:136
#: lib/mv_web/live/member_live/form.ex:49
+#: lib/mv_web/live/member_live/index.html.heex:216
#: lib/mv_web/live/member_live/show.ex:52
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr "Bezahlt"
#: lib/mv_web/live/member_live/form.ex:50
-#: lib/mv_web/live/member_live/index.html.heex:183
+#: lib/mv_web/live/member_live/index.html.heex:190
#: lib/mv_web/live/member_live/show.ex:55
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr "Telefonnummer"
#: lib/mv_web/live/member_live/form.ex:57
-#: lib/mv_web/live/member_live/index.html.heex:149
+#: lib/mv_web/live/member_live/index.html.heex:156
#: lib/mv_web/live/member_live/show.ex:62
#, elixir-autogen, elixir-format
msgid "Postal Code"
@@ -168,7 +171,7 @@ msgid "Saving..."
msgstr "Speichern..."
#: lib/mv_web/live/member_live/form.ex:55
-#: lib/mv_web/live/member_live/index.html.heex:115
+#: lib/mv_web/live/member_live/index.html.heex:122
#: lib/mv_web/live/member_live/show.ex:60
#, elixir-autogen, elixir-format
msgid "Street"
@@ -184,6 +187,7 @@ msgstr "Dieses Formular dient zur Verwaltung von Mitgliedern und deren Eigenscha
msgid "Id"
msgstr "ID"
+#: lib/mv_web/live/member_live/index.html.heex:221
#: lib/mv_web/live/member_live/index/formatter.ex:61
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
@@ -200,6 +204,7 @@ msgstr "Mitglied anzeigen"
msgid "This is a member record from your database."
msgstr "Dies ist ein Mitglied aus deiner Datenbank."
+#: lib/mv_web/live/member_live/index.html.heex:221
#: lib/mv_web/live/member_live/index/formatter.ex:60
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
@@ -365,12 +370,12 @@ msgstr "Profil"
msgid "Required"
msgstr "Erforderlich"
-#: lib/mv_web/live/member_live/index.html.heex:55
+#: lib/mv_web/live/member_live/index.html.heex:62
#, elixir-autogen, elixir-format
msgid "Select all members"
msgstr "Alle Mitglieder auswählen"
-#: lib/mv_web/live/member_live/index.html.heex:69
+#: lib/mv_web/live/member_live/index.html.heex:76
#, elixir-autogen, elixir-format
msgid "Select member"
msgstr "Mitglied auswählen"
@@ -556,7 +561,7 @@ msgid "Toggle dark mode"
msgstr "Dunklen Modus umschalten"
#: 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
msgid "Search..."
msgstr "Suchen..."
@@ -572,7 +577,7 @@ msgstr "Benutzer*innen"
msgid "Click to sort"
msgstr "Klicke um zu sortieren"
-#: lib/mv_web/live/member_live/index.html.heex:81
+#: lib/mv_web/live/member_live/index.html.heex:88
#, elixir-autogen, elixir-format
msgid "First name"
msgstr "Vorname"
@@ -782,7 +787,7 @@ msgstr "Mitglied entverknüpfen"
msgid "Unlinking scheduled"
msgstr "Entverknüpfung geplant"
-#: lib/mv_web/live/member_live/index.ex:159
+#: lib/mv_web/live/member_live/index.ex:149
#, elixir-autogen, elixir-format
msgid "Copied %{count} email address to clipboard"
msgid_plural "Copied %{count} email addresses to clipboard"
@@ -799,12 +804,12 @@ msgstr "E-Mail-Adressen der ausgewählten Mitglieder kopieren"
msgid "Copy emails"
msgstr "E-Mails kopieren"
-#: lib/mv_web/live/member_live/index.ex:142
+#: lib/mv_web/live/member_live/index.ex:138
#, elixir-autogen, elixir-format
msgid "No email addresses found"
msgstr "Keine E-Mail-Adressen gefunden"
-#: lib/mv_web/live/member_live/index.ex:126
+#: lib/mv_web/live/member_live/index.ex:135
#, elixir-autogen, elixir-format
msgid "No members selected"
msgstr "Keine Mitglieder ausgewählt"
@@ -819,7 +824,29 @@ msgstr "E-Mail-Programm mit BCC-Empfänger*innen öffnen"
msgid "Open in email program"
msgstr "Im E-Mail-Programm öffnen"
-#: lib/mv_web/live/member_live/index.ex:168
+#: lib/mv_web/live/member_live/index.ex:158
#, elixir-autogen, elixir-format
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/payment_filter_component.ex:72
+#: lib/mv_web/live/components/payment_filter_component.ex:135
+#, elixir-autogen, elixir-format
+msgid "All"
+msgstr "Alle"
+
+#: lib/mv_web/live/components/payment_filter_component.ex:46
+#, elixir-autogen, elixir-format
+msgid "Filter by payment status"
+msgstr "Nach Zahlungsstatus filtern"
+
+#: lib/mv_web/live/components/payment_filter_component.ex:100
+#: lib/mv_web/live/components/payment_filter_component.ex:137
+#, elixir-autogen, elixir-format
+msgid "Not paid"
+msgstr "Nicht bezahlt"
+
+#: lib/mv_web/live/components/payment_filter_component.ex:57
+#, elixir-autogen, elixir-format
+msgid "Payment filter"
+msgstr "Zahlungsfilter"
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index 682b780..200d111 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -11,12 +11,12 @@
msgid ""
msgstr ""
-#: lib/mv_web/components/core_components.ex:360
+#: lib/mv_web/components/core_components.ex:362
#, elixir-autogen, elixir-format
msgid "Actions"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:220
+#: lib/mv_web/live/member_live/index.html.heex:235
#: lib/mv_web/live/user_live/index.html.heex:72
#, elixir-autogen, elixir-format
msgid "Are you sure?"
@@ -29,19 +29,19 @@ msgid "Attempting to reconnect"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:54
-#: lib/mv_web/live/member_live/index.html.heex:166
+#: lib/mv_web/live/member_live/index.html.heex:173
#: lib/mv_web/live/member_live/show.ex:59
#, elixir-autogen, elixir-format
msgid "City"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:222
+#: lib/mv_web/live/member_live/index.html.heex:237
#: lib/mv_web/live/user_live/index.html.heex:74
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:214
+#: lib/mv_web/live/member_live/index.html.heex:229
#: lib/mv_web/live/user_live/form.ex:265
#: lib/mv_web/live/user_live/index.html.heex:66
#, elixir-autogen, elixir-format
@@ -55,7 +55,7 @@ msgid "Edit Member"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:47
-#: lib/mv_web/live/member_live/index.html.heex:98
+#: lib/mv_web/live/member_live/index.html.heex:105
#: lib/mv_web/live/member_live/show.ex:50
#: lib/mv_web/live/user_live/form.ex:46
#: lib/mv_web/live/user_live/index.html.heex:44
@@ -71,7 +71,7 @@ msgid "First Name"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:51
-#: lib/mv_web/live/member_live/index.html.heex:200
+#: lib/mv_web/live/member_live/index.html.heex:207
#: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Join Date"
@@ -88,7 +88,7 @@ msgstr ""
msgid "New Member"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:211
+#: lib/mv_web/live/member_live/index.html.heex:226
#: lib/mv_web/live/user_live/index.html.heex:63
#, elixir-autogen, elixir-format
msgid "Show"
@@ -104,7 +104,7 @@ msgstr ""
msgid "We can't find the internet"
msgstr ""
-#: lib/mv_web/components/core_components.ex:78
+#: lib/mv_web/components/core_components.ex:81
#, elixir-autogen, elixir-format
msgid "close"
msgstr ""
@@ -122,7 +122,7 @@ msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:56
-#: lib/mv_web/live/member_live/index.html.heex:132
+#: lib/mv_web/live/member_live/index.html.heex:139
#: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format
msgid "House Number"
@@ -134,21 +134,24 @@ msgstr ""
msgid "Notes"
msgstr ""
+#: lib/mv_web/live/components/payment_filter_component.ex:86
+#: lib/mv_web/live/components/payment_filter_component.ex:136
#: lib/mv_web/live/member_live/form.ex:49
+#: lib/mv_web/live/member_live/index.html.heex:216
#: lib/mv_web/live/member_live/show.ex:52
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:50
-#: lib/mv_web/live/member_live/index.html.heex:183
+#: lib/mv_web/live/member_live/index.html.heex:190
#: lib/mv_web/live/member_live/show.ex:55
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:57
-#: lib/mv_web/live/member_live/index.html.heex:149
+#: lib/mv_web/live/member_live/index.html.heex:156
#: lib/mv_web/live/member_live/show.ex:62
#, elixir-autogen, elixir-format
msgid "Postal Code"
@@ -169,7 +172,7 @@ msgid "Saving..."
msgstr ""
#: lib/mv_web/live/member_live/form.ex:55
-#: lib/mv_web/live/member_live/index.html.heex:115
+#: lib/mv_web/live/member_live/index.html.heex:122
#: lib/mv_web/live/member_live/show.ex:60
#, elixir-autogen, elixir-format
msgid "Street"
@@ -185,6 +188,7 @@ msgstr ""
msgid "Id"
msgstr ""
+#: lib/mv_web/live/member_live/index.html.heex:221
#: lib/mv_web/live/member_live/index/formatter.ex:61
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
@@ -201,6 +205,7 @@ msgstr ""
msgid "This is a member record from your database."
msgstr ""
+#: lib/mv_web/live/member_live/index.html.heex:221
#: lib/mv_web/live/member_live/index/formatter.ex:60
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
@@ -366,12 +371,12 @@ msgstr ""
msgid "Required"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:55
+#: lib/mv_web/live/member_live/index.html.heex:62
#, elixir-autogen, elixir-format
msgid "Select all members"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:69
+#: lib/mv_web/live/member_live/index.html.heex:76
#, elixir-autogen, elixir-format
msgid "Select member"
msgstr ""
@@ -557,7 +562,7 @@ msgid "Toggle dark mode"
msgstr ""
#: 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
msgid "Search..."
msgstr ""
@@ -573,7 +578,7 @@ msgstr ""
msgid "Click to sort"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:81
+#: lib/mv_web/live/member_live/index.html.heex:88
#, elixir-autogen, elixir-format
msgid "First name"
msgstr ""
@@ -783,7 +788,7 @@ msgstr ""
msgid "Unlinking scheduled"
msgstr ""
-#: lib/mv_web/live/member_live/index.ex:159
+#: lib/mv_web/live/member_live/index.ex:149
#, elixir-autogen, elixir-format
msgid "Copied %{count} email address to clipboard"
msgid_plural "Copied %{count} email addresses to clipboard"
@@ -800,12 +805,12 @@ msgstr ""
msgid "Copy emails"
msgstr ""
-#: lib/mv_web/live/member_live/index.ex:142
+#: lib/mv_web/live/member_live/index.ex:138
#, elixir-autogen, elixir-format
msgid "No email addresses found"
msgstr ""
-#: lib/mv_web/live/member_live/index.ex:126
+#: lib/mv_web/live/member_live/index.ex:135
#, elixir-autogen, elixir-format
msgid "No members selected"
msgstr ""
@@ -820,7 +825,29 @@ msgstr ""
msgid "Open in email program"
msgstr ""
-#: lib/mv_web/live/member_live/index.ex:168
+#: lib/mv_web/live/member_live/index.ex:158
#, elixir-autogen, elixir-format
msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
msgstr ""
+
+#: lib/mv_web/live/components/payment_filter_component.ex:72
+#: lib/mv_web/live/components/payment_filter_component.ex:135
+#, elixir-autogen, elixir-format
+msgid "All"
+msgstr ""
+
+#: lib/mv_web/live/components/payment_filter_component.ex:46
+#, elixir-autogen, elixir-format
+msgid "Filter by payment status"
+msgstr ""
+
+#: lib/mv_web/live/components/payment_filter_component.ex:100
+#: lib/mv_web/live/components/payment_filter_component.ex:137
+#, elixir-autogen, elixir-format
+msgid "Not paid"
+msgstr ""
+
+#: lib/mv_web/live/components/payment_filter_component.ex:57
+#, elixir-autogen, elixir-format
+msgid "Payment filter"
+msgstr ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index a3fdfa4..df8c16e 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -11,12 +11,12 @@ msgstr ""
"Language: en\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
-#: lib/mv_web/components/core_components.ex:360
+#: lib/mv_web/components/core_components.ex:362
#, elixir-autogen, elixir-format
msgid "Actions"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:220
+#: lib/mv_web/live/member_live/index.html.heex:235
#: lib/mv_web/live/user_live/index.html.heex:72
#, elixir-autogen, elixir-format
msgid "Are you sure?"
@@ -29,19 +29,19 @@ msgid "Attempting to reconnect"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:54
-#: lib/mv_web/live/member_live/index.html.heex:166
+#: lib/mv_web/live/member_live/index.html.heex:173
#: lib/mv_web/live/member_live/show.ex:59
#, elixir-autogen, elixir-format
msgid "City"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:222
+#: lib/mv_web/live/member_live/index.html.heex:237
#: lib/mv_web/live/user_live/index.html.heex:74
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:214
+#: lib/mv_web/live/member_live/index.html.heex:229
#: lib/mv_web/live/user_live/form.ex:265
#: lib/mv_web/live/user_live/index.html.heex:66
#, elixir-autogen, elixir-format
@@ -55,7 +55,7 @@ msgid "Edit Member"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:47
-#: lib/mv_web/live/member_live/index.html.heex:98
+#: lib/mv_web/live/member_live/index.html.heex:105
#: lib/mv_web/live/member_live/show.ex:50
#: lib/mv_web/live/user_live/form.ex:46
#: lib/mv_web/live/user_live/index.html.heex:44
@@ -71,7 +71,7 @@ msgid "First Name"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:51
-#: lib/mv_web/live/member_live/index.html.heex:200
+#: lib/mv_web/live/member_live/index.html.heex:207
#: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Join Date"
@@ -88,7 +88,7 @@ msgstr ""
msgid "New Member"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:211
+#: lib/mv_web/live/member_live/index.html.heex:226
#: lib/mv_web/live/user_live/index.html.heex:63
#, elixir-autogen, elixir-format
msgid "Show"
@@ -104,7 +104,7 @@ msgstr ""
msgid "We can't find the internet"
msgstr ""
-#: lib/mv_web/components/core_components.ex:78
+#: lib/mv_web/components/core_components.ex:81
#, elixir-autogen, elixir-format
msgid "close"
msgstr ""
@@ -122,7 +122,7 @@ msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:56
-#: lib/mv_web/live/member_live/index.html.heex:132
+#: lib/mv_web/live/member_live/index.html.heex:139
#: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format
msgid "House Number"
@@ -134,21 +134,24 @@ msgstr ""
msgid "Notes"
msgstr ""
+#: lib/mv_web/live/components/payment_filter_component.ex:86
+#: lib/mv_web/live/components/payment_filter_component.ex:136
#: lib/mv_web/live/member_live/form.ex:49
+#: lib/mv_web/live/member_live/index.html.heex:216
#: lib/mv_web/live/member_live/show.ex:52
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:50
-#: lib/mv_web/live/member_live/index.html.heex:183
+#: lib/mv_web/live/member_live/index.html.heex:190
#: lib/mv_web/live/member_live/show.ex:55
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:57
-#: lib/mv_web/live/member_live/index.html.heex:149
+#: lib/mv_web/live/member_live/index.html.heex:156
#: lib/mv_web/live/member_live/show.ex:62
#, elixir-autogen, elixir-format
msgid "Postal Code"
@@ -169,7 +172,7 @@ msgid "Saving..."
msgstr ""
#: lib/mv_web/live/member_live/form.ex:55
-#: lib/mv_web/live/member_live/index.html.heex:115
+#: lib/mv_web/live/member_live/index.html.heex:122
#: lib/mv_web/live/member_live/show.ex:60
#, elixir-autogen, elixir-format
msgid "Street"
@@ -185,6 +188,7 @@ msgstr ""
msgid "Id"
msgstr ""
+#: lib/mv_web/live/member_live/index.html.heex:221
#: lib/mv_web/live/member_live/index/formatter.ex:61
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
@@ -201,6 +205,7 @@ msgstr ""
msgid "This is a member record from your database."
msgstr ""
+#: lib/mv_web/live/member_live/index.html.heex:221
#: lib/mv_web/live/member_live/index/formatter.ex:60
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
@@ -366,12 +371,12 @@ msgstr ""
msgid "Required"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:55
+#: lib/mv_web/live/member_live/index.html.heex:62
#, elixir-autogen, elixir-format
msgid "Select all members"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:69
+#: lib/mv_web/live/member_live/index.html.heex:76
#, elixir-autogen, elixir-format
msgid "Select member"
msgstr ""
@@ -557,7 +562,7 @@ msgid "Toggle dark mode"
msgstr ""
#: 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
msgid "Search..."
msgstr ""
@@ -573,7 +578,7 @@ msgstr ""
msgid "Click to sort"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex:81
+#: lib/mv_web/live/member_live/index.html.heex:88
#, elixir-autogen, elixir-format, fuzzy
msgid "First name"
msgstr ""
@@ -783,7 +788,7 @@ msgstr ""
msgid "Unlinking scheduled"
msgstr ""
-#: lib/mv_web/live/member_live/index.ex:159
+#: lib/mv_web/live/member_live/index.ex:149
#, elixir-autogen, elixir-format
msgid "Copied %{count} email address to clipboard"
msgid_plural "Copied %{count} email addresses to clipboard"
@@ -800,12 +805,12 @@ msgstr ""
msgid "Copy emails"
msgstr ""
-#: lib/mv_web/live/member_live/index.ex:142
+#: lib/mv_web/live/member_live/index.ex:138
#, elixir-autogen, elixir-format
msgid "No email addresses found"
msgstr ""
-#: lib/mv_web/live/member_live/index.ex:126
+#: lib/mv_web/live/member_live/index.ex:135
#, elixir-autogen, elixir-format, fuzzy
msgid "No members selected"
msgstr ""
@@ -820,7 +825,29 @@ msgstr ""
msgid "Open in email program"
msgstr ""
-#: lib/mv_web/live/member_live/index.ex:168
+#: lib/mv_web/live/member_live/index.ex:158
#, elixir-autogen, elixir-format
msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
msgstr ""
+
+#: lib/mv_web/live/components/payment_filter_component.ex:72
+#: lib/mv_web/live/components/payment_filter_component.ex:135
+#, elixir-autogen, elixir-format
+msgid "All"
+msgstr ""
+
+#: lib/mv_web/live/components/payment_filter_component.ex:46
+#, elixir-autogen, elixir-format
+msgid "Filter by payment status"
+msgstr ""
+
+#: lib/mv_web/live/components/payment_filter_component.ex:100
+#: lib/mv_web/live/components/payment_filter_component.ex:137
+#, elixir-autogen, elixir-format
+msgid "Not paid"
+msgstr ""
+
+#: lib/mv_web/live/components/payment_filter_component.ex:57
+#, elixir-autogen, elixir-format
+msgid "Payment filter"
+msgstr ""
diff --git a/priv/repo/migrations/20251201115939_add_member_field_visibility_to_settings.exs b/priv/repo/migrations/20251201115939_add_member_field_visibility_to_settings.exs
new file mode 100644
index 0000000..6d278fb
--- /dev/null
+++ b/priv/repo/migrations/20251201115939_add_member_field_visibility_to_settings.exs
@@ -0,0 +1,21 @@
+defmodule Mv.Repo.Migrations.AddMemberFieldVisibilityToSettings do
+ @moduledoc """
+ Updates resources based on their most recent snapshots.
+
+ This file was autogenerated with `mix ash_postgres.generate_migrations`
+ """
+
+ use Ecto.Migration
+
+ def up do
+ alter table(:settings) do
+ add :member_field_visibility, :map
+ end
+ end
+
+ def down do
+ alter table(:settings) do
+ remove :member_field_visibility
+ end
+ end
+end
diff --git a/priv/resource_snapshots/repo/custom_fields/20251201115939.json b/priv/resource_snapshots/repo/custom_fields/20251201115939.json
new file mode 100644
index 0000000..fabd84b
--- /dev/null
+++ b/priv/resource_snapshots/repo/custom_fields/20251201115939.json
@@ -0,0 +1,144 @@
+{
+ "attributes": [
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"gen_random_uuid()\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": true,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "id",
+ "type": "uuid"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "name",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "slug",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "value_type",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "description",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "false",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "immutable",
+ "type": "boolean"
+ },
+ {
+ "allow_nil?": false,
+ "default": "false",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "required",
+ "type": "boolean"
+ },
+ {
+ "allow_nil?": false,
+ "default": "true",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "show_in_overview",
+ "type": "boolean"
+ }
+ ],
+ "base_filter": null,
+ "check_constraints": [],
+ "custom_indexes": [],
+ "custom_statements": [],
+ "has_create_action": true,
+ "hash": "D31160C95D3D32BA715D493DE2D2B8D6572E0EC68AE14B928D99975BC8A81542",
+ "identities": [
+ {
+ "all_tenants?": false,
+ "base_filter": null,
+ "index_name": "custom_fields_unique_name_index",
+ "keys": [
+ {
+ "type": "atom",
+ "value": "name"
+ }
+ ],
+ "name": "unique_name",
+ "nils_distinct?": true,
+ "where": null
+ },
+ {
+ "all_tenants?": false,
+ "base_filter": null,
+ "index_name": "custom_fields_unique_slug_index",
+ "keys": [
+ {
+ "type": "atom",
+ "value": "slug"
+ }
+ ],
+ "name": "unique_slug",
+ "nils_distinct?": true,
+ "where": null
+ }
+ ],
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "repo": "Elixir.Mv.Repo",
+ "schema": null,
+ "table": "custom_fields"
+}
\ No newline at end of file
diff --git a/priv/resource_snapshots/repo/settings/20251201115939.json b/priv/resource_snapshots/repo/settings/20251201115939.json
new file mode 100644
index 0000000..4e635c4
--- /dev/null
+++ b/priv/resource_snapshots/repo/settings/20251201115939.json
@@ -0,0 +1,79 @@
+{
+ "attributes": [
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"gen_random_uuid()\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": true,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "id",
+ "type": "uuid"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "club_name",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "member_field_visibility",
+ "type": "map"
+ },
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "inserted_at",
+ "type": "utc_datetime_usec"
+ },
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "updated_at",
+ "type": "utc_datetime_usec"
+ }
+ ],
+ "base_filter": null,
+ "check_constraints": [],
+ "custom_indexes": [],
+ "custom_statements": [],
+ "has_create_action": true,
+ "hash": "F2823210AA9E6476074A218375F64CD80E7F9E04EECC4E94D4C7FD31A773C016",
+ "identities": [],
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "repo": "Elixir.Mv.Repo",
+ "schema": null,
+ "table": "settings"
+}
\ No newline at end of file
diff --git a/test/membership/member_field_visibility_test.exs b/test/membership/member_field_visibility_test.exs
new file mode 100644
index 0000000..9963169
--- /dev/null
+++ b/test/membership/member_field_visibility_test.exs
@@ -0,0 +1,14 @@
+defmodule Mv.Membership.MemberFieldVisibilityTest do
+ @moduledoc """
+ Tests for member field visibility configuration.
+
+ Tests cover:
+ - Member fields are visible by default (show_in_overview: true)
+ - Member fields can be hidden (show_in_overview: false)
+ - Checking if a specific field is visible
+ - Configuration is stored in Settings resource
+ """
+ use Mv.DataCase, async: true
+
+ alias Mv.Membership.Member
+end
diff --git a/test/mv_web/components/payment_filter_component_test.exs b/test/mv_web/components/payment_filter_component_test.exs
new file mode 100644
index 0000000..c44bf41
--- /dev/null
+++ b/test/mv_web/components/payment_filter_component_test.exs
@@ -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
diff --git a/test/mv_web/member_live/index_custom_fields_display_test.exs b/test/mv_web/member_live/index_custom_fields_display_test.exs
index 25aefe5..0485f5e 100644
--- a/test/mv_web/member_live/index_custom_fields_display_test.exs
+++ b/test/mv_web/member_live/index_custom_fields_display_test.exs
@@ -9,7 +9,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
- Custom field values are correctly formatted for different types
- 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
require Ash.Query
diff --git a/test/mv_web/member_live/index_member_fields_display_test.exs b/test/mv_web/member_live/index_member_fields_display_test.exs
new file mode 100644
index 0000000..6b4f50c
--- /dev/null
+++ b/test/mv_web/member_live/index_member_fields_display_test.exs
@@ -0,0 +1,64 @@
+defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
+ use MvWeb.ConnCase, async: true
+ import Phoenix.LiveViewTest
+ require Ash.Query
+
+ alias Mv.Membership.Member
+
+ setup do
+ {:ok, member1} =
+ Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "Alice",
+ last_name: "Anderson",
+ email: "alice@example.com",
+ street: "Main Street",
+ house_number: "123",
+ postal_code: "12345",
+ city: "Berlin",
+ phone_number: "+49123456789",
+ join_date: ~D[2020-01-15]
+ })
+ |> Ash.create()
+
+ {:ok, member2} =
+ Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "Bob",
+ last_name: "Brown",
+ email: "bob@example.com"
+ })
+ |> Ash.create()
+
+ %{
+ member1: member1,
+ member2: member2
+ }
+ end
+
+ test "shows multiple members correctly", %{conn: conn, member1: m1, member2: m2} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, _view, html} = live(conn, "/members")
+
+ for m <- [m1, m2], field <- [m.first_name, m.last_name, m.email] do
+ assert html =~ field
+ end
+ end
+
+ test "respects show_in_overview config", %{conn: conn, member1: m} do
+ {:ok, settings} = Mv.Membership.get_settings()
+ fields_to_hide = [:street, :house_number]
+
+ {:ok, _} =
+ Mv.Membership.update_settings(settings, %{
+ member_field_visibility: Map.new(fields_to_hide, &{Atom.to_string(&1), false})
+ })
+
+ conn = conn_with_oidc_user(conn)
+ {:ok, _view, html} = live(conn, "/members")
+
+ assert html =~ "Email"
+ assert html =~ m.email
+ refute html =~ m.street
+ end
+end
diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs
index e3ad5bb..0bcc731 100644
--- a/test/mv_web/member_live/index_test.exs
+++ b/test/mv_web/member_live/index_test.exs
@@ -469,4 +469,221 @@ defmodule MvWeb.MemberLive.IndexTest do
assert has_element?(view, "#flash-group")
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