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""" +
+ + + +
+ """ + 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 diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index b0a9bc2..fcdeedd 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -30,16 +30,23 @@ defmodule MvWeb.MemberLive.Index do require Ash.Query import Ash.Expr + alias Mv.Membership alias MvWeb.MemberLive.Index.Formatter # Prefix used in sort field names for custom fields (e.g., "custom_field_") @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