Refinex CSV import and PDf export closes #299 and #433 #446

Merged
carla merged 16 commits from feat/299_plz into main 2026-02-24 16:32:32 +01:00
68 changed files with 4858 additions and 743 deletions
Showing only changes of commit 63040afee7 - Show all commits

View file

@ -30,3 +30,10 @@ ASSOCIATION_NAME="Sportsclub XYZ"
# OIDC_GROUPS_CLAIM defaults to "groups" (JWT claim name for group list). # OIDC_GROUPS_CLAIM defaults to "groups" (JWT claim name for group list).
# OIDC_ADMIN_GROUP_NAME=admin # OIDC_ADMIN_GROUP_NAME=admin
# OIDC_GROUPS_CLAIM=groups # OIDC_GROUPS_CLAIM=groups
# Optional: Vereinfacht accounting integration (finance-contacts sync)
# If set, these override values from Settings UI; those fields become read-only.
# VEREINFACHT_API_URL=https://api.verein.visuel.dev/api/v1
# VEREINFACHT_API_KEY=your-api-key
# VEREINFACHT_CLUB_ID=2
# VEREINFACHT_APP_URL=https://app.verein.visuel.dev

View file

@ -1264,6 +1264,8 @@ end
### 3.12 Internationalization: Gettext ### 3.12 Internationalization: Gettext
**German (de):** Use informal address (“duzen”). All user-facing German text should address the user as “du” (e.g. “Bitte versuche es erneut”, “Deine Einstellungen”), not “Sie”.
**Define Translations:** **Define Translations:**
```elixir ```elixir
@ -2847,12 +2849,14 @@ Building accessible applications ensures that all users, including those with di
**Required Fields:** **Required Fields:**
Which member fields are required (asterisk, tooltip, validation) is configured in **Settings** (Memberdata section: edit a member field and set "Required"). The member create/edit form and Member resource validation both read `settings.member_field_required`. Email is always required; other fields default to optional.
```heex ```heex
<!-- Mark required fields --> <!-- Mark required fields (value from settings or always true for email) -->
<.input <.input
field={@form[:first_name]} field={@form[:first_name]}
label={gettext("First Name")} label={gettext("First Name")}
required required={@member_field_required_map[:first_name]}
aria-required="true" aria-required="true"
/> />
``` ```

View file

@ -7,25 +7,25 @@
# This file is based on these images: # This file is based on these images:
# #
# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20250317-slim - for the release image # - https://hub.docker.com/_/debian?tab=tags&page=1&name=trixie-20260202-slim - for the release image
# - https://pkgs.org/ - resource for finding needed packages # - https://pkgs.org/ - resource for finding needed packages
# - Ex: hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim # - Ex: hexpm/elixir:1.18.3-erlang-27.3-debian-trixie-20260202-slim
# #
ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim" ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-27.3-debian-trixie-20260202-slim"
ARG RUNNER_IMAGE="debian:bullseye-20250317-slim" ARG RUNNER_IMAGE="debian:trixie-20260202-slim"
FROM ${BUILDER_IMAGE} AS builder FROM ${BUILDER_IMAGE} AS builder
# install build dependencies # install build dependencies
RUN apt-get update -y && apt-get install -y build-essential git \ RUN apt-get update -y && apt-get install -y build-essential git \
&& apt-get clean && rm -f /var/lib/apt/lists/*_* && apt-get clean && rm -f /var/lib/apt/lists/*_*
# prepare build dir # prepare build dir
WORKDIR /app WORKDIR /app
# install hex + rebar # install hex + rebar
RUN mix local.hex --force && \ RUN mix local.hex --force && \
mix local.rebar --force mix local.rebar --force
# set build ENV # set build ENV
ENV MIX_ENV="prod" ENV MIX_ENV="prod"
@ -64,7 +64,7 @@ RUN mix release
FROM ${RUNNER_IMAGE} FROM ${RUNNER_IMAGE}
RUN apt-get update -y && \ RUN apt-get update -y && \
apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \ apt-get install -y libstdc++6 openssl libncurses6 locales ca-certificates \
&& apt-get clean && rm -f /var/lib/apt/lists/*_* && apt-get clean && rm -f /var/lib/apt/lists/*_*
# Set the locale # Set the locale

View file

@ -99,6 +99,25 @@
/* Make LiveView wrapper divs transparent for layout */ /* Make LiveView wrapper divs transparent for layout */
[data-phx-session] { display: contents } [data-phx-session] { display: contents }
/* WCAG 1.4.12 Text Spacing: allow user stylesheets to adjust text spacing in popovers.
Popover content (e.g. from DaisyUI dropdown) must not rely on non-overridable inline
spacing; use inherited values so custom stylesheets can override. */
[popover] {
line-height: inherit;
letter-spacing: inherit;
word-spacing: inherit;
}
/* WCAG 2 AA: success/error text on light backgrounds (e.g. base-200). Use instead of
text-success/text-error when contrast ratio of theme colors is insufficient. */
.text-success-aa {
color: oklch(0.35 0.12 165);
}
.text-error-aa {
color: oklch(0.45 0.2 25);
}
/* ============================================ /* ============================================
Sidebar Base Styles Sidebar Base Styles
============================================ */ ============================================ */

View file

@ -118,6 +118,8 @@ defmodule Mv.Accounts.User do
change Mv.EmailSync.Changes.SyncUserEmailToMember do change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:email)] where [changing(:email)]
end end
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
end end
create :create_user do create :create_user do
@ -145,6 +147,8 @@ defmodule Mv.Accounts.User do
# Sync user email to member when linking (User → Member) # Sync user email to member when linking (User → Member)
change Mv.EmailSync.Changes.SyncUserEmailToMember change Mv.EmailSync.Changes.SyncUserEmailToMember
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
end end
update :update_user do update :update_user do
@ -178,6 +182,8 @@ defmodule Mv.Accounts.User do
change Mv.EmailSync.Changes.SyncUserEmailToMember do change Mv.EmailSync.Changes.SyncUserEmailToMember do
where any([changing(:email), changing(:member)]) where any([changing(:email), changing(:member)])
end end
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
end end
# Internal update used only by SystemActor/bootstrap and tests to assign role to system user. # Internal update used only by SystemActor/bootstrap and tests to assign role to system user.
@ -211,6 +217,8 @@ defmodule Mv.Accounts.User do
change Mv.EmailSync.Changes.SyncUserEmailToMember do change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:email)] where [changing(:email)]
end end
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
end end
# Action to link an OIDC account to an existing password-only user # Action to link an OIDC account to an existing password-only user
@ -248,6 +256,8 @@ defmodule Mv.Accounts.User do
change Mv.EmailSync.Changes.SyncUserEmailToMember do change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:email)] where [changing(:email)]
end end
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
end end
read :get_by_subject do read :get_by_subject do
@ -328,6 +338,8 @@ defmodule Mv.Accounts.User do
# Sync user email to member when linking (User → Member) # Sync user email to member when linking (User → Member)
change Mv.EmailSync.Changes.SyncUserEmailToMember change Mv.EmailSync.Changes.SyncUserEmailToMember
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
# Sync role from OIDC groups (e.g. admin group → Admin role) after user is created/updated # Sync role from OIDC groups (e.g. admin group → Admin role) after user is created/updated
change fn changeset, _ctx -> change fn changeset, _ctx ->
user_info = Ash.Changeset.get_argument(changeset, :user_info) user_info = Ash.Changeset.get_argument(changeset, :user_info)

View file

@ -116,6 +116,9 @@ defmodule Mv.Membership.Member do
# Requires both join_date and membership_fee_type_id to be present # Requires both join_date and membership_fee_type_id to be present
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate change Mv.MembershipFees.Changes.SetMembershipFeeStartDate
# Sync member to Vereinfacht as finance contact (if configured)
change Mv.Vereinfacht.Changes.SyncContact
# Trigger cycle generation after member creation # Trigger cycle generation after member creation
# Only runs if membership_fee_type_id is set # Only runs if membership_fee_type_id is set
# Note: Cycle generation runs asynchronously to not block the action, # Note: Cycle generation runs asynchronously to not block the action,
@ -189,6 +192,9 @@ defmodule Mv.Membership.Member do
where [changing(:membership_fee_type_id)] where [changing(:membership_fee_type_id)]
end end
# Sync member to Vereinfacht as finance contact (if configured)
change Mv.Vereinfacht.Changes.SyncContact
# Trigger cycle regeneration when membership_fee_type_id changes # Trigger cycle regeneration when membership_fee_type_id changes
# This deletes future unpaid cycles and regenerates them with the new type/amount # This deletes future unpaid cycles and regenerates them with the new type/amount
# Note: Cycle regeneration runs synchronously in the same transaction to ensure atomicity # Note: Cycle regeneration runs synchronously in the same transaction to ensure atomicity
@ -242,6 +248,13 @@ defmodule Mv.Membership.Member do
end) end)
end end
# Internal: set vereinfacht_contact_id after syncing with Vereinfacht API.
# Not exposed via code interface; used only by Mv.Vereinfacht.Changes.SyncContact.
update :set_vereinfacht_contact_id do
require_atomic? false
accept [:vereinfacht_contact_id]
end
# Action to handle fuzzy search on specific fields # Action to handle fuzzy search on specific fields
read :search do read :search do
argument :query, :string, allow_nil?: true argument :query, :string, allow_nil?: true
@ -319,6 +332,12 @@ defmodule Mv.Membership.Member do
authorize_if Mv.Authorization.Checks.HasPermission authorize_if Mv.Authorization.Checks.HasPermission
end end
# Internal sync action: only SystemActor may set vereinfacht_contact_id (used by SyncContact change).
policy action(:set_vereinfacht_contact_id) do
description "Only system actor may set Vereinfacht contact ID"
authorize_if Mv.Authorization.Checks.ActorIsSystemUser
end
# CREATE/UPDATE: Forbid memberuser link unless admin, then check permissions # CREATE/UPDATE: Forbid memberuser link unless admin, then check permissions
# ForbidMemberUserLinkUnlessAdmin: only admins may pass :user (link or unlink via nil/empty). # ForbidMemberUserLinkUnlessAdmin: only admins may pass :user (link or unlink via nil/empty).
# HasPermission: :own_data → update linked; :read_only → no update; :normal_user/admin → update all. # HasPermission: :own_data → update linked; :read_only → no update; :normal_user/admin → update all.
@ -475,48 +494,97 @@ defmodule Mv.Membership.Member do
end end
end end
# Validate required custom fields (actor from validation context only; no fallback) # Validate required custom fields (actor from validation context only; no fallback).
# Only for create_member/update_member; skip for set_vereinfacht_contact_id (internal sync
# only sets vereinfacht_contact_id; custom fields were already validated and saved).
validate fn changeset, context -> validate fn changeset, context ->
provided_values = provided_custom_field_values(changeset) provided_values = provided_custom_field_values(changeset)
actor = context.actor actor = context.actor
case Mv.Membership.list_required_custom_fields(actor: actor) do case Mv.Membership.list_required_custom_fields(actor: actor) do
{:ok, required_custom_fields} -> {:ok, required_custom_fields} ->
missing_fields = missing_required_fields(required_custom_fields, provided_values) missing_fields =
missing_required_fields(required_custom_fields, provided_values)
if Enum.empty?(missing_fields) do if Enum.empty?(missing_fields) do
:ok :ok
else else
build_custom_field_validation_error(missing_fields) build_custom_field_validation_error(missing_fields)
end end
{:error, %Ash.Error.Forbidden{}} -> {:error, %Ash.Error.Forbidden{}} ->
Logger.warning( Logger.warning(
"Required custom fields validation: actor not authorized to read CustomField" "Required custom fields validation: actor not authorized to read CustomField"
) )
{:error, {:error,
field: :custom_field_values, field: :custom_field_values,
message: message:
"You are not authorized to perform this action. Please sign in again or contact support."} "You are not authorized to perform this action. Please sign in again or contact support."}
{:error, :missing_actor} -> {:error, :missing_actor} ->
Logger.warning("Required custom fields validation: no actor in context") Logger.warning("Required custom fields validation: no actor in context")
{:error, {:error,
field: :custom_field_values, field: :custom_field_values,
message: message:
"You are not authorized to perform this action. Please sign in again or contact support."} "You are not authorized to perform this action. Please sign in again or contact support."}
{:error, error} -> {:error, error} ->
Logger.error( Logger.error(
"Failed to load custom fields for validation: #{inspect(error)}. Required field validation cannot be performed." "Failed to load custom fields for validation: #{inspect(error)}. Required field validation cannot be performed."
) )
{:error, {:error,
field: :custom_field_values, field: :custom_field_values,
message: message:
"Unable to validate required custom fields. Please try again or contact support."} "Unable to validate required custom fields. Please try again or contact support."}
end
end,
where: [action_is([:create_member, :update_member])]
# Validate member fields that are marked as required in settings or by Vereinfacht.
# When settings cannot be loaded, we still enforce email + Vereinfacht-required fields.
validate fn changeset, _context ->
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
required_fields =
case Mv.Membership.get_settings() do
{:ok, settings} ->
required_config = settings.member_field_required || %{}
normalized = VisibilityConfig.normalize(required_config)
Enum.filter(Mv.Constants.member_fields(), fn field ->
field == :email ||
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field)) ||
Map.get(normalized, field, false)
end)
{:error, reason} ->
Logger.warning(
"Member required-fields validation: could not load settings (#{inspect(reason)}). " <>
"Enforcing only email and Vereinfacht-required fields."
)
Enum.filter(Mv.Constants.member_fields(), fn field ->
field == :email ||
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field))
end)
end
missing =
Enum.filter(required_fields, fn field ->
value = Ash.Changeset.get_attribute(changeset, field)
not member_field_value_present?(field, value)
end)
if Enum.empty?(missing) do
:ok
else
field = hd(missing)
{:error,
field: field, message: Gettext.dgettext(MvWeb.Gettext, "default", "can't be blank")}
end end
end end
end end
@ -591,6 +659,14 @@ defmodule Mv.Membership.Member do
public? true public? true
description "Date from which membership fees should be calculated" description "Date from which membership fees should be calculated"
end end
# Vereinfacht accounting software integration: ID of the finance contact synced via API.
# Set by Mv.Vereinfacht.Changes.SyncContact; not accepted in create/update actions.
attribute :vereinfacht_contact_id, :string do
allow_nil? true
public? true
description "ID of the finance contact in Vereinfacht (set by sync)"
end
end end
relationships do relationships do
@ -1272,17 +1348,24 @@ defmodule Mv.Membership.Member do
end end
end end
# Extracts custom field values from existing member data (update scenario) # Extracts custom field values from existing member data (update scenario).
# Actor must come from context; no system-actor fallback (per guidelines).
# When no actor is present we skip the load and return empty map.
defp extract_existing_values(member_data, changeset) do defp extract_existing_values(member_data, changeset) do
actor = Map.get(changeset.context, :actor) case Map.get(changeset.context, :actor) do
opts = Helpers.ash_actor_opts(actor) nil ->
case Ash.load(member_data, :custom_field_values, opts) do
{:ok, %{custom_field_values: existing_values}} ->
Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2)
_ ->
%{} %{}
actor ->
opts = Helpers.ash_actor_opts(actor)
case Ash.load(member_data, :custom_field_values, opts) do
{:ok, %{custom_field_values: existing_values}} ->
Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2)
_ ->
%{}
end
end end
end end
@ -1385,4 +1468,14 @@ defmodule Mv.Membership.Member do
defp value_present?(_value, :email), do: false defp value_present?(_value, :email), do: false
defp value_present?(_value, _type), do: false defp value_present?(_value, _type), do: false
# Used by member-field-required validation (settings-driven required fields)
defp member_field_value_present?(_field, nil), do: false
defp member_field_value_present?(_, value) when is_binary(value),
do: String.trim(value) != ""
defp member_field_value_present?(_, %Date{}), do: true
defp member_field_value_present?(_, value) when is_struct(value, Date), do: true
defp member_field_value_present?(_, _), do: false
end end

View file

@ -64,6 +64,8 @@ defmodule Mv.Membership do
define :update_single_member_field_visibility, define :update_single_member_field_visibility,
action: :update_single_member_field_visibility action: :update_single_member_field_visibility
define :update_single_member_field, action: :update_single_member_field
end end
resource Mv.Membership.Group do resource Mv.Membership.Group do
@ -257,6 +259,46 @@ defmodule Mv.Membership do
|> Ash.update(domain: __MODULE__) |> Ash.update(domain: __MODULE__)
end end
@doc """
Atomically updates visibility and required for a single member field.
Updates both `member_field_visibility` and `member_field_required` in one
operation. Use this when saving from the member field settings form.
## Parameters
- `settings` - The settings record to update
- `field` - The member field name as a string (e.g., "first_name", "street")
- `show_in_overview` - Boolean value indicating visibility in member overview
- `required` - Boolean value indicating whether the field is required in member forms
## 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_single_member_field(settings, field: "first_name", show_in_overview: true, required: true)
iex> updated.member_field_required["first_name"]
true
"""
def update_single_member_field(settings,
field: field,
show_in_overview: show_in_overview,
required: required
) do
settings
|> Ash.Changeset.new()
|> Ash.Changeset.set_argument(:field, field)
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|> Ash.Changeset.set_argument(:required, required)
|> Ash.Changeset.for_update(:update_single_member_field, %{})
|> Ash.update(domain: __MODULE__)
end
@doc """ @doc """
Gets a group by its slug. Gets a group by its slug.

View file

@ -11,6 +11,8 @@ defmodule Mv.Membership.Setting do
- `club_name` - The name of the association/club (required, cannot be empty) - `club_name` - The name of the association/club (required, cannot be empty)
- `member_field_visibility` - JSONB map storing visibility configuration for member fields - `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`. (e.g., `%{"street" => false, "house_number" => false}`). Fields not in the map default to `true`.
- `member_field_required` - JSONB map storing which member fields are required in forms
(e.g., `%{"first_name" => true, "last_name" => true}`). Email is always required; other fields default to optional.
- `include_joining_cycle` - Whether to include the joining cycle in membership fee generation (default: true) - `include_joining_cycle` - Whether to include the joining cycle in membership fee generation (default: true)
- `default_membership_fee_type_id` - Default membership fee type for new members (optional) - `default_membership_fee_type_id` - Default membership fee type for new members (optional)
@ -42,6 +44,9 @@ defmodule Mv.Membership.Setting do
# Update member field visibility # Update member field visibility
{:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false}) {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
# Update visibility and required for a single member field (e.g. from settings UI)
{:ok, updated} = Mv.Membership.update_single_member_field(settings, field: "first_name", show_in_overview: true, required: true)
# Update membership fee settings # Update membership fee settings
{:ok, updated} = Mv.Membership.update_settings(settings, %{include_joining_cycle: false}) {:ok, updated} = Mv.Membership.update_settings(settings, %{include_joining_cycle: false})
""" """
@ -68,8 +73,13 @@ defmodule Mv.Membership.Setting do
accept [ accept [
:club_name, :club_name,
:member_field_visibility, :member_field_visibility,
:member_field_required,
:include_joining_cycle, :include_joining_cycle,
:default_membership_fee_type_id :default_membership_fee_type_id,
:vereinfacht_api_url,
:vereinfacht_api_key,
:vereinfacht_club_id,
:vereinfacht_app_url
] ]
end end
@ -80,8 +90,13 @@ defmodule Mv.Membership.Setting do
accept [ accept [
:club_name, :club_name,
:member_field_visibility, :member_field_visibility,
:member_field_required,
:include_joining_cycle, :include_joining_cycle,
:default_membership_fee_type_id :default_membership_fee_type_id,
:vereinfacht_api_url,
:vereinfacht_api_key,
:vereinfacht_club_id,
:vereinfacht_app_url
] ]
end end
@ -101,6 +116,17 @@ defmodule Mv.Membership.Setting do
change Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility change Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility
end end
update :update_single_member_field do
description "Atomically updates visibility and required for a single member field"
require_atomic? false
argument :field, :string, allow_nil?: false
argument :show_in_overview, :boolean, allow_nil?: false
argument :required, :boolean, allow_nil?: false
change Mv.Membership.Setting.Changes.UpdateSingleMemberField
end
update :update_membership_fee_settings do update :update_membership_fee_settings do
description "Updates the membership fee configuration" description "Updates the membership fee configuration"
require_atomic? false require_atomic? false
@ -154,6 +180,44 @@ defmodule Mv.Membership.Setting do
end, end,
on: [:create, :update] on: [:create, :update]
# Validate member_field_required map structure and content
validate fn changeset, _context ->
required_config = Ash.Changeset.get_attribute(changeset, :member_field_required)
if required_config && is_map(required_config) do
invalid_values =
Enum.filter(required_config, fn {_key, value} ->
not is_boolean(value)
end)
valid_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
invalid_keys =
Enum.filter(required_config, 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_required,
message: "All values in member_field_required must be booleans"}
not Enum.empty?(invalid_keys) ->
{:error,
field: :member_field_required,
message: "Invalid member field keys: #{inspect(invalid_keys)}"}
true ->
:ok
end
else
:ok
end
end,
on: [:create, :update]
# Validate default_membership_fee_type_id exists if set # Validate default_membership_fee_type_id exists if set
validate fn changeset, context -> validate fn changeset, context ->
fee_type_id = fee_type_id =
@ -211,6 +275,12 @@ defmodule Mv.Membership.Setting do
description: description:
"Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans." "Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans."
attribute :member_field_required, :map,
allow_nil?: true,
public?: true,
description:
"Configuration for which member fields are required in forms (JSONB map). Keys are member field names (strings), values are booleans. Email is always required."
# Membership fee settings # Membership fee settings
attribute :include_joining_cycle, :boolean do attribute :include_joining_cycle, :boolean do
allow_nil? false allow_nil? false
@ -225,6 +295,33 @@ defmodule Mv.Membership.Setting do
description "Default membership fee type ID for new members" description "Default membership fee type ID for new members"
end end
# Vereinfacht accounting software integration (can be overridden by ENV)
attribute :vereinfacht_api_url, :string do
allow_nil? true
public? true
description "Vereinfacht API base URL (e.g. https://api.verein.visuel.dev/api/v1)"
end
attribute :vereinfacht_api_key, :string do
allow_nil? true
public? false
description "Vereinfacht API key (Bearer token)"
sensitive? true
end
attribute :vereinfacht_club_id, :string do
allow_nil? true
public? true
description "Vereinfacht club ID for multi-tenancy"
end
attribute :vereinfacht_app_url, :string do
allow_nil? true
public? true
description "Vereinfacht app base URL for contact view links (e.g. https://app.verein.visuel.dev)"
end
timestamps() timestamps()
end end

View file

@ -0,0 +1,179 @@
defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberField do
@moduledoc """
Ash change that atomically updates visibility and required for a single member field.
Updates both `member_field_visibility` and `member_field_required` JSONB maps
in one SQL UPDATE to avoid lost updates when saving from the settings UI.
## Arguments
- `field` - The member field name as a string (e.g., "street", "first_name")
- `show_in_overview` - Boolean value indicating visibility in member overview
- `required` - Boolean value indicating whether the field is required in member forms
## Example
settings
|> Ash.Changeset.for_update(:update_single_member_field, %{},
arguments: %{field: "first_name", show_in_overview: true, required: true}
)
|> Ash.update(domain: Mv.Membership)
"""
use Ash.Resource.Change
alias Ash.Error.Invalid
alias Ecto.Adapters.SQL
require Logger
def change(changeset, _opts, _context) do
with {:ok, field} <- get_and_validate_field(changeset),
{:ok, show_in_overview} <- get_and_validate_boolean(changeset, :show_in_overview),
{:ok, required} <- get_and_validate_boolean(changeset, :required) do
add_after_action(changeset, field, show_in_overview, required)
else
{:error, updated_changeset} -> updated_changeset
end
end
defp get_and_validate_field(changeset) do
case Ash.Changeset.get_argument(changeset, :field) do
nil ->
{:error,
add_error(changeset,
field: :field,
message: "field argument is required"
)}
field ->
valid_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
if field in valid_fields do
{:ok, field}
else
{:error,
add_error(
changeset,
field: :field,
message: "Invalid member field: #{field}"
)}
end
end
end
defp get_and_validate_boolean(changeset, :show_in_overview = arg_name) do
do_validate_boolean(changeset, arg_name, :show_in_overview)
end
defp get_and_validate_boolean(changeset, :required = arg_name) do
do_validate_boolean(changeset, arg_name, :member_field_required)
end
defp do_validate_boolean(changeset, arg_name, error_field) do
case Ash.Changeset.get_argument(changeset, arg_name) do
nil ->
{:error,
add_error(
changeset,
field: error_field,
message: "#{arg_name} argument is required"
)}
value when is_boolean(value) ->
{:ok, value}
_ ->
{:error,
add_error(
changeset,
field: error_field,
message: "#{arg_name} must be a boolean"
)}
end
end
defp add_error(changeset, opts) do
Ash.Changeset.add_error(changeset, opts)
end
defp add_after_action(changeset, field, show_in_overview, required) do
Ash.Changeset.after_action(changeset, fn _changeset, settings ->
# Update both JSONB columns in one statement
sql = """
UPDATE settings
SET
member_field_visibility = jsonb_set(
COALESCE(member_field_visibility, '{}'::jsonb),
ARRAY[$1::text],
to_jsonb($2::boolean),
true
),
member_field_required = jsonb_set(
COALESCE(member_field_required, '{}'::jsonb),
ARRAY[$1::text],
to_jsonb($3::boolean),
true
),
updated_at = (now() AT TIME ZONE 'utc')
WHERE id = $4
RETURNING member_field_visibility, member_field_required
"""
uuid_binary = Ecto.UUID.dump!(settings.id)
case SQL.query(Mv.Repo, sql, [field, show_in_overview, required, uuid_binary]) do
{:ok, %{rows: [[updated_visibility, updated_required] | _]}} ->
vis = normalize_jsonb_result(updated_visibility)
req = normalize_jsonb_result(updated_required)
updated_settings = %{
settings
| member_field_visibility: vis,
member_field_required: req
}
{:ok, updated_settings}
{:ok, %{rows: []}} ->
{:error,
Invalid.exception(
field: :member_field_required,
message: "Settings not found"
)}
{:error, error} ->
Logger.error("Failed to atomically update member field settings: #{inspect(error)}")
{:error,
Invalid.exception(
field: :member_field_required,
message: "Failed to update member field settings"
)}
end
end)
end
defp normalize_jsonb_result(updated_jsonb) do
case updated_jsonb do
map when is_map(map) ->
Enum.reduce(map, %{}, fn
{k, v}, acc when is_atom(k) -> Map.put(acc, Atom.to_string(k), v)
{k, v}, acc -> Map.put(acc, k, v)
end)
binary when is_binary(binary) ->
case Jason.decode(binary) do
{:ok, decoded} when is_map(decoded) ->
decoded
{:ok, _} ->
%{}
{:error, reason} ->
Logger.warning("Failed to decode JSONB: #{inspect(reason)}")
%{}
end
_ ->
Logger.warning("Unexpected JSONB format: #{inspect(updated_jsonb)}")
%{}
end
end
end

View file

@ -7,6 +7,8 @@ defmodule Mv.Application do
@impl true @impl true
def start(_type, _args) do def start(_type, _args) do
Mv.Vereinfacht.SyncFlash.create_table!()
children = [ children = [
MvWeb.Telemetry, MvWeb.Telemetry,
Mv.Repo, Mv.Repo,

View file

@ -0,0 +1,15 @@
defmodule Mv.Authorization.Checks.ActorIsSystemUser do
@moduledoc """
Policy check: true only when the actor is the system user (e.g. system@mila.local).
Used to restrict internal actions (e.g. Member.set_vereinfacht_contact_id) so that
only code paths using SystemActor can perform them, not regular admins.
"""
use Ash.Policy.SimpleCheck
@impl true
def describe(_opts), do: "actor is the system user"
@impl true
def match?(actor, _context, _opts), do: Mv.Helpers.SystemActor.system_user?(actor)
end

View file

@ -142,4 +142,160 @@ defmodule Mv.Config do
|> Keyword.get(key, default) |> Keyword.get(key, default)
|> parse_and_validate_integer(default) |> parse_and_validate_integer(default)
end end
# ---------------------------------------------------------------------------
# Vereinfacht accounting software integration
# ENV variables take priority; fallback to Settings from database.
# ---------------------------------------------------------------------------
@doc """
Returns the Vereinfacht API base URL.
Reads from `VEREINFACHT_API_URL` env first, then from Settings.
"""
@spec vereinfacht_api_url() :: String.t() | nil
def vereinfacht_api_url do
env_or_setting("VEREINFACHT_API_URL", :vereinfacht_api_url)
end
@doc """
Returns the Vereinfacht API key (Bearer token).
Reads from `VEREINFACHT_API_KEY` env first, then from Settings.
"""
@spec vereinfacht_api_key() :: String.t() | nil
def vereinfacht_api_key do
env_or_setting("VEREINFACHT_API_KEY", :vereinfacht_api_key)
end
@doc """
Returns the Vereinfacht club ID for multi-tenancy.
Reads from `VEREINFACHT_CLUB_ID` env first, then from Settings.
"""
@spec vereinfacht_club_id() :: String.t() | nil
def vereinfacht_club_id do
env_or_setting("VEREINFACHT_CLUB_ID", :vereinfacht_club_id)
end
@doc """
Returns the Vereinfacht app base URL for contact view links (frontend, not API).
Reads from `VEREINFACHT_APP_URL` env first, then from Settings.
Used to build links like https://app.verein.visuel.dev/en/admin/finances/contacts/{id}.
If not set, derived from API URL by replacing host \"api.\" with \"app.\" when possible.
"""
@spec vereinfacht_app_url() :: String.t() | nil
def vereinfacht_app_url do
env_or_setting("VEREINFACHT_APP_URL", :vereinfacht_app_url) ||
derive_app_url_from_api_url(vereinfacht_api_url())
end
defp derive_app_url_from_api_url(nil), do: nil
defp derive_app_url_from_api_url(api_url) when is_binary(api_url) do
api_url = String.trim(api_url)
uri = URI.parse(api_url)
host = uri.host || ""
if String.starts_with?(host, "api.") do
app_host = "app." <> String.slice(host, 4..-1//1)
scheme = uri.scheme || "https"
"#{scheme}://#{app_host}"
else
nil
end
end
defp derive_app_url_from_api_url(_), do: nil
@doc """
Returns true if Vereinfacht is fully configured (URL, API key, and club ID all set).
"""
@spec vereinfacht_configured?() :: boolean()
def vereinfacht_configured? do
present?(vereinfacht_api_url()) and present?(vereinfacht_api_key()) and
present?(vereinfacht_club_id())
end
@doc """
Returns true if any Vereinfacht ENV variable is set (used to show hint in Settings UI).
"""
@spec vereinfacht_env_configured?() :: boolean()
def vereinfacht_env_configured? do
vereinfacht_api_url_env_set?() or vereinfacht_api_key_env_set?() or
vereinfacht_club_id_env_set?()
end
@doc """
Returns true if VEREINFACHT_API_URL is set (field is read-only in Settings).
"""
def vereinfacht_api_url_env_set?, do: env_set?("VEREINFACHT_API_URL")
@doc """
Returns true if VEREINFACHT_API_KEY is set (field is read-only in Settings).
"""
def vereinfacht_api_key_env_set?, do: env_set?("VEREINFACHT_API_KEY")
@doc """
Returns true if VEREINFACHT_CLUB_ID is set (field is read-only in Settings).
"""
def vereinfacht_club_id_env_set?, do: env_set?("VEREINFACHT_CLUB_ID")
@doc """
Returns true if VEREINFACHT_APP_URL is set (field is read-only in Settings).
"""
def vereinfacht_app_url_env_set?, do: env_set?("VEREINFACHT_APP_URL")
defp env_set?(key) do
case System.get_env(key) do
nil -> false
v when is_binary(v) -> String.trim(v) != ""
_ -> false
end
end
defp env_or_setting(env_key, setting_key) do
case System.get_env(env_key) do
nil -> get_vereinfacht_from_settings(setting_key)
value -> trim_nil(value)
end
end
defp get_vereinfacht_from_settings(key) do
case Mv.Membership.get_settings() do
{:ok, settings} -> settings |> Map.get(key) |> trim_nil()
{:error, _} -> nil
end
end
defp trim_nil(nil), do: nil
defp trim_nil(s) when is_binary(s) do
t = String.trim(s)
if t == "", do: nil, else: t
end
@doc """
Returns the URL to view a finance contact in the Vereinfacht app (frontend).
Uses the configured app base URL (or derived from API URL) and appends
/en/admin/finances/contacts/{id}. Returns nil if no app URL can be determined.
"""
@spec vereinfacht_contact_view_url(String.t()) :: String.t() | nil
def vereinfacht_contact_view_url(contact_id) when is_binary(contact_id) do
base = vereinfacht_app_url()
if present?(base) do
base
|> String.trim_trailing("/")
|> then(&"#{&1}/en/admin/finances/contacts/#{contact_id}")
else
nil
end
end
defp present?(nil), do: false
defp present?(s) when is_binary(s), do: String.trim(s) != ""
defp present?(_), do: false
end end

View file

@ -28,8 +28,26 @@ defmodule Mv.Constants do
@email_validator_checks [:html_input, :pow] @email_validator_checks [:html_input, :pow]
# Member fields that are required when Vereinfacht integration is active (contact sync)
@vereinfacht_required_member_fields [:first_name, :last_name, :street, :postal_code, :city]
def member_fields, do: @member_fields def member_fields, do: @member_fields
@doc """
Returns member fields that are always required when Vereinfacht integration is configured.
Used for validation, member form required indicators, and settings UI (checkbox disabled).
"""
def vereinfacht_required_member_fields, do: @vereinfacht_required_member_fields
@doc """
Returns whether the given member field is required by Vereinfacht when integration is active.
"""
def vereinfacht_required_field?(field) when is_atom(field),
do: field in @vereinfacht_required_member_fields
def vereinfacht_required_field?(_), do: false
@doc """ @doc """
Returns the prefix used for custom field keys in field visibility maps. Returns the prefix used for custom field keys in field visibility maps.

View file

@ -16,7 +16,7 @@ defmodule Mv.Membership.MemberExport do
alias MvWeb.MemberLive.Index.MembershipFeeStatus alias MvWeb.MemberLive.Index.MembershipFeeStatus
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++ @member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
["membership_fee_status"] ["membership_fee_status", "groups"]
@computed_export_fields ["membership_fee_status"] @computed_export_fields ["membership_fee_status"]
@computed_insert_after "membership_fee_start_date" @computed_insert_after "membership_fee_start_date"
@custom_field_prefix Mv.Constants.custom_field_prefix() @custom_field_prefix Mv.Constants.custom_field_prefix()
@ -323,10 +323,14 @@ defmodule Mv.Membership.MemberExport do
|> Enum.filter(&(&1 in @domain_member_field_strings)) |> Enum.filter(&(&1 in @domain_member_field_strings))
|> order_member_fields_like_table() |> order_member_fields_like_table()
# final member_fields list (used for column specs order): table order + computed inserted # Separate groups from other fields (groups is handled as a special field, not a member field)
groups_field = if "groups" in member_fields, do: ["groups"], else: []
# final member_fields list (used for column specs order): table order + computed inserted + groups
ordered_member_fields = ordered_member_fields =
selectable_member_fields selectable_member_fields
|> insert_computed_fields_like_table(computed_fields) |> insert_computed_fields_like_table(computed_fields)
|> then(fn fields -> fields ++ groups_field end)
%{ %{
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")), selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),

View file

@ -132,12 +132,15 @@ defmodule Mv.Membership.MemberExport.Build do
parsed.computed_fields != [] or parsed.computed_fields != [] or
"membership_fee_status" in parsed.member_fields "membership_fee_status" in parsed.member_fields
need_groups = "groups" in parsed.member_fields
query = query =
Member Member
|> Ash.Query.new() |> Ash.Query.new()
|> Ash.Query.select(select_fields) |> Ash.Query.select(select_fields)
|> load_custom_field_values_query(custom_field_ids_union) |> load_custom_field_values_query(custom_field_ids_union)
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle) |> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|> maybe_load_groups(need_groups)
query = query =
if parsed.selected_ids != [] do if parsed.selected_ids != [] do
@ -241,16 +244,22 @@ defmodule Mv.Membership.MemberExport.Build do
defp maybe_sort(query, _field, nil), do: {query, false} defp maybe_sort(query, _field, nil), do: {query, false}
defp maybe_sort(query, field, order) when is_binary(field) do defp maybe_sort(query, field, order) when is_binary(field) do
if custom_field_sort?(field) do cond do
{query, true} field == "groups" ->
else # Groups sort → in-memory nach dem Read (wie Tabelle)
field_atom = String.to_existing_atom(field) {query, true}
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do custom_field_sort?(field) ->
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false} {query, true}
else
{query, false} true ->
end field_atom = String.to_existing_atom(field)
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
else
{query, false}
end
end end
rescue rescue
ArgumentError -> {query, false} ArgumentError -> {query, false}
@ -260,11 +269,25 @@ defmodule Mv.Membership.MemberExport.Build do
do: [] do: []
defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) do defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) do
if field == "groups" do
sort_members_by_groups_export(members, order)
else
sort_by_custom_field_value(members, field, order, custom_fields)
end
end
defp sort_by_custom_field_value(members, field, order, custom_fields) do
id_str = String.trim_leading(field, @custom_field_prefix) id_str = String.trim_leading(field, @custom_field_prefix)
custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
if is_nil(custom_field), do: members if is_nil(custom_field) do
members
else
sort_members_with_custom_field(members, custom_field, order)
end
end
defp sort_members_with_custom_field(members, custom_field, order) do
key_fn = fn member -> key_fn = fn member ->
cfv = find_cfv(member, custom_field) cfv = find_cfv(member, custom_field)
raw = if cfv, do: cfv.value, else: nil raw = if cfv, do: cfv.value, else: nil
@ -277,6 +300,26 @@ defmodule Mv.Membership.MemberExport.Build do
|> Enum.map(fn {m, _} -> m end) |> Enum.map(fn {m, _} -> m end)
end end
defp sort_members_by_groups_export(members, order) do
# Members with groups first, then by first group name alphabetically (min = first by sort order)
# Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2
first_group_name = fn member ->
(member.groups || [])
|> Enum.map(& &1.name)
|> Enum.min(fn -> nil end)
end
members
|> Enum.sort_by(fn member ->
name = first_group_name.(member)
# Nil (no groups) sorts last in asc, first in desc
{name == nil, name || ""}
end)
|> then(fn list ->
if order == "desc", do: Enum.reverse(list), else: list
end)
end
defp find_cfv(member, custom_field) do defp find_cfv(member, custom_field) do
(member.custom_field_values || []) (member.custom_field_values || [])
|> Enum.find(fn cfv -> |> Enum.find(fn cfv ->
@ -294,6 +337,13 @@ defmodule Mv.Membership.MemberExport.Build do
MembershipFeeStatus.load_cycles_for_members(query, show_current) MembershipFeeStatus.load_cycles_for_members(query, show_current)
end end
defp maybe_load_groups(query, false), do: query
defp maybe_load_groups(query, true) do
# Load groups with id and name only (for export formatting)
Ash.Query.load(query, groups: [:id, :name])
end
defp apply_cycle_status_filter(members, nil, _show_current), do: members defp apply_cycle_status_filter(members, nil, _show_current), do: members
defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do
@ -343,6 +393,19 @@ defmodule Mv.Membership.MemberExport.Build do
} }
end) end)
groups_col =
if "groups" in parsed.member_fields do
[
%{
key: :groups,
kind: :groups,
label: label_fn.(:groups)
}
]
else
[]
end
custom_cols = custom_cols =
parsed.custom_field_ids parsed.custom_field_ids
|> Enum.map(fn id -> |> Enum.map(fn id ->
@ -361,7 +424,7 @@ defmodule Mv.Membership.MemberExport.Build do
end) end)
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
member_cols ++ computed_cols ++ custom_cols member_cols ++ computed_cols ++ groups_col ++ custom_cols
end end
defp build_rows(members, columns, custom_fields_by_id) do defp build_rows(members, columns, custom_fields_by_id) do
@ -391,6 +454,11 @@ defmodule Mv.Membership.MemberExport.Build do
if is_binary(value), do: value, else: "" if is_binary(value), do: value, else: ""
end end
defp cell_value(member, %{kind: :groups, key: :groups}, _custom_fields_by_id) do
groups = Map.get(member, :groups) || []
format_groups(groups)
end
defp key_to_atom(k) when is_atom(k), do: k defp key_to_atom(k) when is_atom(k), do: k
defp key_to_atom(k) when is_binary(k) do defp key_to_atom(k) when is_binary(k) do
@ -424,6 +492,15 @@ defmodule Mv.Membership.MemberExport.Build do
defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt) defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
defp format_member_value(value), do: to_string(value) defp format_member_value(value), do: to_string(value)
defp format_groups([]), do: ""
defp format_groups(groups) when is_list(groups) do
groups
|> Enum.map(fn group -> Map.get(group, :name) || "" end)
|> Enum.reject(&(&1 == ""))
|> Enum.join(", ")
end
defp build_meta(members) do defp build_meta(members) do
%{ %{
generated_at: DateTime.utc_now() |> DateTime.to_iso8601(), generated_at: DateTime.utc_now() |> DateTime.to_iso8601(),

View file

@ -59,6 +59,11 @@ defmodule Mv.Membership.MembersCSV do
if is_binary(value), do: value, else: "" if is_binary(value), do: value, else: ""
end end
defp cell_value(member, %{kind: :groups, key: :groups}) do
groups = Map.get(member, :groups) || []
format_groups(groups)
end
defp key_to_atom(k) when is_atom(k), do: k defp key_to_atom(k) when is_atom(k), do: k
defp key_to_atom(k) when is_binary(k) do defp key_to_atom(k) when is_binary(k) do
@ -97,4 +102,13 @@ defmodule Mv.Membership.MembersCSV do
defp format_member_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt) defp format_member_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt) defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
defp format_member_value(value), do: to_string(value) defp format_member_value(value), do: to_string(value)
defp format_groups([]), do: ""
defp format_groups(groups) when is_list(groups) do
groups
|> Enum.map(fn group -> Map.get(group, :name) || "" end)
|> Enum.reject(&(&1 == ""))
|> Enum.join(", ")
end
end end

View file

@ -0,0 +1,91 @@
defmodule Mv.Vereinfacht.Changes.SyncContact do
@moduledoc """
Syncs a member to Vereinfacht as a finance contact after create/update.
- If the member has no `vereinfacht_contact_id`, creates a contact via API and saves the ID.
- If the member already has an ID, updates the contact via API.
Runs in `after_transaction` so the member is persisted first. API failures are logged
but do not block the member operation. Requires Vereinfacht to be configured
(Mv.Config.vereinfacht_configured?/0).
Only runs when relevant data changed: on create always; on update only when
first_name, last_name, email, street, house_number, postal_code, or city changed,
or when the member has no vereinfacht_contact_id yet (to avoid unnecessary API calls).
"""
use Ash.Resource.Change
require Logger
@synced_attributes [
:first_name,
:last_name,
:email,
:street,
:house_number,
:postal_code,
:city
]
@impl true
def change(changeset, _opts, _context) do
if Mv.Config.vereinfacht_configured?() and sync_relevant?(changeset) do
Ash.Changeset.after_transaction(changeset, &sync_after_transaction/2)
else
changeset
end
end
defp sync_relevant?(changeset) do
case changeset.action_type do
:create -> true
:update -> relevant_update?(changeset)
_ -> false
end
end
defp relevant_update?(changeset) do
any_synced_attr_changed? =
Enum.any?(@synced_attributes, &Ash.Changeset.changing_attribute?(changeset, &1))
record = changeset.data
no_contact_id_yet? = record && blank_contact_id?(record.vereinfacht_contact_id)
any_synced_attr_changed? or no_contact_id_yet?
end
defp blank_contact_id?(nil), do: true
defp blank_contact_id?(""), do: true
defp blank_contact_id?(s) when is_binary(s), do: String.trim(s) == ""
defp blank_contact_id?(_), do: false
# Ash calls after_transaction with (changeset, result) only - 2 args.
defp sync_after_transaction(_changeset, {:ok, member}) do
case Mv.Vereinfacht.sync_member(member) do
:ok ->
Mv.Vereinfacht.SyncFlash.store(to_string(member.id), :ok, "Synced to Vereinfacht.")
{:ok, member}
{:ok, member_updated} ->
Mv.Vereinfacht.SyncFlash.store(
to_string(member_updated.id),
:ok,
"Synced to Vereinfacht."
)
{:ok, member_updated}
{:error, reason} ->
Logger.warning("Vereinfacht sync failed for member #{member.id}: #{inspect(reason)}")
Mv.Vereinfacht.SyncFlash.store(
to_string(member.id),
:warning,
Mv.Vereinfacht.format_error(reason)
)
{:ok, member}
end
end
defp sync_after_transaction(_changeset, error), do: error
end

View file

@ -0,0 +1,71 @@
defmodule Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange do
@moduledoc """
Syncs the linked Member to Vereinfacht after a User action that may have updated
the member's email via Ecto (e.g. User email change → SyncUserEmailToMember).
Attach to any User action that uses SyncUserEmailToMember. After the transaction
commits, if the user has a linked member and Vereinfacht is configured, syncs
that member to the API. Failures are logged but do not affect the User result.
"""
use Ash.Resource.Change
require Logger
alias Mv.Membership.Member
alias Mv.Membership
alias Mv.Helpers.SystemActor
alias Mv.Helpers
@impl true
def change(changeset, _opts, _context) do
if Mv.Config.vereinfacht_configured?() and relevant_change?(changeset) do
Ash.Changeset.after_transaction(changeset, &sync_linked_member_after_transaction/2)
else
changeset
end
end
# Only sync when something that affects the linked member's data actually changed
# (email sync or member link), to avoid unnecessary API calls on every user update.
defp relevant_change?(changeset) do
Ash.Changeset.changing_attribute?(changeset, :email) or
Ash.Changeset.changing_relationship?(changeset, :member)
end
defp sync_linked_member_after_transaction(_changeset, {:ok, user}) do
case load_linked_member(user) do
nil ->
{:ok, user}
member ->
case Mv.Vereinfacht.sync_member(member) do
:ok ->
{:ok, user}
{:ok, _} ->
{:ok, user}
{:error, reason} ->
Logger.warning(
"Vereinfacht sync failed for member #{member.id} (linked to user #{user.id}): #{inspect(reason)}"
)
{:ok, user}
end
end
end
defp sync_linked_member_after_transaction(_changeset, result), do: result
defp load_linked_member(%{member_id: nil}), do: nil
defp load_linked_member(%{member_id: ""}), do: nil
defp load_linked_member(user) do
actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(actor)
case Ash.get(Member, user.member_id, [domain: Membership] ++ opts) do
{:ok, %Member{} = member} -> member
_ -> nil
end
end
end

View file

@ -0,0 +1,364 @@
defmodule Mv.Vereinfacht.Client do
@moduledoc """
HTTP client for the Vereinfacht accounting software JSON:API.
Creates and updates finance contacts. Uses Bearer token authentication and
requires club ID for multi-tenancy. Configuration via ENV or Settings
(see Mv.Config).
"""
require Logger
@content_type "application/vnd.api+json"
@doc """
Creates a finance contact in Vereinfacht for the given member.
Returns the contact ID on success. Does not update the member record;
the caller (e.g. SyncContact change) must persist `vereinfacht_contact_id`.
## Options
- None; URL, API key, and club ID are read from Mv.Config.
## Examples
iex> create_contact(member)
{:ok, "242"}
iex> create_contact(member)
{:error, {:http, 401, "Unauthenticated."}}
"""
@spec create_contact(struct()) :: {:ok, String.t()} | {:error, term()}
def create_contact(member) do
base_url = base_url()
api_key = api_key()
club_id = club_id()
if is_nil(base_url) or is_nil(api_key) or is_nil(club_id) do
{:error, :not_configured}
else
body = build_create_body(member, club_id)
url = base_url |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts")
post_and_parse_contact(url, body, api_key)
end
end
@sync_timeout_ms 5_000
# In test, skip retries so sync fails fast when no API is running (avoids log spam and long waits).
defp req_http_options do
opts = [receive_timeout: @sync_timeout_ms]
if Mix.env() == :test, do: [retry: false] ++ opts, else: opts
end
defp post_and_parse_contact(url, body, api_key) do
encoded_body = Jason.encode!(body)
case Req.post(url, [body: encoded_body, headers: headers(api_key)] ++ req_http_options()) do
{:ok, %{status: 201, body: resp_body}} ->
case get_contact_id_from_response(resp_body) do
nil -> {:error, {:invalid_response, resp_body}}
id -> {:ok, id}
end
{:ok, %{status: status, body: resp_body}} ->
{:error, {:http, status, extract_error_message(resp_body)}}
{:error, reason} ->
{:error, {:request_failed, reason}}
end
end
@doc """
Updates an existing finance contact in Vereinfacht.
Only sends attributes that are typically synced from the member (name, email,
address fields). Returns the same contact_id on success.
## Examples
iex> update_contact("242", member)
{:ok, "242"}
iex> update_contact("242", member)
{:error, {:http, 404, "Not Found"}}
"""
@spec update_contact(String.t(), struct()) :: {:ok, String.t()} | {:error, term()}
def update_contact(contact_id, member) when is_binary(contact_id) do
base_url = base_url()
api_key = api_key()
if is_nil(base_url) or is_nil(api_key) do
{:error, :not_configured}
else
body = build_update_body(contact_id, member)
encoded_body = Jason.encode!(body)
url =
base_url
|> String.trim_trailing("/")
|> then(&"#{&1}/finance-contacts/#{contact_id}")
case Req.patch(
url,
[
body: encoded_body,
headers: headers(api_key)
] ++ req_http_options()
) do
{:ok, %{status: 200, body: _resp_body}} ->
{:ok, contact_id}
{:ok, %{status: status, body: body}} ->
{:error, {:http, status, extract_error_message(body)}}
{:error, reason} ->
{:error, {:request_failed, reason}}
end
end
end
@doc """
Finds a finance contact by email (GET /finance-contacts, then match in response).
The Vereinfacht API does not allow filter by email on this endpoint, so we
fetch the first page and find the contact client-side. Returns {:ok, contact_id}
if a contact with that email exists, {:error, :not_found} if none, or
{:error, reason} on API/network failure. Used before create for idempotency.
"""
@spec find_contact_by_email(String.t()) :: {:ok, String.t()} | {:error, :not_found | term()}
def find_contact_by_email(email) when is_binary(email) do
if is_nil(base_url()) or is_nil(api_key()) or is_nil(club_id()) do
{:error, :not_configured}
else
do_find_contact_by_email(email)
end
end
@find_contact_page_size 100
@find_contact_max_pages 100
defp do_find_contact_by_email(email) do
normalized = String.trim(email) |> String.downcase()
do_find_contact_by_email_page(1, normalized)
end
defp do_find_contact_by_email_page(page, _normalized) when page > @find_contact_max_pages do
{:error, :not_found}
end
defp do_find_contact_by_email_page(page, normalized) do
base = base_url() |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts")
url = base <> "?page[size]=#{@find_contact_page_size}&page[number]=#{page}"
case Req.get(url, [headers: headers(api_key())] ++ req_http_options()) do
{:ok, %{status: 200, body: body}} when is_map(body) ->
handle_find_contact_page_response(body, page, normalized)
{:ok, %{status: status, body: body}} ->
{:error, {:http, status, extract_error_message(body)}}
{:error, reason} ->
{:error, {:request_failed, reason}}
end
end
defp handle_find_contact_page_response(body, page, normalized) do
case find_contact_id_by_email_in_list(body, normalized) do
id when is_binary(id) -> {:ok, id}
nil -> maybe_find_contact_next_page(body, page, normalized)
end
end
defp maybe_find_contact_next_page(body, page, normalized) do
data = Map.get(body, "data") || []
if length(data) < @find_contact_page_size,
do: {:error, :not_found},
else: do_find_contact_by_email_page(page + 1, normalized)
end
defp find_contact_id_by_email_in_list(%{"data" => list}, normalized) when is_list(list) do
Enum.find_value(list, fn
%{"id" => id, "attributes" => %{"email" => att_email, "isExternal" => true}}
when is_binary(att_email) ->
if att_email |> String.trim() |> String.downcase() == normalized do
normalize_contact_id(id)
else
nil
end
%{"id" => id, "attributes" => %{"email" => att_email, "isExternal" => "true"}}
when is_binary(att_email) ->
if att_email |> String.trim() |> String.downcase() == normalized do
normalize_contact_id(id)
else
nil
end
%{"id" => _id, "attributes" => _} ->
nil
_ ->
nil
end)
end
defp find_contact_id_by_email_in_list(_, _), do: nil
defp normalize_contact_id(id) when is_binary(id), do: id
defp normalize_contact_id(id) when is_integer(id), do: to_string(id)
defp normalize_contact_id(_), do: nil
@doc """
Fetches a single finance contact from Vereinfacht (GET /finance-contacts/:id).
Returns the full response body (decoded JSON) for debugging/display.
"""
@spec get_contact(String.t()) :: {:ok, map()} | {:error, term()}
def get_contact(contact_id) when is_binary(contact_id) do
fetch_contact(contact_id, [])
end
@doc """
Fetches a finance contact with receipts (GET /finance-contacts/:id?include=receipts).
Returns {:ok, receipts} where receipts is a list of maps with :id and :attributes
(and optional :type) for each receipt, or {:error, reason}.
"""
@spec get_contact_with_receipts(String.t()) :: {:ok, [map()]} | {:error, term()}
def get_contact_with_receipts(contact_id) when is_binary(contact_id) do
case fetch_contact(contact_id, include: "receipts") do
{:ok, body} -> {:ok, extract_receipts_from_response(body)}
{:error, _} = err -> err
end
end
defp fetch_contact(contact_id, query_params) do
base_url = base_url()
api_key = api_key()
if is_nil(base_url) or is_nil(api_key) do
{:error, :not_configured}
else
path =
base_url |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts/#{contact_id}")
url = build_url_with_params(path, query_params)
case Req.get(url, [headers: headers(api_key)] ++ req_http_options()) do
{:ok, %{status: 200, body: body}} when is_map(body) ->
{:ok, body}
{:ok, %{status: status, body: body}} ->
{:error, {:http, status, extract_error_message(body)}}
{:error, reason} ->
{:error, {:request_failed, reason}}
end
end
end
defp build_url_with_params(base, []), do: base
defp build_url_with_params(base, include: value) do
sep = if String.contains?(base, "?"), do: "&", else: "?"
base <> sep <> "include=" <> URI.encode(value, &URI.char_unreserved?/1)
end
# Allowlist of receipt attribute keys we expose (avoids String.to_atom on arbitrary API input / DoS).
@receipt_attr_allowlist ~w[amount bookingDate createdAt receiptType referenceNumber status updatedAt]a
defp extract_receipts_from_response(%{"included" => included}) when is_list(included) do
included
|> Enum.filter(&match?(%{"type" => "receipts"}, &1))
|> Enum.map(fn %{"id" => id, "attributes" => attrs} = r ->
Map.merge(%{id: id, type: r["type"]}, receipt_attrs_allowlist(attrs || %{}))
end)
end
defp extract_receipts_from_response(_), do: []
defp receipt_attrs_allowlist(attrs) when is_map(attrs) do
Map.new(@receipt_attr_allowlist, fn key ->
str_key = to_string(key)
{key, Map.get(attrs, str_key)}
end)
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Map.new()
end
defp base_url, do: Mv.Config.vereinfacht_api_url()
defp api_key, do: Mv.Config.vereinfacht_api_key()
defp club_id, do: Mv.Config.vereinfacht_club_id()
defp headers(api_key) do
[
{"Accept", @content_type},
{"Content-Type", @content_type},
{"Authorization", "Bearer #{api_key}"}
]
end
defp build_create_body(member, club_id) do
attributes = member_to_attributes(member)
%{
"data" => %{
"type" => "finance-contacts",
"attributes" => attributes,
"relationships" => %{
"club" => %{
"data" => %{"type" => "clubs", "id" => club_id}
}
}
}
}
end
defp build_update_body(contact_id, member) do
attributes = member_to_attributes(member)
%{
"data" => %{
"type" => "finance-contacts",
"id" => contact_id,
"attributes" => attributes
}
}
end
defp member_to_attributes(member) do
address =
[member |> Map.get(:street), member |> Map.get(:house_number)]
|> Enum.reject(&is_nil/1)
|> Enum.map_join(" ", &to_string/1)
|> then(fn s -> if s == "", do: nil, else: s end)
%{}
|> put_attr("lastName", member |> Map.get(:last_name))
|> put_attr("firstName", member |> Map.get(:first_name))
|> put_attr("email", member |> Map.get(:email))
|> put_attr("address", address)
|> put_attr("zipCode", member |> Map.get(:postal_code))
|> put_attr("city", member |> Map.get(:city))
|> Map.put("contactType", "person")
|> Map.put("isExternal", true)
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Map.new()
end
defp put_attr(acc, _key, nil), do: acc
defp put_attr(acc, key, value), do: Map.put(acc, key, to_string(value))
defp get_contact_id_from_response(%{"data" => %{"id" => id}}) when is_binary(id), do: id
defp get_contact_id_from_response(%{"data" => %{"id" => id}}) when is_integer(id),
do: to_string(id)
defp get_contact_id_from_response(_), do: nil
defp extract_error_message(%{"errors" => [%{"detail" => d} | _]}) when is_binary(d), do: d
defp extract_error_message(%{"errors" => [%{"title" => t} | _]}) when is_binary(t), do: t
defp extract_error_message(body) when is_map(body), do: inspect(body)
defp extract_error_message(other), do: inspect(other)
end

View file

@ -0,0 +1,46 @@
defmodule Mv.Vereinfacht.SyncFlash do
@moduledoc """
Short-lived store for Vereinfacht sync results so the UI can show them after save.
The SyncContact change runs in after_transaction and cannot access the LiveView
socket. This module stores a message keyed by member_id; the form LiveView
calls `take/1` after a successful save and displays the message in flash.
"""
@table :vereinfacht_sync_flash
@doc """
Stores a sync result for the given member. Overwrites any previous message.
- `:ok` - Sync succeeded (optional user message).
- `:warning` - Sync failed; message should be shown as a warning.
"""
@spec store(String.t(), :ok | :warning, String.t()) :: :ok
def store(member_id, kind, message) when is_binary(member_id) do
:ets.insert(@table, {member_id, {kind, message}})
:ok
end
@doc """
Takes and removes the stored sync message for the given member.
Returns `{kind, message}` if present, otherwise `nil`.
"""
@spec take(String.t()) :: {:ok | :warning, String.t()} | nil
def take(member_id) when is_binary(member_id) do
case :ets.take(@table, member_id) do
[{^member_id, value}] -> value
[] -> nil
end
end
@doc false
def create_table! do
# :public so any process can write (SyncContact runs in LiveView/Ash transaction process,
# not the process that created the table). :protected would restrict writes to the creating process.
if :ets.whereis(@table) == :undefined do
:ets.new(@table, [:set, :public, :named_table])
end
:ok
end
end

View file

@ -0,0 +1,165 @@
defmodule Mv.Vereinfacht do
@moduledoc """
Business logic for Vereinfacht accounting software integration.
- `sync_member/1` Sync a single member to the API (create or update contact).
Used by Member create/update (SyncContact) and by User actions that update
the linked member's email via Ecto (e.g. user email change).
- `sync_members_without_contact/0` Bulk sync of members without a contact ID.
"""
require Ash.Query
import Ash.Expr
alias Mv.Vereinfacht.Client
alias Mv.Membership.Member
alias Mv.Helpers.SystemActor
alias Mv.Helpers
@doc """
Syncs a single member to Vereinfacht (create or update finance contact).
If the member has no `vereinfacht_contact_id`, creates a contact and updates
the member with the new ID. If they already have an ID, updates the contact.
Uses system actor for any Ash update. Does nothing if Vereinfacht is not configured.
Returns:
- `:ok` Contact was updated.
- `{:ok, member}` Contact was created and member was updated with the new ID.
- `{:error, reason}` API or update failed.
"""
@spec sync_member(struct()) :: :ok | {:ok, struct()} | {:error, term()}
def sync_member(member) do
if Mv.Config.vereinfacht_configured?() do
do_sync_member(member)
else
:ok
end
end
defp do_sync_member(member) do
if present_contact_id?(member.vereinfacht_contact_id) do
sync_existing_contact(member)
else
ensure_contact_then_save(member)
end
end
defp sync_existing_contact(member) do
case Client.update_contact(member.vereinfacht_contact_id, member) do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
defp ensure_contact_then_save(member) do
case get_or_create_contact_id(member) do
{:ok, contact_id} -> save_contact_id(member, contact_id)
{:error, _} = err -> err
end
end
# Before create: find by email to avoid duplicate contacts (idempotency).
# When an existing contact is found, update it with current member data.
defp get_or_create_contact_id(member) do
email = member |> Map.get(:email) |> to_string() |> String.trim()
if email == "" do
Client.create_contact(member)
else
case Client.find_contact_by_email(email) do
{:ok, existing_id} -> update_existing_contact_and_return_id(existing_id, member)
{:error, :not_found} -> Client.create_contact(member)
{:error, _} = err -> err
end
end
end
defp update_existing_contact_and_return_id(contact_id, member) do
case Client.update_contact(contact_id, member) do
{:ok, _} -> {:ok, contact_id}
{:error, _} = err -> err
end
end
defp save_contact_id(member, contact_id) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
case Ash.update(member, %{vereinfacht_contact_id: contact_id}, [
{:action, :set_vereinfacht_contact_id} | opts
]) do
{:ok, updated} -> {:ok, updated}
{:error, reason} -> {:error, reason}
end
end
defp present_contact_id?(nil), do: false
defp present_contact_id?(""), do: false
defp present_contact_id?(s) when is_binary(s), do: String.trim(s) != ""
defp present_contact_id?(_), do: false
@doc """
Formats an API/request error reason into a short user-facing message.
Used by SyncContact (flash) and GlobalSettingsLive (sync result list).
"""
@spec format_error(term()) :: String.t()
def format_error({:http, _status, detail}) when is_binary(detail), do: "Vereinfacht: " <> detail
def format_error({:http, status, _}), do: "Vereinfacht: API error (HTTP #{status})."
def format_error({:request_failed, _}),
do: "Vereinfacht: Request failed (e.g. connection error)."
def format_error({:invalid_response, _}), do: "Vereinfacht: Invalid API response."
def format_error(other), do: "Vereinfacht: " <> inspect(other)
@doc """
Creates Vereinfacht contacts for all members that do not yet have a
`vereinfacht_contact_id`. Uses system actor for reads and updates.
Returns `{:ok, %{synced: count, errors: list}}` where errors is a list of
`{member_id, reason}`. Does nothing if Vereinfacht is not configured.
"""
@spec sync_members_without_contact() ::
{:ok, %{synced: non_neg_integer(), errors: [{String.t(), term()}]}}
| {:error, :not_configured}
def sync_members_without_contact do
if Mv.Config.vereinfacht_configured?() do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
query =
Member
|> Ash.Query.filter(
expr(is_nil(^ref(:vereinfacht_contact_id)) or ^ref(:vereinfacht_contact_id) == "")
)
case Ash.read(query, opts) do
{:ok, members} ->
do_sync_members(members, opts)
{:error, _} = err ->
err
end
else
{:error, :not_configured}
end
end
defp do_sync_members(members, opts) do
{synced, errors} =
Enum.reduce(members, {0, []}, fn member, {acc_synced, acc_errors} ->
{inc, new_errors} = sync_one_member(member, opts)
{acc_synced + inc, acc_errors ++ new_errors}
end)
{:ok, %{synced: synced, errors: errors}}
end
defp sync_one_member(member, _opts) do
case sync_member(member) do
:ok -> {1, []}
{:ok, _} -> {1, []}
{:error, reason} -> {0, [{member.id, reason}]}
end
end
end

View file

@ -448,6 +448,8 @@ defmodule MvWeb.CoreComponents do
end end
def input(%{type: "select"} = assigns) do def input(%{type: "select"} = assigns) do
assigns = ensure_aria_required_for_input(assigns)
~H""" ~H"""
<fieldset class="mb-2 fieldset"> <fieldset class="mb-2 fieldset">
<label> <label>
@ -475,6 +477,8 @@ defmodule MvWeb.CoreComponents do
end end
def input(%{type: "textarea"} = assigns) do def input(%{type: "textarea"} = assigns) do
assigns = ensure_aria_required_for_input(assigns)
~H""" ~H"""
<fieldset class="mb-2 fieldset"> <fieldset class="mb-2 fieldset">
<label> <label>
@ -502,6 +506,8 @@ defmodule MvWeb.CoreComponents do
# All other inputs text, datetime-local, url, password, etc. are handled here... # All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do def input(assigns) do
assigns = ensure_aria_required_for_input(assigns)
~H""" ~H"""
<fieldset class="mb-2 fieldset"> <fieldset class="mb-2 fieldset">
<label> <label>
@ -529,6 +535,18 @@ defmodule MvWeb.CoreComponents do
""" """
end end
# WCAG 2.1: set aria-required when required is true so screen readers announce required state
defp ensure_aria_required_for_input(assigns) do
rest = assigns.rest || %{}
rest =
if rest[:required],
do: Map.put(rest, :aria_required, "true"),
else: rest
assign(assigns, :rest, rest)
end
# Helper used by inputs to generate form errors # Helper used by inputs to generate form errors
defp error(assigns) do defp error(assigns) do
~H""" ~H"""

View file

@ -18,7 +18,8 @@ defmodule MvWeb.MemberExportController do
alias MvWeb.MemberLive.Index.MembershipFeeStatus alias MvWeb.MemberLive.Index.MembershipFeeStatus
use Gettext, backend: MvWeb.Gettext use Gettext, backend: MvWeb.Gettext
@member_fields_allowlist Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) @member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
["groups"]
@computed_export_fields ["membership_fee_status"] @computed_export_fields ["membership_fee_status"]
@custom_field_prefix Mv.Constants.custom_field_prefix() @custom_field_prefix Mv.Constants.custom_field_prefix()
@ -83,6 +84,7 @@ defmodule MvWeb.MemberExportController do
domain_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) domain_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
selectable = Enum.filter(member_fields, fn f -> f in domain_fields end) selectable = Enum.filter(member_fields, fn f -> f in domain_fields end)
computed = Enum.filter(member_fields, fn f -> f in @computed_export_fields end) computed = Enum.filter(member_fields, fn f -> f in @computed_export_fields end)
# "groups" is neither a domain field nor a computed field, it's handled separately
{selectable, computed} {selectable, computed}
end end
@ -235,12 +237,15 @@ defmodule MvWeb.MemberExportController do
need_cycles = need_cycles =
parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields
need_groups = "groups" in parsed.member_fields
query = query =
Member Member
|> Ash.Query.new() |> Ash.Query.new()
|> Ash.Query.select(select_fields) |> Ash.Query.select(select_fields)
|> load_custom_field_values_query(parsed.custom_field_ids) |> load_custom_field_values_query(parsed.custom_field_ids)
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle) |> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|> maybe_load_groups(need_groups)
query = query =
if parsed.selected_ids != [] do if parsed.selected_ids != [] do
@ -284,6 +289,13 @@ defmodule MvWeb.MemberExportController do
MembershipFeeStatus.load_cycles_for_members(query, show_current) MembershipFeeStatus.load_cycles_for_members(query, show_current)
end end
defp maybe_load_groups(query, false), do: query
defp maybe_load_groups(query, true) do
# Load groups with id and name only (for export formatting)
Ash.Query.load(query, groups: [:id, :name])
end
# Adds computed field values to members (e.g. membership_fee_status) # Adds computed field values to members (e.g. membership_fee_status)
defp add_computed_fields(members, computed_fields, show_current_cycle) do defp add_computed_fields(members, computed_fields, show_current_cycle) do
if "membership_fee_status" in computed_fields do if "membership_fee_status" in computed_fields do
@ -329,17 +341,23 @@ defmodule MvWeb.MemberExportController do
defp maybe_sort_export(query, _field, nil), do: {query, false} defp maybe_sort_export(query, _field, nil), do: {query, false}
defp maybe_sort_export(query, field, order) when is_binary(field) do defp maybe_sort_export(query, field, order) when is_binary(field) do
if custom_field_sort?(field) do cond do
# Custom field sort → in-memory nach dem Read (wie Tabelle) field == "groups" ->
{query, true} # Groups sort → in-memory nach dem Read (wie Tabelle)
else {query, true}
field_atom = String.to_existing_atom(field)
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do custom_field_sort?(field) ->
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false} # Custom field sort → in-memory nach dem Read (wie Tabelle)
else {query, true}
{query, false}
end true ->
field_atom = String.to_existing_atom(field)
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
else
{query, false}
end
end end
rescue rescue
ArgumentError -> {query, false} ArgumentError -> {query, false}
@ -358,6 +376,15 @@ defmodule MvWeb.MemberExportController do
defp sort_members_by_custom_field_export(members, field, order, custom_fields) defp sort_members_by_custom_field_export(members, field, order, custom_fields)
when is_binary(field) do when is_binary(field) do
order = order || "asc" order = order || "asc"
if field == "groups" do
sort_members_by_groups_export(members, order)
else
sort_by_custom_field_value(members, field, order, custom_fields)
end
end
defp sort_by_custom_field_value(members, field, order, custom_fields) do
id_str = String.trim_leading(field, @custom_field_prefix) id_str = String.trim_leading(field, @custom_field_prefix)
custom_field = custom_field =
@ -387,6 +414,26 @@ defmodule MvWeb.MemberExportController do
end end
end end
defp sort_members_by_groups_export(members, order) do
# Members with groups first, then by first group name alphabetically (min = first by sort order)
# Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2
first_group_name = fn member ->
(member.groups || [])
|> Enum.map(& &1.name)
|> Enum.min(fn -> nil end)
end
members
|> Enum.sort_by(fn member ->
name = first_group_name.(member)
# Nil (no groups) sorts last in asc, first in desc
{name == nil, name || ""}
end)
|> then(fn list ->
if order == "desc", do: Enum.reverse(list), else: list
end)
end
defp has_non_empty_custom_field_value?(member, custom_field) do defp has_non_empty_custom_field_value?(member, custom_field) do
case find_cfv(member, custom_field) do case find_cfv(member, custom_field) do
nil -> nil ->
@ -441,6 +488,19 @@ defmodule MvWeb.MemberExportController do
} }
end) end)
groups_col =
if "groups" in parsed.member_fields do
[
%{
header: groups_field_header(conn),
kind: :groups,
key: :groups
}
]
else
[]
end
custom_cols = custom_cols =
parsed.custom_field_ids parsed.custom_field_ids
|> Enum.map(fn id -> |> Enum.map(fn id ->
@ -459,7 +519,7 @@ defmodule MvWeb.MemberExportController do
end) end)
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
member_cols ++ computed_cols ++ custom_cols member_cols ++ computed_cols ++ groups_col ++ custom_cols
end end
# --- headers: use MemberFields.label for translations --- # --- headers: use MemberFields.label for translations ---
@ -499,6 +559,10 @@ defmodule MvWeb.MemberExportController do
cf.name cf.name
end end
defp groups_field_header(_conn) do
MemberFields.label(:groups)
end
defp humanize_field(str) do defp humanize_field(str) do
str str
|> String.replace("_", " ") |> String.replace("_", " ")

View file

@ -23,6 +23,9 @@ defmodule MvWeb.GlobalSettingsLive do
""" """
use MvWeb, :live_view use MvWeb, :live_view
require Ash.Query
import Ash.Expr
alias Mv.Membership alias Mv.Membership
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded} on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@ -41,11 +44,23 @@ defmodule MvWeb.GlobalSettingsLive do
|> assign(:settings, settings) |> assign(:settings, settings)
|> assign(:active_editing_section, nil) |> assign(:active_editing_section, nil)
|> assign(:locale, locale) |> assign(:locale, locale)
|> assign(:vereinfacht_env_configured, Mv.Config.vereinfacht_env_configured?())
|> assign(:vereinfacht_api_url_env_set, Mv.Config.vereinfacht_api_url_env_set?())
|> assign(:vereinfacht_api_key_env_set, Mv.Config.vereinfacht_api_key_env_set?())
|> assign(:vereinfacht_club_id_env_set, Mv.Config.vereinfacht_club_id_env_set?())
|> assign(:vereinfacht_app_url_env_set, Mv.Config.vereinfacht_app_url_env_set?())
|> assign(:vereinfacht_api_key_set, present?(settings.vereinfacht_api_key))
|> assign(:last_vereinfacht_sync_result, nil)
|> assign_form() |> assign_form()
{:ok, socket} {:ok, socket}
end end
defp present?(nil), do: false
defp present?(""), do: false
defp present?(s) when is_binary(s), do: String.trim(s) != ""
defp present?(_), do: false
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -74,6 +89,98 @@ defmodule MvWeb.GlobalSettingsLive do
</.button> </.button>
</.form> </.form>
</.form_section> </.form_section>
<%!-- Vereinfacht Integration Section --%>
<.form_section title={gettext("Vereinfacht Integration")}>
<%= if @vereinfacht_env_configured do %>
<p class="text-sm text-base-content/70 mb-4">
{gettext("Some values are set via environment variables. Those fields are read-only.")}
</p>
<% end %>
<.form for={@form} id="vereinfacht-form" phx-change="validate" phx-submit="save">
<div class="grid gap-4">
<.input
field={@form[:vereinfacht_api_url]}
type="text"
label={gettext("API URL")}
disabled={@vereinfacht_api_url_env_set}
placeholder={
if(@vereinfacht_api_url_env_set,
do: gettext("From VEREINFACHT_API_URL"),
else: "https://api.verein.visuel.dev/api/v1"
)
}
/>
<div class="form-control">
<label class="label" for={@form[:vereinfacht_api_key].id}>
<span class="label-text">{gettext("API Key")}</span>
<%= if @vereinfacht_api_key_set do %>
<span class="label-text-alt badge badge-ghost">{gettext("(set)")}</span>
<% end %>
</label>
<.input
field={@form[:vereinfacht_api_key]}
type="password"
label=""
disabled={@vereinfacht_api_key_env_set}
placeholder={
if(@vereinfacht_api_key_env_set,
do: gettext("From VEREINFACHT_API_KEY"),
else:
if(@vereinfacht_api_key_set,
do: gettext("Leave blank to keep current"),
else: nil
)
)
}
/>
</div>
<.input
field={@form[:vereinfacht_club_id]}
type="text"
label={gettext("Club ID")}
disabled={@vereinfacht_club_id_env_set}
placeholder={
if(@vereinfacht_club_id_env_set, do: gettext("From VEREINFACHT_CLUB_ID"), else: "2")
}
/>
<.input
field={@form[:vereinfacht_app_url]}
type="text"
label={gettext("App URL (contact view link)")}
disabled={@vereinfacht_app_url_env_set}
placeholder={
if(@vereinfacht_app_url_env_set,
do: gettext("From VEREINFACHT_APP_URL"),
else: "https://app.verein.visuel.dev"
)
}
/>
</div>
<.button
:if={
not (@vereinfacht_api_url_env_set and @vereinfacht_api_key_env_set and
@vereinfacht_club_id_env_set)
}
phx-disable-with={gettext("Saving...")}
variant="primary"
class="mt-2"
>
{gettext("Save Vereinfacht Settings")}
</.button>
<.button
:if={Mv.Config.vereinfacht_configured?()}
type="button"
phx-click="sync_vereinfacht_contacts"
phx-disable-with={gettext("Syncing...")}
class="mt-4 btn-outline"
>
{gettext("Sync all members without Vereinfacht contact")}
</.button>
<%= if @last_vereinfacht_sync_result do %>
<.vereinfacht_sync_result result={@last_vereinfacht_sync_result} />
<% end %>
</.form>
</.form_section>
<%!-- Memberdata Section --%> <%!-- Memberdata Section --%>
<.form_section title={gettext("Memberdata")}> <.form_section title={gettext("Memberdata")}>
<.live_component <.live_component
@ -100,18 +207,54 @@ defmodule MvWeb.GlobalSettingsLive do
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))} assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
end end
@impl true
def handle_event("sync_vereinfacht_contacts", _params, socket) do
case Mv.Vereinfacht.sync_members_without_contact() do
{:ok, %{synced: synced, errors: errors}} ->
errors_with_names = enrich_sync_errors(errors)
result = %{synced: synced, errors: errors_with_names}
socket =
socket
|> assign(:last_vereinfacht_sync_result, result)
|> put_flash(
:info,
if(errors_with_names == [],
do: gettext("Synced %{count} member(s) to Vereinfacht.", count: synced),
else:
gettext("Synced %{count} member(s). %{error_count} failed.",
count: synced,
error_count: length(errors_with_names)
)
)
)
{:noreply, socket}
{:error, :not_configured} ->
{:noreply,
put_flash(
socket,
:error,
gettext("Vereinfacht is not configured. Set API URL, API Key, and Club ID.")
)}
end
end
@impl true @impl true
def handle_event("save", %{"setting" => setting_params}, socket) do def handle_event("save", %{"setting" => setting_params}, socket) do
actor = MvWeb.LiveHelpers.current_actor(socket) actor = MvWeb.LiveHelpers.current_actor(socket)
# Never send blank API key so we do not overwrite the stored secret (security)
setting_params_clean = drop_blank_vereinfacht_api_key(setting_params)
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params, actor) do case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params_clean, actor) do
{:ok, _updated_settings} -> {:ok, _updated_settings} ->
# Reload settings from database to ensure all dependent data is updated
{:ok, fresh_settings} = Membership.get_settings() {:ok, fresh_settings} = Membership.get_settings()
socket = socket =
socket socket
|> assign(:settings, fresh_settings) |> assign(:settings, fresh_settings)
|> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key))
|> put_flash(:info, gettext("Settings updated successfully")) |> put_flash(:info, gettext("Settings updated successfully"))
|> assign_form() |> assign_form()
@ -122,6 +265,16 @@ defmodule MvWeb.GlobalSettingsLive do
end end
end end
defp drop_blank_vereinfacht_api_key(params) when is_map(params) do
case params do
%{"vereinfacht_api_key" => v} when v in [nil, ""] ->
Map.delete(params, "vereinfacht_api_key")
_ ->
params
end
end
@impl true @impl true
def handle_info({:custom_field_saved, _custom_field, action}, socket) do def handle_info({:custom_field_saved, _custom_field, action}, socket) do
send_update(MvWeb.CustomFieldLive.IndexComponent, send_update(MvWeb.CustomFieldLive.IndexComponent,
@ -202,9 +355,12 @@ defmodule MvWeb.GlobalSettingsLive do
end end
defp assign_form(%{assigns: %{settings: settings}} = socket) do defp assign_form(%{assigns: %{settings: settings}} = socket) do
# Never put API key into form/DOM to avoid secret leak in source or DevTools
settings_for_form = %{settings | vereinfacht_api_key: nil}
form = form =
AshPhoenix.Form.for_update( AshPhoenix.Form.for_update(
settings, settings_for_form,
:update, :update,
api: Membership, api: Membership,
as: "setting", as: "setting",
@ -213,4 +369,74 @@ defmodule MvWeb.GlobalSettingsLive do
assign(socket, form: to_form(form)) assign(socket, form: to_form(form))
end end
defp enrich_sync_errors([]), do: []
defp enrich_sync_errors(errors) when is_list(errors) do
name_by_id = fetch_member_names_by_ids(Enum.map(errors, fn {id, _} -> id end))
Enum.map(errors, fn {member_id, reason} ->
%{
member_id: member_id,
member_name: Map.get(name_by_id, member_id) || to_string(member_id),
message: Mv.Vereinfacht.format_error(reason),
detail: extract_vereinfacht_detail(reason)
}
end)
end
defp fetch_member_names_by_ids(ids) do
actor = Mv.Helpers.SystemActor.get_system_actor()
opts = Mv.Helpers.ash_actor_opts(actor)
query = Ash.Query.filter(Mv.Membership.Member, expr(id in ^ids))
case Ash.read(query, opts) do
{:ok, members} ->
Map.new(members, fn m -> {m.id, MvWeb.Helpers.MemberHelpers.display_name(m)} end)
_ ->
%{}
end
end
defp extract_vereinfacht_detail({:http, _status, detail}) when is_binary(detail), do: detail
defp extract_vereinfacht_detail(_), do: nil
defp translate_vereinfacht_message(%{detail: detail}) when is_binary(detail) do
gettext("Vereinfacht: %{detail}",
detail: Gettext.dgettext(MvWeb.Gettext, "default", detail)
)
end
defp translate_vereinfacht_message(%{message: message}) do
Gettext.dgettext(MvWeb.Gettext, "default", message)
end
attr :result, :map, required: true
defp vereinfacht_sync_result(assigns) do
~H"""
<div class="mt-4 p-4 rounded-lg border border-base-300 bg-base-200 space-y-2">
<p class="font-medium">
{gettext("Last sync result:")}
<span class="text-success-aa ml-1">{gettext("%{count} synced", count: @result.synced)}</span>
<%= if @result.errors != [] do %>
<span class="text-error-aa ml-1">
{gettext("%{count} failed", count: length(@result.errors))}
</span>
<% end %>
</p>
<%= if @result.errors != [] do %>
<p class="text-sm text-base-content/70 mt-2">{gettext("Failed members:")}</p>
<ul class="list-disc list-inside text-sm space-y-1 max-h-48 overflow-y-auto">
<%= for err <- @result.errors do %>
<li>
<span class="font-medium">{err.member_name}</span>: {translate_vereinfacht_message(err)}
</li>
<% end %>
</ul>
<% end %>
</div>
"""
end
end end

View file

@ -17,10 +17,12 @@ defmodule MvWeb.GroupLive.Show do
require Logger require Logger
import Ash.Expr
import MvWeb.LiveHelpers, only: [current_actor: 1] import MvWeb.LiveHelpers, only: [current_actor: 1]
import MvWeb.Authorization import MvWeb.Authorization
alias Mv.Membership alias Mv.Membership
alias MvWeb.Helpers.MemberHelpers, as: MemberHelpers
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
@ -29,6 +31,7 @@ defmodule MvWeb.GroupLive.Show do
|> assign(:show_add_member_input, false) |> assign(:show_add_member_input, false)
|> assign(:member_search_query, "") |> assign(:member_search_query, "")
|> assign(:available_members, []) |> assign(:available_members, [])
|> assign(:add_member_candidates, [])
|> assign(:selected_member_ids, []) |> assign(:selected_member_ids, [])
|> assign(:selected_members, []) |> assign(:selected_members, [])
|> assign(:show_member_dropdown, false) |> assign(:show_member_dropdown, false)
@ -94,13 +97,21 @@ defmodule MvWeb.GroupLive.Show do
</h1> </h1>
<div class="flex gap-2"> <div class="flex gap-2">
<%= if can?(@current_user, :update, Mv.Membership.Group) do %> <%= if can?(@current_user, :update, @group) do %>
<.button variant="primary" navigate={~p"/groups/#{@group.slug}/edit"}> <.button
variant="primary"
navigate={~p"/groups/#{@group.slug}/edit"}
data-testid="group-show-edit-btn"
>
{gettext("Edit")} {gettext("Edit")}
</.button> </.button>
<% end %> <% end %>
<%= if can?(@current_user, :destroy, Mv.Membership.Group) do %> <%= if can?(@current_user, :destroy, @group) do %>
<.button class="btn-error" phx-click="open_delete_modal"> <.button
class="btn-error"
phx-click="open_delete_modal"
data-testid="group-show-delete-btn"
>
{gettext("Delete")} {gettext("Delete")}
</.button> </.button>
<% end %> <% end %>
@ -123,7 +134,7 @@ defmodule MvWeb.GroupLive.Show do
<div> <div>
<h2 class="text-lg font-semibold mb-2">{gettext("Members")}</h2> <h2 class="text-lg font-semibold mb-2">{gettext("Members")}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100"> <div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="mb-4"> <p class="mb-4" data-testid="group-show-member-count">
{ngettext( {ngettext(
"Total: %{count} member", "Total: %{count} member",
"Total: %{count} members", "Total: %{count} members",
@ -132,7 +143,7 @@ defmodule MvWeb.GroupLive.Show do
)} )}
</p> </p>
<%= if can?(@current_user, :update, Mv.Membership.Group) do %> <%= if can?(@current_user, :update, @group) do %>
<div class="mb-4"> <div class="mb-4">
<%= if assigns[:show_add_member_input] do %> <%= if assigns[:show_add_member_input] do %>
<div class="join w-full"> <div class="join w-full">
@ -160,6 +171,7 @@ defmodule MvWeb.GroupLive.Show do
<input <input
type="text" type="text"
id="member-search-input" id="member-search-input"
data-testid="group-show-member-search-input"
role="combobox" role="combobox"
phx-hook="ComboBox" phx-hook="ComboBox"
phx-focus="show_member_dropdown" phx-focus="show_member_dropdown"
@ -228,6 +240,7 @@ defmodule MvWeb.GroupLive.Show do
type="button" type="button"
class="btn btn-primary join-item" class="btn btn-primary join-item"
phx-click="add_selected_members" phx-click="add_selected_members"
data-testid="group-show-add-selected-members-btn"
disabled={Enum.empty?(@selected_member_ids)} disabled={Enum.empty?(@selected_member_ids)}
aria-label={gettext("Add members")} aria-label={gettext("Add members")}
> >
@ -255,15 +268,17 @@ defmodule MvWeb.GroupLive.Show do
<% end %> <% end %>
<%= if Enum.empty?(@group.members || []) do %> <%= if Enum.empty?(@group.members || []) do %>
<p class="text-base-content/50 italic">{gettext("No members in this group")}</p> <p class="text-base-content/50 italic" data-testid="group-show-no-members">
{gettext("No members in this group")}
</p>
<% else %> <% else %>
<div class="overflow-x-auto"> <div class="overflow-x-auto" data-testid="group-show-members-table">
<table class="table table-zebra"> <table class="table table-zebra">
<thead> <thead>
<tr> <tr>
<th>{gettext("Name")}</th> <th>{gettext("Name")}</th>
<th>{gettext("Email")}</th> <th>{gettext("Email")}</th>
<%= if can?(@current_user, :update, Mv.Membership.Group) do %> <%= if can?(@current_user, :update, @group) do %>
<th class="w-0">{gettext("Actions")}</th> <th class="w-0">{gettext("Actions")}</th>
<% end %> <% end %>
</tr> </tr>
@ -291,13 +306,14 @@ defmodule MvWeb.GroupLive.Show do
<span class="text-base-content/50 italic"></span> <span class="text-base-content/50 italic"></span>
<% end %> <% end %>
</td> </td>
<%= if can?(@current_user, :update, Mv.Membership.Group) do %> <%= if can?(@current_user, :update, @group) do %>
<td> <td>
<button <button
type="button" type="button"
class="btn btn-ghost btn-sm text-error" class="btn btn-ghost btn-sm text-error"
phx-click="remove_member" phx-click="remove_member"
phx-value-member_id={member.id} phx-value-member_id={member.id}
data-testid="group-show-remove-member"
aria-label={gettext("Remove member from group")} aria-label={gettext("Remove member from group")}
data-tooltip={gettext("Remove")} data-tooltip={gettext("Remove")}
> >
@ -431,28 +447,31 @@ defmodule MvWeb.GroupLive.Show do
# Add Member Events # Add Member Events
@impl true @impl true
def handle_event("show_add_member_input", _params, socket) do def handle_event("show_add_member_input", _params, socket) do
# Reload group to ensure we have the latest members list # Load candidate members once (single DB read). Search/focus then filter in memory (R2).
actor = current_actor(socket) socket =
group = socket.assigns.group socket
socket = reload_group(socket, group.slug, actor) |> assign(:show_add_member_input, true)
|> assign(:member_search_query, "")
|> assign(:selected_member_ids, [])
|> assign(:selected_members, [])
|> assign(:show_member_dropdown, false)
|> assign(:focused_member_index, nil)
|> load_add_member_candidates()
{:noreply, {:noreply, socket}
socket
|> assign(:show_add_member_input, true)
|> assign(:member_search_query, "")
|> assign(:available_members, [])
|> assign(:selected_member_ids, [])
|> assign(:selected_members, [])
|> assign(:show_member_dropdown, false)
|> assign(:focused_member_index, nil)}
end end
@impl true @impl true
def handle_event("show_member_dropdown", _params, socket) do def handle_event("show_member_dropdown", _params, socket) do
# Use existing group.members for filtering; reload only on add/remove # Filter in memory from preloaded candidates; no DB read (R2).
query = socket.assigns.member_search_query || ""
socket = socket =
socket socket
|> load_available_members("") |> assign(
:available_members,
filter_candidates_in_memory(socket.assigns.add_member_candidates, query)
)
|> assign(:show_member_dropdown, true) |> assign(:show_member_dropdown, true)
|> assign(:focused_member_index, nil) |> assign(:focused_member_index, nil)
@ -466,6 +485,7 @@ defmodule MvWeb.GroupLive.Show do
|> assign(:show_add_member_input, false) |> assign(:show_add_member_input, false)
|> assign(:member_search_query, "") |> assign(:member_search_query, "")
|> assign(:available_members, []) |> assign(:available_members, [])
|> assign(:add_member_candidates, [])
|> assign(:selected_member_ids, []) |> assign(:selected_member_ids, [])
|> assign(:selected_members, []) |> assign(:selected_members, [])
|> assign(:show_member_dropdown, false) |> assign(:show_member_dropdown, false)
@ -532,11 +552,13 @@ defmodule MvWeb.GroupLive.Show do
@impl true @impl true
def handle_event("search_members", %{"member_search" => query}, socket) do def handle_event("search_members", %{"member_search" => query}, socket) do
# Use existing group.members for filtering; reload only on add/remove # Filter in memory from preloaded candidates; no DB read (R2).
candidates = socket.assigns.add_member_candidates || []
socket = socket =
socket socket
|> assign(:member_search_query, query) |> assign(:member_search_query, query)
|> load_available_members(query) |> assign(:available_members, filter_candidates_in_memory(candidates, query))
|> assign(:show_member_dropdown, true) |> assign(:show_member_dropdown, true)
|> assign(:focused_member_index, nil) |> assign(:focused_member_index, nil)
@ -660,47 +682,69 @@ defmodule MvWeb.GroupLive.Show do
end end
end end
defp load_available_members(socket, query) do # Load candidate members once when opening add-member UI (single DB read).
defp load_add_member_candidates(socket) do
require Ash.Query require Ash.Query
current_member_ids = group_member_ids_set(socket.assigns.group) group = socket.assigns.group
base_query = available_members_base_query(query) exclude_ids = group_member_ids_set(group) |> MapSet.to_list()
# Fetch more than 10, then exclude already-in-group and take 10 (avoids empty dropdown when first N are all in group)
fetch_limit = 50
limited_query = Ash.Query.limit(base_query, fetch_limit)
actor = current_actor(socket) actor = current_actor(socket)
case Ash.read(limited_query, actor: actor, domain: Mv.Membership) do if exclude_ids == [] do
{:ok, members} -> # No members in group; load first N members
available = query =
members Mv.Membership.Member
|> Enum.reject(fn m -> MapSet.member?(current_member_ids, m.id) end) |> Ash.Query.sort([:last_name, :first_name])
|> Enum.take(10) |> Ash.Query.limit(300)
assign(socket, available_members: available) do_load_add_member_candidates(socket, query, actor)
else
query =
Mv.Membership.Member
|> Ash.Query.filter(expr(id not in ^exclude_ids))
|> Ash.Query.sort([:last_name, :first_name])
|> Ash.Query.limit(300)
do_load_add_member_candidates(socket, query, actor)
end
end
defp do_load_add_member_candidates(socket, query, actor) do
case Ash.read(query, actor: actor, domain: Mv.Membership) do
{:ok, candidates} ->
socket
|> assign(:add_member_candidates, candidates)
|> assign(:available_members, Enum.take(candidates, 10))
{:error, error} -> {:error, error} ->
Logger.warning("Failed to load available members for group: #{inspect(error)}") Logger.warning("Failed to load add-member candidates: #{inspect(error)}")
socket socket
|> put_flash(:error, gettext("Could not load member search. Please try again.")) |> put_flash(:error, gettext("Could not load member list. Please try again."))
|> assign(:add_member_candidates, [])
|> assign(:available_members, []) |> assign(:available_members, [])
end end
end end
defp available_members_base_query(query) do # Filter preloaded candidates by query string (name/email). No DB read. R2.
search_query = if query && String.trim(query) != "", do: String.trim(query), else: nil defp filter_candidates_in_memory(candidates, query) when is_list(candidates) do
q = if is_binary(query), do: String.trim(query) |> String.downcase(), else: ""
if search_query do if q == "" do
Mv.Membership.Member candidates |> Enum.take(10)
|> Ash.Query.for_read(:search, %{query: search_query})
else else
Mv.Membership.Member candidates
|> Ash.Query.new() |> Enum.filter(fn m ->
name = MemberHelpers.display_name(m) |> String.downcase()
email = (m.email || "") |> String.downcase()
String.contains?(name, q) or String.contains?(email, q)
end)
|> Enum.take(10)
end end
end end
defp filter_candidates_in_memory(_, _), do: []
defp group_member_ids_set(group) do defp group_member_ids_set(group) do
members = group.members || [] members = group.members || []
members |> Enum.map(& &1.id) |> MapSet.new() members |> Enum.map(& &1.id) |> MapSet.new()
@ -740,6 +784,7 @@ defmodule MvWeb.GroupLive.Show do
|> assign(:show_add_member_input, false) |> assign(:show_add_member_input, false)
|> assign(:member_search_query, "") |> assign(:member_search_query, "")
|> assign(:available_members, []) |> assign(:available_members, [])
|> assign(:add_member_candidates, [])
|> assign(:selected_member_ids, []) |> assign(:selected_member_ids, [])
|> assign(:selected_members, []) |> assign(:selected_members, [])
|> assign(:show_member_dropdown, false) |> assign(:show_member_dropdown, false)

View file

@ -16,8 +16,8 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
- `on_cancel` - Callback function to call when form is cancelled - `on_cancel` - Callback function to call when form is cancelled
## Note ## Note
Member fields are technical fields that cannot be changed (name, value_type, description, required). Member fields are technical fields that cannot be changed (name, value_type).
Only the visibility (show_in_overview) can be modified. Visibility (show_in_overview) and required flag are stored in Settings and can be modified.
""" """
use MvWeb, :live_component use MvWeb, :live_component
@ -27,14 +27,13 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
alias MvWeb.Helpers.FieldTypeFormatter alias MvWeb.Helpers.FieldTypeFormatter
alias MvWeb.Translations.MemberFields alias MvWeb.Translations.MemberFields
@required_fields [:first_name, :last_name, :email]
@impl true @impl true
def render(assigns) do def render(assigns) do
assigns = assigns =
assigns assigns
|> assign(:field_attributes, get_field_attributes(assigns.member_field)) |> assign(:field_attributes, get_field_attributes(assigns.member_field))
|> assign(:is_email_field?, assigns.member_field == :email) |> assign(:is_email_field?, assigns.member_field == :email)
|> assign(:vereinfacht_required_field?, vereinfacht_required_field?(assigns))
|> assign(:field_label, MemberFields.label(assigns.member_field)) |> assign(:field_label, MemberFields.label(assigns.member_field))
~H""" ~H"""
@ -117,89 +116,64 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
</fieldset> </fieldset>
</div> </div>
<div <%!-- Line break before Required / Show in overview block --%>
:if={@is_email_field?} <div class="mt-4">
class="tooltip tooltip-right" <%!-- Required: disabled for email (always required) or Vereinfacht-required fields when integration is active --%>
data-tip={gettext("This is a technical field and cannot be changed")} <div
aria-label={gettext("This is a technical field and cannot be changed")} :if={@is_email_field? or @vereinfacht_required_field?}
> class="tooltip tooltip-right"
<fieldset class="mb-2 fieldset"> data-tip={
<label> if(@is_email_field?,
<span class="mb-1 label flex items-center gap-2"> do: gettext("This is a technical field and cannot be changed"),
{gettext("Description")} else: gettext("Required for Vereinfacht integration and cannot be disabled.")
<.icon )
name="hero-information-circle" }
class="w-4 h-4 text-base-content/60 cursor-help" aria-label={
aria-hidden="true" if(@is_email_field?,
/> do: gettext("This is a technical field and cannot be changed"),
</span> else: gettext("Required for Vereinfacht integration and cannot be disabled.")
<input )
type="text" }
name={@form[:description].name} >
id={@form[:description].id} <fieldset class="mb-2 fieldset">
value={@form[:description].value} <label>
disabled <input type="hidden" name={@form[:required].name} value="true" />
readonly <span class="label flex items-center gap-2">
class="w-full input" <input
/> type="checkbox"
</label> name={@form[:required].name}
</fieldset> id={@form[:required].id}
</div> value="true"
<.input checked={@form[:required].value}
:if={not @is_email_field?} disabled
field={@form[:description]} readonly
type="text" class="checkbox checkbox-sm"
label={gettext("Description")}
disabled={@is_email_field?}
readonly={@is_email_field?}
/>
<div
:if={@is_email_field?}
class="tooltip tooltip-right"
data-tip={gettext("This is a technical field and cannot be changed")}
aria-label={gettext("This is a technical field and cannot be changed")}
>
<fieldset class="mb-2 fieldset">
<label>
<input type="hidden" name={@form[:required].name} value="false" disabled />
<span class="label flex items-center gap-2">
<input
type="checkbox"
name={@form[:required].name}
id={@form[:required].id}
value="true"
checked={@form[:required].value}
disabled
readonly
class="checkbox checkbox-sm"
/>
<span class="flex items-center gap-2">
{gettext("Required")}
<.icon
name="hero-information-circle"
class="w-4 h-4 text-base-content/60 cursor-help"
aria-hidden="true"
/> />
<span class="flex items-center gap-2">
{gettext("Required")}
<.icon
name="hero-information-circle"
class="w-4 h-4 text-base-content/60 cursor-help"
aria-hidden="true"
/>
</span>
</span> </span>
</span> </label>
</label> </fieldset>
</fieldset> </div>
</div> <.input
<.input :if={not @is_email_field? and not @vereinfacht_required_field?}
:if={not @is_email_field?} field={@form[:required]}
field={@form[:required]} type="checkbox"
type="checkbox" label={gettext("Required")}
label={gettext("Required")} />
disabled={@is_email_field?}
readonly={@is_email_field?}
/>
<.input <.input
field={@form[:show_in_overview]} field={@form[:show_in_overview]}
type="checkbox" type="checkbox"
label={gettext("Show in overview")} label={gettext("Show in overview")}
/> />
</div>
<div class="justify-end mt-4 card-actions"> <div class="justify-end mt-4 card-actions">
<.button type="button" phx-click="cancel" phx-target={@myself}> <.button type="button" phx-click="cancel" phx-target={@myself}>
@ -225,24 +199,35 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
@impl true @impl true
def handle_event("validate", %{"member_field" => member_field_params}, socket) do def handle_event("validate", %{"member_field" => member_field_params}, socket) do
# For member fields, we only validate show_in_overview
# Other fields are read-only or derived from the Member Resource
form = socket.assigns.form form = socket.assigns.form
# Unchecked checkboxes are not in params; preserve current form value when key is missing
updated_params = show_in_overview =
member_field_params if Map.has_key?(member_field_params, "show_in_overview") do
|> Map.put(
"show_in_overview",
TypeParsers.parse_boolean(member_field_params["show_in_overview"]) TypeParsers.parse_boolean(member_field_params["show_in_overview"])
) else
|> Map.put("name", form.source["name"]) form.source["show_in_overview"]
|> Map.put("value_type", form.source["value_type"]) end
|> Map.put("description", form.source["description"])
|> Map.put("required", form.source["required"]) required =
socket.assigns.vereinfacht_required_field? ||
if Map.has_key?(member_field_params, "required") do
TypeParsers.parse_boolean(member_field_params["required"])
else
form.source["required"]
end
# Merge so we keep name/value_type and have current checkbox state; use as new form source
merged_source =
form.source
|> Map.merge(%{
"show_in_overview" => show_in_overview,
"required" => required,
"name" => form.source["name"],
"value_type" => form.source["value_type"]
})
updated_form = updated_form =
form to_form(merged_source, as: "member_field")
|> Map.put(:value, updated_params)
|> Map.put(:errors, []) |> Map.put(:errors, [])
{:noreply, assign(socket, form: updated_form)} {:noreply, assign(socket, form: updated_form)}
@ -250,23 +235,36 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
@impl true @impl true
def handle_event("save", %{"member_field" => member_field_params}, socket) do def handle_event("save", %{"member_field" => member_field_params}, socket) do
# Only show_in_overview can be changed for member fields form = socket.assigns.form
show_in_overview = TypeParsers.parse_boolean(member_field_params["show_in_overview"]) # Unchecked checkboxes are not in submit params; use form source when key missing
show_in_overview =
if Map.has_key?(member_field_params, "show_in_overview") do
TypeParsers.parse_boolean(member_field_params["show_in_overview"])
else
form.source["show_in_overview"]
end
required =
socket.assigns.vereinfacht_required_field? ||
if Map.has_key?(member_field_params, "required") do
TypeParsers.parse_boolean(member_field_params["required"])
else
form.source["required"]
end
field_string = Atom.to_string(socket.assigns.member_field) field_string = Atom.to_string(socket.assigns.member_field)
# Use atomic action to update only this single field case Membership.update_single_member_field(
# This prevents lost updates in concurrent scenarios
case Membership.update_single_member_field_visibility(
socket.assigns.settings, socket.assigns.settings,
field: field_string, field: field_string,
show_in_overview: show_in_overview show_in_overview: show_in_overview,
required: required
) do ) do
{:ok, _updated_settings} -> {:ok, _updated_settings} ->
socket.assigns.on_save.(socket.assigns.member_field, "update") socket.assigns.on_save.(socket.assigns.member_field, "update")
{:noreply, socket} {:noreply, socket}
{:error, error} -> {:error, error} ->
# Add error to form
form = form =
socket.assigns.form socket.assigns.form
|> Map.put(:errors, [ |> Map.put(:errors, [
@ -288,16 +286,29 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
defp assign_form(%{assigns: %{member_field: member_field, settings: settings}} = socket) do defp assign_form(%{assigns: %{member_field: member_field, settings: settings}} = socket) do
field_attributes = get_field_attributes(member_field) field_attributes = get_field_attributes(member_field)
visibility_config = settings.member_field_visibility || %{} visibility_config = settings.member_field_visibility || %{}
normalized_config = VisibilityConfig.normalize(visibility_config) required_config = settings.member_field_required || %{}
show_in_overview = Map.get(normalized_config, member_field, true) normalized_visibility = VisibilityConfig.normalize(visibility_config)
normalized_required = VisibilityConfig.normalize(required_config)
show_in_overview = Map.get(normalized_visibility, member_field, true)
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
# Persist in socket so validate/save can enforce server-side without relying on render assigns
socket =
assign(
socket,
:vereinfacht_required_field?,
vereinfacht_required_field?(%{member_field: member_field})
)
# Email always required; Vereinfacht-required fields when integration active; else from settings
required =
member_field == :email ||
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(member_field)) ||
Map.get(normalized_required, member_field, false)
# Create a manual form structure with string keys
# Note: immutable is not included as it's not editable for member fields
form_data = %{ form_data = %{
"name" => MemberFields.label(member_field), "name" => MemberFields.label(member_field),
"value_type" => FieldTypeFormatter.format(field_attributes.value_type), "value_type" => FieldTypeFormatter.format(field_attributes.value_type),
"description" => field_attributes.description || "", "required" => required,
"required" => field_attributes.required,
"show_in_overview" => show_in_overview "show_in_overview" => show_in_overview
} }
@ -307,24 +318,14 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
end end
defp get_field_attributes(field) when is_atom(field) do defp get_field_attributes(field) when is_atom(field) do
# Get attribute info from Member Resource
alias Ash.Resource.Info alias Ash.Resource.Info
case Info.attribute(Mv.Membership.Member, field) do case Info.attribute(Mv.Membership.Member, field) do
nil -> nil ->
# Fallback for fields not in resource (shouldn't happen with Constants) %{value_type: :string}
%{
value_type: :string,
description: nil,
required: field in @required_fields
}
attribute -> attribute ->
%{ %{value_type: attribute.type}
value_type: attribute.type,
description: nil,
required: not attribute.allow_nil?
}
end end
end end
@ -335,4 +336,9 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
defp format_error(error) do defp format_error(error) do
inspect(error) inspect(error)
end end
defp vereinfacht_required_field?(assigns) do
Mv.Config.vereinfacht_configured?() &&
Mv.Constants.vereinfacht_required_field?(assigns.member_field)
end
end end

View file

@ -22,7 +22,6 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
assigns = assigns =
assigns assigns
|> assign(:member_fields, get_member_fields_with_visibility(assigns.settings)) |> assign(:member_fields, get_member_fields_with_visibility(assigns.settings))
|> assign(:required?, &required?/1)
~H""" ~H"""
<div id={@id}> <div id={@id}>
@ -62,22 +61,15 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
{format_value_type(field_data.field)} {format_value_type(field_data.field)}
</:col> </:col>
<:col :let={{_field_name, field_data}} label={gettext("Description")}>
{field_data.description || ""}
</:col>
<:col <:col
:let={{_field_name, field_data}} :let={{_field_name, field_data}}
label={gettext("Required")} label={gettext("Required")}
class="max-w-[9.375rem] text-center" class="max-w-[9.375rem] text-center"
> >
<span <span :if={field_data.required} class="text-base-content font-semibold">
:if={@required?.(field_data.field)}
class="text-base-content font-semibold"
>
{gettext("Required")} {gettext("Required")}
</span> </span>
<span :if={!@required?.(field_data.field)} class="text-base-content/70"> <span :if={!field_data.required} class="text-base-content/70">
{gettext("Optional")} {gettext("Optional")}
</span> </span>
</:col> </:col>
@ -173,26 +165,35 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
{:error, _} -> {:error, _} ->
# Return a minimal struct-like map for fallback # Return a minimal struct-like map for fallback
# This is only used for initial rendering, actual settings will be loaded properly # This is only used for initial rendering, actual settings will be loaded properly
%{member_field_visibility: %{}} %{member_field_visibility: %{}, member_field_required: %{}}
end end
end end
defp get_member_fields_with_visibility(settings) do defp get_member_fields_with_visibility(settings) do
member_fields = Mv.Constants.member_fields() member_fields = Mv.Constants.member_fields()
visibility_config = settings.member_field_visibility || %{} visibility_config = settings.member_field_visibility || %{}
required_config = settings.member_field_required || %{}
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
# Normalize visibility config keys to atoms normalized_visibility = VisibilityConfig.normalize(visibility_config)
normalized_config = VisibilityConfig.normalize(visibility_config) normalized_required = VisibilityConfig.normalize(required_config)
Enum.map(member_fields, fn field -> Enum.map(member_fields, fn field ->
show_in_overview = Map.get(normalized_config, field, true) show_in_overview = Map.get(normalized_visibility, field, true)
# Email always required; Vereinfacht-required fields when integration active; else from settings
required =
field == :email ||
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field)) ||
Map.get(normalized_required, field, false)
attribute = Info.attribute(Mv.Membership.Member, field) attribute = Info.attribute(Mv.Membership.Member, field)
%{ %{
field: field, field: field,
show_in_overview: show_in_overview, show_in_overview: show_in_overview,
value_type: (attribute && attribute.type) || :string, required: required,
description: nil value_type: (attribute && attribute.type) || :string
} }
end) end)
|> Enum.map(fn field_data -> |> Enum.map(fn field_data ->
@ -206,14 +207,4 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
attribute -> FieldTypeFormatter.format(attribute.type) attribute -> FieldTypeFormatter.format(attribute.type)
end end
end end
# Check if a field is required by checking the actual attribute definition
defp required?(field) when is_atom(field) do
case Info.attribute(Mv.Membership.Member, field) do
nil -> false
attribute -> not attribute.allow_nil?
end
end
defp required?(_), do: false
end end

View file

@ -23,6 +23,8 @@ defmodule MvWeb.MemberLive.Form do
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3] import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
alias Mv.Membership
alias Mv.Membership.Helpers.VisibilityConfig
alias Mv.MembershipFees alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.MembershipFeeHelpers alias MvWeb.Helpers.MembershipFeeHelpers
@ -84,10 +86,18 @@ defmodule MvWeb.MemberLive.Form do
<%!-- Name Row --%> <%!-- Name Row --%>
<div class="flex gap-4"> <div class="flex gap-4">
<div class="w-48"> <div class="w-48">
<.input field={@form[:first_name]} label={gettext("First Name")} /> <.input
field={@form[:first_name]}
label={gettext("First Name")}
required={@member_field_required_map[:first_name]}
/>
</div> </div>
<div class="w-48"> <div class="w-48">
<.input field={@form[:last_name]} label={gettext("Last Name")} /> <.input
field={@form[:last_name]}
label={gettext("Last Name")}
required={@member_field_required_map[:last_name]}
/>
</div> </div>
</div> </div>
@ -97,7 +107,11 @@ defmodule MvWeb.MemberLive.Form do
<.input field={@form[:country]} label={gettext("Country")} /> <.input field={@form[:country]} label={gettext("Country")} />
</div> </div>
<div class="w-24"> <div class="w-24">
<.input field={@form[:postal_code]} label={gettext("Postal Code")} /> <.input
field={@form[:postal_code]}
label={gettext("Postal Code")}
required={@member_field_required_map[:postal_code]}
/>
</div> </div>
<div class="w-48"> <div class="w-48">
<.input field={@form[:city]} label={gettext("City")} /> <.input field={@form[:city]} label={gettext("City")} />
@ -122,16 +136,31 @@ defmodule MvWeb.MemberLive.Form do
<%!-- Membership Dates Row --%> <%!-- Membership Dates Row --%>
<div class="flex gap-4"> <div class="flex gap-4">
<div class="w-36"> <div class="w-36">
<.input field={@form[:join_date]} label={gettext("Join Date")} type="date" /> <.input
field={@form[:join_date]}
label={gettext("Join Date")}
type="date"
required={@member_field_required_map[:join_date]}
/>
</div> </div>
<div class="w-36"> <div class="w-36">
<.input field={@form[:exit_date]} label={gettext("Exit Date")} type="date" /> <.input
field={@form[:exit_date]}
label={gettext("Exit Date")}
type="date"
required={@member_field_required_map[:exit_date]}
/>
</div> </div>
</div> </div>
<%!-- Notes --%> <%!-- Notes --%>
<div> <div>
<.input field={@form[:notes]} label={gettext("Notes")} type="textarea" /> <.input
field={@form[:notes]}
label={gettext("Notes")}
type="textarea"
required={@member_field_required_map[:notes]}
/>
</div> </div>
</div> </div>
</.form_section> </.form_section>
@ -261,6 +290,9 @@ defmodule MvWeb.MemberLive.Form do
# Load available membership fee types # Load available membership fee types
available_fee_types = load_available_fee_types(member, actor) available_fee_types = load_available_fee_types(member, actor)
# Load settings to know which member fields are required (for asterisk/tooltip)
member_field_required_map = get_member_field_required_map()
{:ok, {:ok,
socket socket
|> assign(:return_to, return_to(params["return_to"])) |> assign(:return_to, return_to(params["return_to"]))
@ -270,9 +302,38 @@ defmodule MvWeb.MemberLive.Form do
|> assign(:page_title, page_title) |> assign(:page_title, page_title)
|> assign(:available_fee_types, available_fee_types) |> assign(:available_fee_types, available_fee_types)
|> assign(:interval_warning, nil) |> assign(:interval_warning, nil)
|> assign(:member_field_required_map, member_field_required_map)
|> assign_form()} |> assign_form()}
end end
defp get_member_field_required_map do
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
case Membership.get_settings() do
{:ok, settings} ->
required_config = settings.member_field_required || %{}
normalized = VisibilityConfig.normalize(required_config)
Mv.Constants.member_fields()
|> Enum.map(fn field ->
required =
field == :email ||
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field)) ||
Map.get(normalized, field, false)
{field, required}
end)
|> Map.new()
{:error, _} ->
# Email always required; Vereinfacht fields when integration active
Map.new(Mv.Constants.member_fields(), fn f ->
{f,
f == :email || (vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(f))}
end)
end
end
defp return_to("show"), do: "show" defp return_to("show"), do: "show"
defp return_to(_), do: "index" defp return_to(_), do: "index"
@ -326,11 +387,40 @@ defmodule MvWeb.MemberLive.Form do
socket = socket =
socket socket
|> put_flash(:info, flash_message) |> put_flash(:info, flash_message)
|> maybe_put_vereinfacht_sync_flash(member.id)
|> push_navigate(to: return_path(socket.assigns.return_to, member)) |> push_navigate(to: return_path(socket.assigns.return_to, member))
{:noreply, socket} {:noreply, socket}
end end
defp maybe_put_vereinfacht_sync_flash(socket, member_id) do
case Mv.Vereinfacht.SyncFlash.take(to_string(member_id)) do
{:warning, message} ->
put_flash(socket, :warning, translate_vereinfacht_flash(message))
{:ok, _message} ->
# Optionally show sync success; for now we keep only the main success message
socket
nil ->
socket
end
end
defp translate_vereinfacht_flash(message) when is_binary(message) do
prefix = "Vereinfacht: "
if String.starts_with?(message, prefix) do
detail = message |> String.trim_leading(prefix) |> String.trim()
Gettext.dgettext(MvWeb.Gettext, "default", "Vereinfacht: %{detail}",
detail: Gettext.dgettext(MvWeb.Gettext, "default", detail)
)
else
Gettext.dgettext(MvWeb.Gettext, "default", message)
end
end
defp handle_save_error(socket, form) do defp handle_save_error(socket, form) do
# Always show a flash message when save fails # Always show a flash message when save fails
# Field-level validation errors are displayed in form fields, but flash provides additional feedback # Field-level validation errors are displayed in form fields, but flash provides additional feedback

View file

@ -682,6 +682,19 @@ defmodule MvWeb.MemberLive.Index do
|> update_selection_assigns() |> update_selection_assigns()
end end
# Update sort components after rendering
socket =
if socket.assigns[:sort_needs_update] do
old_field = socket.assigns[:previous_sort_field] || socket.assigns.sort_field
socket
|> update_sort_components(old_field, socket.assigns.sort_field, socket.assigns.sort_order)
|> assign(:sort_needs_update, false)
|> assign(:previous_sort_field, nil)
else
socket
end
{:noreply, socket} {:noreply, socket}
end end
@ -940,9 +953,10 @@ defmodule MvWeb.MemberLive.Index do
) )
# Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked) # Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked)
# Note: :groups is in computed_member_fields() but can be sorted in-memory, so we only block :membership_fee_status
members = members =
if sort_after_load and if sort_after_load and
socket.assigns.sort_field not in FieldVisibility.computed_member_fields() do socket.assigns.sort_field != :membership_fee_status do
sort_members_in_memory( sort_members_in_memory(
members, members,
socket.assigns.sort_field, socket.assigns.sort_field,
@ -1044,21 +1058,15 @@ defmodule MvWeb.MemberLive.Index do
defp maybe_sort(query, _field, nil, _custom_fields), do: {query, false} defp maybe_sort(query, _field, nil, _custom_fields), do: {query, false}
defp maybe_sort(query, field, order, _custom_fields) do defp maybe_sort(query, field, order, _custom_fields) do
if computed_field?(field) do # :groups is in computed_member_fields() but can be sorted in-memory
# Only :membership_fee_status should be blocked from sorting
if field == :membership_fee_status or field == "membership_fee_status" do
{query, false} {query, false}
else else
apply_sort_to_query(query, field, order) apply_sort_to_query(query, field, order)
end end
end end
defp computed_field?(field) do
computed_atoms = FieldVisibility.computed_member_fields()
computed_strings = Enum.map(computed_atoms, &Atom.to_string/1)
(is_atom(field) and field in computed_atoms) or
(is_binary(field) and field in computed_strings)
end
defp apply_sort_to_query(query, field, order) do defp apply_sort_to_query(query, field, order) do
cond do cond do
# Groups sort -> after load (in memory) # Groups sort -> after load (in memory)
@ -1086,13 +1094,19 @@ defmodule MvWeb.MemberLive.Index do
end end
defp valid_sort_field?(field) when is_atom(field) do defp valid_sort_field?(field) when is_atom(field) do
if field in FieldVisibility.computed_member_fields(), # :groups is in computed_member_fields() but can be sorted
do: false, # Only :membership_fee_status should be blocked
else: valid_sort_field_db_or_custom?(field) if field == :membership_fee_status do
false
else
valid_sort_field_db_or_custom?(field)
end
end end
defp valid_sort_field?(field) when is_binary(field) do defp valid_sort_field?(field) when is_binary(field) do
if field in Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1) do # "groups" is in computed_member_fields() but can be sorted
# Only "membership_fee_status" should be blocked
if field == "membership_fee_status" do
false false
else else
valid_sort_field_db_or_custom?(field) valid_sort_field_db_or_custom?(field)
@ -1249,10 +1263,13 @@ defmodule MvWeb.MemberLive.Index do
defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do
field = determine_field(socket.assigns.sort_field, sf) field = determine_field(socket.assigns.sort_field, sf)
order = determine_order(socket.assigns.sort_order, so) order = determine_order(socket.assigns.sort_order, so)
old_field = socket.assigns.sort_field
socket socket
|> assign(:sort_field, field) |> assign(:sort_field, field)
|> assign(:sort_order, order) |> assign(:sort_order, order)
|> assign(:sort_needs_update, old_field != field or socket.assigns.sort_order != order)
|> assign(:previous_sort_field, old_field)
end end
defp maybe_update_sort(socket, _), do: socket defp maybe_update_sort(socket, _), do: socket
@ -1261,17 +1278,27 @@ defmodule MvWeb.MemberLive.Index do
defp determine_field(default, nil), do: default defp determine_field(default, nil), do: default
defp determine_field(default, sf) when is_binary(sf) do defp determine_field(default, sf) when is_binary(sf) do
computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1) # Handle "groups" specially - it's in computed_member_fields() but can be sorted
if sf == "groups" do
:groups
else
computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1)
if sf in computed_strings, if sf in computed_strings,
do: default, do: default,
else: determine_field_after_computed_check(default, sf) else: determine_field_after_computed_check(default, sf)
end
end end
defp determine_field(default, sf) when is_atom(sf) do defp determine_field(default, sf) when is_atom(sf) do
if sf in FieldVisibility.computed_member_fields(), # Handle :groups specially - it's in computed_member_fields() but can be sorted
do: default, if sf == :groups do
else: determine_field_after_computed_check(default, sf) :groups
else
if sf in FieldVisibility.computed_member_fields(),
do: default,
else: determine_field_after_computed_check(default, sf)
end
end end
defp determine_field(default, _), do: default defp determine_field(default, _), do: default
@ -1620,6 +1647,14 @@ defmodule MvWeb.MemberLive.Index do
FieldVisibility.computed_member_fields() FieldVisibility.computed_member_fields()
|> Enum.filter(&(&1 in member_fields_computed)) |> Enum.filter(&(&1 in member_fields_computed))
# Include groups in export only if it's visible in the table
member_fields_with_groups =
if :groups in socket.assigns[:member_fields_visible] do
ordered_member_fields_db ++ ["groups"]
else
ordered_member_fields_db
end
# Order custom fields like the table (same as dynamic_cols / all_custom_fields order) # Order custom fields like the table (same as dynamic_cols / all_custom_fields order)
ordered_custom_field_ids = ordered_custom_field_ids =
socket.assigns.all_custom_fields socket.assigns.all_custom_fields
@ -1628,7 +1663,11 @@ defmodule MvWeb.MemberLive.Index do
%{ %{
selected_ids: socket.assigns.selected_members |> MapSet.to_list(), selected_ids: socket.assigns.selected_members |> MapSet.to_list(),
member_fields: Enum.map(ordered_member_fields_db, &Atom.to_string/1), member_fields:
Enum.map(member_fields_with_groups, fn
f when is_atom(f) -> Atom.to_string(f)
f when is_binary(f) -> f
end),
computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1), computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1),
custom_field_ids: ordered_custom_field_ids, custom_field_ids: ordered_custom_field_ids,
column_order: column_order:

View file

@ -349,6 +349,7 @@
</:col> </:col>
<:col <:col
:let={member} :let={member}
:if={:groups in @member_fields_visible}
label={ label={
~H""" ~H"""
<.live_component <.live_component

View file

@ -28,7 +28,8 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
alias Mv.Membership.Helpers.VisibilityConfig alias Mv.Membership.Helpers.VisibilityConfig
# Single UI key for "Membership Fee Status"; only this appears in the dropdown. # Single UI key for "Membership Fee Status"; only this appears in the dropdown.
@pseudo_member_fields [:membership_fee_status] # Groups is also a pseudo field (not a DB attribute, but displayed in the table).
@pseudo_member_fields [:membership_fee_status, :groups]
# Export/API may accept this as alias; must not appear in the UI options list. # Export/API may accept this as alias; must not appear in the UI options list.
@export_only_alias :payment_status @export_only_alias :payment_status
@ -201,7 +202,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
""" """
@spec get_visible_member_fields_computed(%{String.t() => boolean()}) :: [atom()] @spec get_visible_member_fields_computed(%{String.t() => boolean()}) :: [atom()]
def get_visible_member_fields_computed(field_selection) when is_map(field_selection) do def get_visible_member_fields_computed(field_selection) when is_map(field_selection) do
computed_set = MapSet.new(@pseudo_member_fields) computed_set = MapSet.new([:membership_fee_status])
field_selection field_selection
|> Enum.filter(fn {field_string, visible} -> |> Enum.filter(fn {field_string, visible} ->

View file

@ -256,6 +256,7 @@ defmodule MvWeb.MemberLive.Show do
id={"membership-fees-#{@member.id}"} id={"membership-fees-#{@member.id}"}
member={@member} member={@member}
current_user={@current_user} current_user={@current_user}
vereinfacht_receipts={@vereinfacht_receipts}
/> />
<% end %> <% end %>
</Layouts.app> </Layouts.app>
@ -264,7 +265,10 @@ defmodule MvWeb.MemberLive.Show do
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
{:ok, assign(socket, :active_tab, :contact)} {:ok,
socket
|> assign(:active_tab, :contact)
|> assign(:vereinfacht_receipts, nil)}
end end
@impl true @impl true
@ -316,6 +320,16 @@ defmodule MvWeb.MemberLive.Show do
{:noreply, assign(socket, :active_tab, :membership_fees)} {:noreply, assign(socket, :active_tab, :membership_fees)}
end end
def handle_event("load_vereinfacht_receipts", %{"contact_id" => contact_id}, socket) do
response =
case Mv.Vereinfacht.Client.get_contact_with_receipts(contact_id) do
{:ok, receipts} -> {:ok, receipts}
{:error, reason} -> {:error, reason}
end
{:noreply, assign(socket, :vereinfacht_receipts, response)}
end
# Flash set in LiveComponent is not shown in parent layout; child sends this to display flash # Flash set in LiveComponent is not shown in parent layout; child sends this to display flash
@impl true @impl true
def handle_info({:put_flash, type, message}, socket) do def handle_info({:put_flash, type, message}, socket) do

View file

@ -50,6 +50,90 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<% end %> <% end %>
</div> </div>
<%!-- Vereinfacht: contact info when synced, or warning when API is configured but no contact --%>
<%= if Mv.Config.vereinfacht_configured?() do %>
<%= if @vereinfacht_contact_present do %>
<div class="mb-4">
<div class="flex flex-col gap-2">
<.link
:if={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)}
href={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)}
target="_blank"
rel="noopener noreferrer"
class="link link-accent underline inline-flex items-center gap-1 w-fit"
>
{gettext("View contact in Vereinfacht")}
<.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" />
</.link>
<div>
<button
type="button"
phx-click="load_vereinfacht_receipts"
phx-value-contact_id={@member.vereinfacht_contact_id}
class="btn btn-sm btn-ghost"
>
{gettext("Show bookings/receipts from Vereinfacht")}
</button>
</div>
<%= if @vereinfacht_receipts do %>
<div
class="mt-2 rounded border border-base-300 bg-base-200 p-3 overflow-x-auto max-h-96 overflow-y-auto"
tabindex="0"
role="region"
aria-label={gettext("Vereinfacht receipts")}
>
<%= if match?({:ok, _}, @vereinfacht_receipts) do %>
<% {_, receipts} = @vereinfacht_receipts %>
<%= if receipts == [] do %>
<p class="text-sm text-base-content/70">{gettext("No receipts")}</p>
<% else %>
<% cols = receipt_display_columns(receipts) %>
<table class="table table-xs table-pin-rows">
<thead>
<tr>
<%= for {_key, translated_label} <- cols do %>
<th>{translated_label}</th>
<% end %>
</tr>
</thead>
<tbody>
<%= for r <- receipts do %>
<tr>
<%= for {col_key, _header_key} <- cols do %>
<td>{format_receipt_cell(col_key, r[col_key])}</td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
<% end %>
<% else %>
<% {:error, reason} = @vereinfacht_receipts %>
<p class="text-sm text-error">
{gettext("Error loading receipts: %{reason}",
reason: format_vereinfacht_error(reason)
)}
</p>
<% end %>
</div>
<% end %>
</div>
</div>
<% else %>
<div class="mb-4 rounded-lg border border-warning/50 bg-warning/10 p-3">
<p class="text-warning font-medium flex items-center gap-2">
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
{gettext("No Vereinfacht contact exists for this member.")}
</p>
<p class="text-sm text-base-content/70 mt-1">
{gettext(
"Sync this member from Settings (Vereinfacht section) or save the member again to create the contact."
)}
</p>
</div>
<% end %>
<% end %>
<%!-- Action Buttons (only when user has permission) --%> <%!-- Action Buttons (only when user has permission) --%>
<div class="flex gap-2 mb-4"> <div class="flex gap-2 mb-4">
<.button <.button
@ -431,6 +515,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:can_create_cycle, can_create_cycle) |> assign(:can_create_cycle, can_create_cycle)
|> assign(:can_destroy_cycle, can_destroy_cycle) |> assign(:can_destroy_cycle, can_destroy_cycle)
|> assign(:can_update_cycle, can_update_cycle) |> assign(:can_update_cycle, can_update_cycle)
|> assign(:vereinfacht_contact_present, present_contact_id?(member.vereinfacht_contact_id))
|> assign_new(:interval_warning, fn -> nil end) |> assign_new(:interval_warning, fn -> nil end)
|> assign_new(:editing_cycle, fn -> nil end) |> assign_new(:editing_cycle, fn -> nil end)
|> assign_new(:deleting_cycle, fn -> nil end) |> assign_new(:deleting_cycle, fn -> nil end)
@ -439,7 +524,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign_new(:creating_cycle, fn -> false end) |> assign_new(:creating_cycle, fn -> false end)
|> assign_new(:create_cycle_date, fn -> nil end) |> assign_new(:create_cycle_date, fn -> nil end)
|> assign_new(:create_cycle_error, fn -> nil end) |> assign_new(:create_cycle_error, fn -> nil end)
|> assign_new(:regenerating, fn -> false end)} |> assign_new(:regenerating, fn -> false end)
|> assign_new(:vereinfacht_receipts, fn -> nil end)}
end end
@impl true @impl true
@ -997,6 +1083,142 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
defp format_create_cycle_period(_date, _interval), do: "" defp format_create_cycle_period(_date, _interval), do: ""
defp present_contact_id?(nil), do: false
defp present_contact_id?(id) when is_binary(id), do: String.trim(id) != ""
defp present_contact_id?(_), do: false
defp format_vereinfacht_error({:http, status, detail}) when is_binary(detail),
do: "HTTP #{status} #{detail}"
defp format_vereinfacht_error({:http, status, _}), do: "HTTP #{status}"
defp format_vereinfacht_error(reason), do: inspect(reason)
# Ordered receipt columns: {api_key, gettext key for header}. Only columns present in data are shown.
@receipt_column_spec [
{:amount, "Amount"},
{:bookingDate, "Booking date"},
{:createdAt, "Created at"},
{:receiptType, "Receipt type"},
{:referenceNumber, "Reference number"},
{:status, "Status"},
{:updatedAt, "Updated at"}
]
defp receipt_display_columns(receipts) when is_list(receipts) do
keys_in_data = receipts |> Enum.flat_map(&Map.keys/1) |> MapSet.new()
Enum.filter(@receipt_column_spec, fn {key, _} -> MapSet.member?(keys_in_data, key) end)
|> Enum.map(fn {key, msgid} -> {key, Gettext.gettext(MvWeb.Gettext, msgid)} end)
end
defp format_receipt_cell(:amount, nil), do: ""
defp format_receipt_cell(:amount, val) when is_number(val) do
case Decimal.cast(val) do
{:ok, d} -> MembershipFeeHelpers.format_currency(d)
_ -> to_string(val)
end
end
defp format_receipt_cell(:amount, val) when is_binary(val) do
case Decimal.parse(val) do
{d, _} -> MembershipFeeHelpers.format_currency(d)
:error -> val
end
end
defp format_receipt_cell(:amount, val), do: to_string(val)
defp format_receipt_cell(:status, nil), do: ""
defp format_receipt_cell(:status, val) when is_binary(val) do
translate_receipt_status(val)
end
defp format_receipt_cell(:status, val), do: translate_receipt_status(to_string(val))
defp format_receipt_cell(:receiptType, nil), do: ""
defp format_receipt_cell(:receiptType, val) when is_binary(val) do
translate_receipt_type(val)
end
defp format_receipt_cell(:receiptType, val), do: translate_receipt_type(to_string(val))
defp format_receipt_cell(col_key, nil) when col_key in [:bookingDate, :createdAt, :updatedAt],
do: ""
defp format_receipt_cell(col_key, val) when col_key in [:bookingDate, :createdAt, :updatedAt] do
format_receipt_date(val)
end
defp format_receipt_cell(_col_key, val) when is_binary(val), do: val
defp format_receipt_cell(_col_key, val) when is_number(val), do: to_string(val)
defp format_receipt_cell(_col_key, val) when is_boolean(val),
do: if(val, do: gettext("Yes"), else: gettext("No"))
defp format_receipt_cell(_col_key, %Date{} = d), do: format_receipt_date_short(d)
defp format_receipt_cell(_col_key, val) when is_map(val) or is_list(val), do: Jason.encode!(val)
defp format_receipt_cell(_col_key, val), do: to_string(val)
defp format_receipt_date(%Date{} = d), do: format_receipt_date_short(d)
defp format_receipt_date(val) when is_binary(val) do
case parse_receipt_date(val) do
{:ok, d} -> format_receipt_date_short(d)
_ -> val
end
end
defp format_receipt_date(val), do: to_string(val)
# Parses ISO date or datetime string to Date (uses first 10 chars for datetime strings)
defp parse_receipt_date(val) when is_binary(val) do
date_str = if String.length(val) >= 10, do: String.slice(val, 0, 10), else: val
Date.from_iso8601(date_str)
end
# Format as "12. Dez. 2025" (day. abbreviated month. year) with translated month
defp format_receipt_date_short(%Date{day: day, month: month, year: year}) do
"#{day}. #{receipt_month_abbr(month)} #{year}"
end
defp receipt_month_abbr(1), do: gettext("Jan.")
defp receipt_month_abbr(2), do: gettext("Feb.")
defp receipt_month_abbr(3), do: gettext("Mar.")
defp receipt_month_abbr(4), do: gettext("Apr.")
defp receipt_month_abbr(5), do: gettext("May")
defp receipt_month_abbr(6), do: gettext("Jun.")
defp receipt_month_abbr(7), do: gettext("Jul.")
defp receipt_month_abbr(8), do: gettext("Aug.")
defp receipt_month_abbr(9), do: gettext("Sep.")
defp receipt_month_abbr(10), do: gettext("Oct.")
defp receipt_month_abbr(11), do: gettext("Nov.")
defp receipt_month_abbr(12), do: gettext("Dec.")
defp receipt_month_abbr(_), do: ""
# Translate API status values for display (extend as API returns more values)
defp translate_receipt_status("paid"), do: gettext("Paid")
defp translate_receipt_status("unpaid"), do: gettext("Unpaid")
defp translate_receipt_status("suspended"), do: gettext("Suspended")
defp translate_receipt_status("open"), do: gettext("Open")
defp translate_receipt_status("cancelled"), do: gettext("Cancelled")
defp translate_receipt_status("draft"), do: gettext("Draft")
defp translate_receipt_status("incompleted"), do: gettext("Incompleted")
defp translate_receipt_status("completed"), do: gettext("Completed")
defp translate_receipt_status("empty"), do: ""
defp translate_receipt_status(other), do: other
# Translate API receipt type values (extend as API returns more values)
defp translate_receipt_type("invoice"), do: gettext("Invoice")
defp translate_receipt_type("receipt"), do: gettext("Receipt")
defp translate_receipt_type("credit_note"), do: gettext("Credit note")
defp translate_receipt_type("credit"), do: gettext("Credit")
defp translate_receipt_type("expense"), do: gettext("Expense")
defp translate_receipt_type("income"), do: gettext("Income")
defp translate_receipt_type(other), do: other
# Helper component for section box # Helper component for section box
attr :title, :string, required: true attr :title, :string, required: true
slot :inner_block, required: true slot :inner_block, required: true

View file

@ -30,6 +30,7 @@ defmodule MvWeb.Translations.MemberFields do
def label(:country), do: gettext("Country") def label(:country), do: gettext("Country")
def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date") def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date")
def label(:membership_fee_status), do: gettext("Membership Fee Status") def label(:membership_fee_status), do: gettext("Membership Fee Status")
def label(:groups), do: gettext("Groups")
# Fallback for unknown fields # Fallback for unknown fields
def label(field) do def label(field) do

View file

@ -200,6 +200,7 @@ msgstr "Straße"
#: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex #: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No" msgid "No"
@ -286,8 +287,6 @@ msgstr "Abbrechen"
#: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
@ -2218,6 +2217,7 @@ msgstr "Gruppe erfolgreich gespeichert."
#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Groups" msgid "Groups"
msgstr "Gruppen" msgstr "Gruppen"
@ -2277,11 +2277,6 @@ msgstr "Nicht berechtigt."
msgid "Could not load data fields. Please check your permissions." msgid "Could not load data fields. Please check your permissions."
msgstr "Datenfelder konnten nicht geladen werden. Bitte überprüfe deine Berechtigungen." msgstr "Datenfelder konnten nicht geladen werden. Bitte überprüfe deine Berechtigungen."
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Could not load member search. Please try again."
msgstr "Mitgliedersuche konnte nicht geladen werden. Bitte versuchen Sie es erneut."
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Add Member" msgid "Add Member"
@ -2628,3 +2623,301 @@ msgstr "Der Wertetyp kann nach dem Erstellen nicht mehr geändert werden."
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Country" msgid "Country"
msgstr "Land" msgstr "Land"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Could not load member list. Please try again."
msgstr "Mitgliederliste konnte nicht geladen werden. Bitte versuche es erneut."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "API Key"
msgstr "API-Schlüssel"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "API URL"
msgstr "API-URL"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Club ID"
msgstr "Vereins-ID"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From VEREINFACHT_API_KEY"
msgstr "Aus VEREINFACHT_API_KEY"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From VEREINFACHT_API_URL"
msgstr "Aus VEREINFACHT_API_URL"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From VEREINFACHT_CLUB_ID"
msgstr "Aus VEREINFACHT_CLUB_ID"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Save Vereinfacht Settings"
msgstr "Vereinfacht-Einstellungen speichern"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Sync all members without Vereinfacht contact"
msgstr "Alle Mitglieder ohne Vereinfacht-Kontakt synchronisieren"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Synced %{count} member(s) to Vereinfacht."
msgstr "%{count} Mitglied(er) mit Vereinfacht synchronisiert."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Syncing..."
msgstr "Synchronisiere..."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Vereinfacht Integration"
msgstr "Vereinfacht-Integration"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID."
msgstr "Vereinfacht ist nicht konfiguriert. Bitte setze API-URL, API-Schlüssel und Vereins-ID."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "View contact in Vereinfacht"
msgstr "Kontakt in Vereinfacht anzeigen"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "%{count} failed"
msgstr "%{count} fehlgeschlagen"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "%{count} synced"
msgstr "%{count} synchronisiert"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Failed members:"
msgstr "Fehlgeschlagene Mitglieder:"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Last sync result:"
msgstr "Letztes Sync-Ergebnis:"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Synced %{count} member(s). %{error_count} failed."
msgstr "%{count} Mitglied(er) synchronisiert. %{error_count} Fehler."
# Vereinfacht API error messages (translated for UI)
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Vereinfacht: %{detail}"
msgstr "Vereinfacht: %{detail}"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "No Vereinfacht contact exists for this member."
msgstr "Für dieses Mitglied existiert kein Vereinfacht-Kontakt."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact."
msgstr "Synchronisiere dieses Mitglied unter Einstellungen (Bereich Vereinfacht) oder speichere das Mitglied erneut, um den Kontakt anzulegen."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "(set)"
msgstr "(gesetzt)"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Leave blank to keep current"
msgstr "Leer lassen, um den aktuellen Wert beizubehalten"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Some values are set via environment variables. Those fields are read-only."
msgstr "Einige Werte werden über Umgebungsvariablen gesetzt. Diese Felder sind schreibgeschützt."
# Vereinfacht API validation messages (looked up at runtime via dgettext)
msgid "The address field is required."
msgstr "Das Adressfeld ist erforderlich."
msgid "The city field is required."
msgstr "Das Stadtfeld ist erforderlich."
msgid "The email field is required."
msgstr "Das E-Mail-Feld ist erforderlich."
msgid "The first name field is required."
msgstr "Das Vornamenfeld ist erforderlich."
msgid "The last name field is required."
msgstr "Das Nachnamenfeld ist erforderlich."
msgid "The zip code field is required."
msgstr "Das Postleitzahlenfeld ist erforderlich."
msgid "Too Many Attempts."
msgstr "Zu viele Versuche."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "App URL (contact view link)"
msgstr "App-URL (Link zur Kontaktansicht)"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "From VEREINFACHT_APP_URL"
msgstr "Aus VEREINFACHT_APP_URL"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Error loading receipts: %{reason}"
msgstr "Belege konnten nicht geladen werden: %{reason}"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "No receipts"
msgstr "Keine Belege"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Show bookings/receipts from Vereinfacht"
msgstr "Buchungen/Belege aus Vereinfacht anzeigen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Vereinfacht receipts"
msgstr "Vereinfacht-Belege"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Cancelled"
msgstr "Storniert"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Credit"
msgstr "Gutschrift"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Credit note"
msgstr "Gutschrift"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Draft"
msgstr "Entwurf"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Invoice"
msgstr "Rechnung"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Open"
msgstr "Offen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Receipt"
msgstr "Beleg"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Apr."
msgstr "Apr."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Aug."
msgstr "Aug."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Completed"
msgstr "Abgeschlossen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Dec."
msgstr "Dez."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Expense"
msgstr "Ausgabe"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Feb."
msgstr "Feb."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Income"
msgstr "Einnahme"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Incompleted"
msgstr "Unvollständig"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Jan."
msgstr "Jan."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Jul."
msgstr "Jul."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Jun."
msgstr "Jun."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Mar."
msgstr "Mär."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "May"
msgstr "Mai"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Nov."
msgstr "Nov."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Oct."
msgstr "Okt."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Sep."
msgstr "Sep."
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Required for Vereinfacht integration and cannot be disabled."
msgstr "Für die Vereinfacht-Integration erforderlich und kann nicht deaktiviert werden."

View file

@ -201,6 +201,7 @@ msgstr ""
#: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex #: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No" msgid "No"
@ -287,8 +288,6 @@ msgstr ""
#: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
@ -2219,6 +2218,7 @@ msgstr ""
#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Groups" msgid "Groups"
msgstr "" msgstr ""
@ -2278,11 +2278,6 @@ msgstr ""
msgid "Could not load data fields. Please check your permissions." msgid "Could not load data fields. Please check your permissions."
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Could not load member search. Please try again."
msgstr ""
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Add Member" msgid "Add Member"
@ -2629,3 +2624,300 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Country" msgid "Country"
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Could not load member list. Please try again."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "API Key"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "API URL"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Club ID"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From VEREINFACHT_API_KEY"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From VEREINFACHT_API_URL"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From VEREINFACHT_CLUB_ID"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Save Vereinfacht Settings"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Sync all members without Vereinfacht contact"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Synced %{count} member(s) to Vereinfacht."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Syncing..."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Vereinfacht Integration"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "View contact in Vereinfacht"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "%{count} failed"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "%{count} synced"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Failed members:"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Last sync result:"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Synced %{count} member(s). %{error_count} failed."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Vereinfacht: %{detail}"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "No Vereinfacht contact exists for this member."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "(set)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Leave blank to keep current"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Some values are set via environment variables. Those fields are read-only."
msgstr ""
# Vereinfacht API validation messages (looked up at runtime via dgettext)
msgid "The address field is required."
msgstr ""
msgid "The city field is required."
msgstr ""
msgid "The email field is required."
msgstr ""
msgid "The first name field is required."
msgstr ""
msgid "The last name field is required."
msgstr ""
msgid "The zip code field is required."
msgstr ""
msgid "Too Many Attempts."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "App URL (contact view link)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From VEREINFACHT_APP_URL"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Error loading receipts: %{reason}"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "No receipts"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Show bookings/receipts from Vereinfacht"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Vereinfacht receipts"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Cancelled"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Credit"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Credit note"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Draft"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Invoice"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Open"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Receipt"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Apr."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Aug."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Completed"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Dec."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Expense"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Feb."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Income"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Incompleted"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Jan."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Jul."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Jun."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Mar."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "May"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Nov."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Oct."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Sep."
msgstr ""
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Required for Vereinfacht integration and cannot be disabled."
msgstr ""

View file

@ -201,6 +201,7 @@ msgstr ""
#: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex #: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No" msgid "No"
@ -287,8 +288,6 @@ msgstr ""
#: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
@ -2219,6 +2218,7 @@ msgstr ""
#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Groups" msgid "Groups"
msgstr "" msgstr ""
@ -2278,11 +2278,6 @@ msgstr ""
msgid "Could not load data fields. Please check your permissions." msgid "Could not load data fields. Please check your permissions."
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Could not load member search. Please try again."
msgstr ""
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Add Member" msgid "Add Member"
@ -2629,3 +2624,300 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Country" msgid "Country"
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Could not load member list. Please try again."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "API Key"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "API URL"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Club ID"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From VEREINFACHT_API_KEY"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From VEREINFACHT_API_URL"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From VEREINFACHT_CLUB_ID"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Save Vereinfacht Settings"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Sync all members without Vereinfacht contact"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Synced %{count} member(s) to Vereinfacht."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Syncing..."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Vereinfacht Integration"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "View contact in Vereinfacht"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "%{count} failed"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "%{count} synced"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Failed members:"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Last sync result:"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Synced %{count} member(s). %{error_count} failed."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Vereinfacht: %{detail}"
msgstr "Vereinfacht: %{detail}"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "No Vereinfacht contact exists for this member."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact."
msgstr "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "(set)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Leave blank to keep current"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Some values are set via environment variables. Those fields are read-only."
msgstr ""
# Vereinfacht API validation messages (looked up at runtime via dgettext)
msgid "The address field is required."
msgstr ""
msgid "The city field is required."
msgstr ""
msgid "The email field is required."
msgstr ""
msgid "The first name field is required."
msgstr ""
msgid "The last name field is required."
msgstr ""
msgid "The zip code field is required."
msgstr ""
msgid "Too Many Attempts."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "App URL (contact view link)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "From VEREINFACHT_APP_URL"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Error loading receipts: %{reason}"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "No receipts"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Show bookings/receipts from Vereinfacht"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Vereinfacht receipts"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Cancelled"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Credit"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Credit note"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Draft"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Invoice"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Open"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Receipt"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Apr."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Aug."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Completed"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Dec."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Expense"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Feb."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Income"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Incompleted"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Jan."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Jul."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Jun."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Mar."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "May"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Nov."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Oct."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Sep."
msgstr ""
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Required for Vereinfacht integration and cannot be disabled."
msgstr "Required for Vereinfacht integration and cannot be disabled."

View file

@ -0,0 +1,21 @@
defmodule Mv.Repo.Migrations.AddVereinfachtContactIdToMembers 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(:members) do
add :vereinfacht_contact_id, :text
end
end
def down do
alter table(:members) do
remove :vereinfacht_contact_id
end
end
end

View file

@ -0,0 +1,25 @@
defmodule Mv.Repo.Migrations.AddVereinfachtSettings 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 :vereinfacht_api_url, :text
add :vereinfacht_api_key, :text
add :vereinfacht_club_id, :text
end
end
def down do
alter table(:settings) do
remove :vereinfacht_club_id
remove :vereinfacht_api_key
remove :vereinfacht_api_url
end
end
end

View file

@ -0,0 +1,15 @@
defmodule Mv.Repo.Migrations.AddVereinfachtAppUrl do
use Ecto.Migration
def up do
alter table(:settings) do
add :vereinfacht_app_url, :text
end
end
def down do
alter table(:settings) do
remove :vereinfacht_app_url
end
end
end

View file

@ -0,0 +1,21 @@
defmodule Mv.Repo.Migrations.AddMemberFieldRequiredToSettings 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_required, :map
end
end
def down do
alter table(:settings) do
remove :member_field_required
end
end
end

View file

@ -3,10 +3,10 @@
# mix run priv/repo/seeds.exs # mix run priv/repo/seeds.exs
# #
alias Mv.Membership
alias Mv.Accounts alias Mv.Accounts
alias Mv.MembershipFees.MembershipFeeType alias Mv.Membership
alias Mv.MembershipFees.CycleGenerator alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.MembershipFeeType
require Ash.Query require Ash.Query
@ -579,6 +579,39 @@ Enum.with_index(linked_members)
end end
end) end)
# Create example groups (idempotent: create only if name does not exist)
group_configs = [
%{name: "Vorstand", description: "Gremium Vorstand"},
%{name: "Trainer*innen", description: "Alle lizenzierten Trainer*innen"},
%{name: "Jugend", description: "Jugendbereich"},
%{name: "Newsletter", description: "Empfänger*innen Newsletter"}
]
existing_groups =
case Membership.list_groups(actor: admin_user_with_role) do
{:ok, list} -> list
{:error, _} -> []
end
existing_names_lower = MapSet.new(existing_groups, &String.downcase(&1.name))
seed_groups =
Enum.reduce(group_configs, %{}, fn config, acc ->
name = config.name
if MapSet.member?(existing_names_lower, String.downcase(name)) do
group = Enum.find(existing_groups, &(String.downcase(&1.name) == String.downcase(name)))
Map.put(acc, name, group)
else
group =
Membership.create_group!(%{name: name, description: config.description},
actor: admin_user_with_role
)
Map.put(acc, name, group)
end
end)
# Create sample custom field values for some members # Create sample custom field values for some members
all_members = Ash.read!(Membership.Member, actor: admin_user_with_role) all_members = Ash.read!(Membership.Member, actor: admin_user_with_role)
all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_role) all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_role)
@ -587,6 +620,35 @@ all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_rol
find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end
find_member = fn email -> Enum.find(all_members, &(&1.email == email)) end find_member = fn email -> Enum.find(all_members, &(&1.email == email)) end
# Assign seed members to groups (idempotent: duplicate create_member_group is skipped)
member_group_assignments = [
{"hans.mueller@example.de", ["Vorstand", "Newsletter"]},
{"greta.schmidt@example.de", ["Jugend", "Newsletter"]},
{"friedrich.wagner@example.de", ["Trainer*innen"]},
{"maria.weber@example.de", ["Newsletter"]},
{"thomas.klein@example.de", ["Newsletter"]}
]
Enum.each(member_group_assignments, fn {email, group_names} ->
member = find_member.(email)
if member do
Enum.each(group_names, fn group_name ->
group = seed_groups[group_name]
if group do
case Membership.create_member_group(
%{member_id: member.id, group_id: group.id},
actor: admin_user_with_role
) do
{:ok, _} -> :ok
{:error, _} -> :ok
end
end
end)
end
end)
# Add custom field values for Hans Müller # Add custom field values for Hans Müller
if hans = find_member.("hans.mueller@example.de") do if hans = find_member.("hans.mueller@example.de") do
[ [
@ -731,6 +793,7 @@ IO.puts(
) )
IO.puts(" - Sample members: Hans, Greta, Friedrich") IO.puts(" - Sample members: Hans, Greta, Friedrich")
IO.puts(" - Groups: Vorstand, Trainer*innen, Jugend, Newsletter (with members assigned)")
IO.puts( IO.puts(
" - Additional users: hans.mueller@example.de, greta.schmidt@example.de, maria.weber@example.de, thomas.klein@example.de" " - Additional users: hans.mueller@example.de, greta.schmidt@example.de, maria.weber@example.de, thomas.klein@example.de"

View file

@ -0,0 +1,234 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"uuid_generate_v7()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "first_name",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "last_name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "email",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "join_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "exit_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "notes",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "city",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "street",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "house_number",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "postal_code",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "search_vector",
"type": "tsvector"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "membership_fee_start_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "vereinfacht_contact_id",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "members_membership_fee_type_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "membership_fee_types"
},
"scale": null,
"size": null,
"source": "membership_fee_type_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"create_table_options": null,
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "4DF7F20D4C8D91E229906D6ADF87A4B5EB410672799753012DE4F0F49B470A51",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "members_unique_email_index",
"keys": [
{
"type": "atom",
"value": "email"
}
],
"name": "unique_email",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "members"
}

View file

@ -0,0 +1,140 @@
{
"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": "true",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "include_joining_cycle",
"type": "boolean"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "default_membership_fee_type_id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "vereinfacht_api_url",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "vereinfacht_api_key",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "vereinfacht_club_id",
"type": "text"
},
{
"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": [],
"create_table_options": null,
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "1038A37F021DFC347E325042D613B0359FEB7DAFAE3286CBCEAA940A52B71217",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "settings"
}

View file

@ -0,0 +1,152 @@
{
"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": "true",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "include_joining_cycle",
"type": "boolean"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "default_membership_fee_type_id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "vereinfacht_api_url",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "vereinfacht_api_key",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "vereinfacht_club_id",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "vereinfacht_app_url",
"type": "text"
},
{
"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": [],
"create_table_options": null,
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "4C29CEF273C1180162E7231A7F7CCE5DABD035E121648E48B6FBE30AE5191FF0",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "settings"
}

View file

@ -86,6 +86,66 @@ defmodule Mv.Membership.MemberTest do
end end
end end
describe "Settings-driven required fields" do
@valid_attrs %{
first_name: "John",
last_name: "Doe",
email: "john@example.com"
}
setup do
{:ok, settings} = Membership.get_settings()
saved_visibility = settings.member_field_visibility || %{}
saved_required = settings.member_field_required || %{}
on_exit(fn ->
{:ok, s} = Membership.get_settings()
Membership.update_settings(s, %{
member_field_visibility: saved_visibility,
member_field_required: saved_required
})
end)
:ok
end
test "when first_name is required in settings, create without first_name fails", %{
actor: actor
} do
{:ok, settings} = Membership.get_settings()
{:ok, _} =
Membership.update_single_member_field(settings,
field: "first_name",
show_in_overview: true,
required: true
)
attrs = Map.delete(@valid_attrs, :first_name)
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.create_member(attrs, actor: actor)
assert error_message(errors, :first_name) =~ "can't be blank"
end
test "when first_name is required in settings, create with first_name succeeds", %{
actor: actor
} do
{:ok, settings} = Membership.get_settings()
{:ok, _} =
Membership.update_single_member_field(settings,
field: "first_name",
show_in_overview: true,
required: true
)
assert {:ok, _member} = Membership.create_member(@valid_attrs, actor: actor)
end
end
describe "Authorization" do describe "Authorization" do
@valid_attrs %{ @valid_attrs %{
first_name: "John", first_name: "John",

View file

@ -3,6 +3,23 @@ defmodule Mv.Membership.SettingTest do
alias Mv.Membership alias Mv.Membership
describe "Settings Resource" do describe "Settings Resource" do
setup do
{:ok, settings} = Membership.get_settings()
saved_visibility = settings.member_field_visibility || %{}
saved_required = settings.member_field_required || %{}
on_exit(fn ->
{:ok, s} = Membership.get_settings()
Membership.update_settings(s, %{
member_field_visibility: saved_visibility,
member_field_required: saved_required
})
end)
:ok
end
test "can read settings" do test "can read settings" do
# Settings should be a singleton resource # Settings should be a singleton resource
assert {:ok, _settings} = Membership.get_settings() assert {:ok, _settings} = Membership.get_settings()
@ -39,6 +56,65 @@ defmodule Mv.Membership.SettingTest do
assert error_message(errors, :club_name) =~ "must be present" assert error_message(errors, :club_name) =~ "must be present"
end end
test "can update and read member_field_required" do
{:ok, settings} = Membership.get_settings()
required_config = %{"first_name" => true, "last_name" => true}
assert {:ok, updated} =
Membership.update_settings(settings, %{member_field_required: required_config})
assert updated.member_field_required["first_name"] == true
assert updated.member_field_required["last_name"] == true
end
test "member_field_required rejects invalid keys" do
{:ok, settings} = Membership.get_settings()
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.update_settings(settings, %{
member_field_required: %{"invalid_field" => true}
})
assert error_message(errors, :member_field_required) =~ "Invalid member field"
end
test "member_field_required rejects non-boolean values" do
{:ok, settings} = Membership.get_settings()
assert {:error, %Ash.Error.Invalid{errors: errors}} =
Membership.update_settings(settings, %{
member_field_required: %{"first_name" => "yes"}
})
assert error_message(errors, :member_field_required) =~ "must be booleans"
end
test "update_single_member_field updates both visibility and required" do
{:ok, settings} = Membership.get_settings()
assert {:ok, updated} =
Membership.update_single_member_field(settings,
field: "first_name",
show_in_overview: true,
required: true
)
assert updated.member_field_visibility["first_name"] == true
assert updated.member_field_required["first_name"] == true
# Update same field to required: false
assert {:ok, updated2} =
Membership.update_single_member_field(updated,
field: "first_name",
show_in_overview: false,
required: false
)
assert updated2.member_field_visibility["first_name"] == false
assert updated2.member_field_required["first_name"] == false
end
end end
# Helper function to extract error messages # Helper function to extract error messages

View file

@ -0,0 +1,83 @@
defmodule Mv.ConfigVereinfachtTest do
@moduledoc """
Tests for Mv.Config Vereinfacht-related helpers.
"""
use Mv.DataCase, async: false
describe "vereinfacht_env_configured?/0" do
test "returns false when no Vereinfacht ENV variables are set" do
clear_vereinfacht_env()
refute Mv.Config.vereinfacht_env_configured?()
end
test "returns true when VEREINFACHT_API_URL is set" do
set_vereinfacht_env("VEREINFACHT_API_URL", "https://api.example.com")
assert Mv.Config.vereinfacht_env_configured?()
after
clear_vereinfacht_env()
end
test "returns true when VEREINFACHT_CLUB_ID is set" do
set_vereinfacht_env("VEREINFACHT_CLUB_ID", "2")
assert Mv.Config.vereinfacht_env_configured?()
after
clear_vereinfacht_env()
end
end
describe "vereinfacht_configured?/0" do
test "returns false when no config is set" do
clear_vereinfacht_env()
# Settings may have nil for vereinfacht fields
refute Mv.Config.vereinfacht_configured?()
end
end
describe "vereinfacht_contact_view_url/1" do
test "returns nil when API URL is not configured" do
clear_vereinfacht_env()
assert Mv.Config.vereinfacht_contact_view_url("123") == nil
end
test "returns app contact view URL when API URL is set (derived app URL)" do
clear_vereinfacht_env()
clear_vereinfacht_app_url_from_settings()
set_vereinfacht_env("VEREINFACHT_API_URL", "https://api.example.com/api/v1")
assert Mv.Config.vereinfacht_contact_view_url("42") ==
"https://app.example.com/en/admin/finances/contacts/42"
after
clear_vereinfacht_env()
end
test "returns app contact view URL when VEREINFACHT_APP_URL is set" do
set_vereinfacht_env("VEREINFACHT_APP_URL", "https://app.verein.visuel.dev")
assert Mv.Config.vereinfacht_contact_view_url("abc") ==
"https://app.verein.visuel.dev/en/admin/finances/contacts/abc"
after
clear_vereinfacht_env()
end
end
defp set_vereinfacht_env(key, value) do
System.put_env(key, value)
end
defp clear_vereinfacht_env do
System.delete_env("VEREINFACHT_API_URL")
System.delete_env("VEREINFACHT_API_KEY")
System.delete_env("VEREINFACHT_CLUB_ID")
System.delete_env("VEREINFACHT_APP_URL")
end
defp clear_vereinfacht_app_url_from_settings do
case Mv.Membership.get_settings() do
{:ok, settings} ->
Mv.Membership.update_settings(settings, %{vereinfacht_app_url: nil})
_ ->
:ok
end
end
end

View file

@ -0,0 +1,102 @@
defmodule Mv.Vereinfacht.Changes.SyncContactTest do
@moduledoc """
Tests for Mv.Vereinfacht.Changes.SyncContact.
When Vereinfacht is not configured, member create/update should succeed
and vereinfacht_contact_id remains nil.
"""
use Mv.DataCase, async: false
alias Mv.Membership
setup do
clear_vereinfacht_env()
:ok
end
describe "member create when Vereinfacht not configured" do
test "member is created and vereinfacht_contact_id is nil" do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
attrs = %{
first_name: "Sync",
last_name: "Test",
email: "sync_test_#{System.unique_integer([:positive])}@example.com"
}
assert {:ok, member} = Membership.create_member(attrs, actor: system_actor)
assert member.vereinfacht_contact_id == nil
end
end
describe "member update when Vereinfacht not configured" do
test "member is updated and vereinfacht_contact_id is unchanged" do
member = Mv.Fixtures.member_fixture()
system_actor = Mv.Helpers.SystemActor.get_system_actor()
assert {:ok, updated} =
Membership.update_member(member, %{first_name: "Updated"}, actor: system_actor)
assert updated.vereinfacht_contact_id == nil
end
end
describe "when Vereinfacht is configured" do
# Regression: after_transaction callback receives 2 args (changeset, result), not 3.
# If the callback had arity 3, create_member would raise BadArityError.
# Also: Client must send JSON-encoded body (iodata); raw map causes ArgumentError
# when the request is sent. With an unreachable URL we get :econnrefused before
# that, so this test would not catch the iodata bug; a Bypass/stub server would.
test "create_member succeeds and after_transaction runs without error (API may fail)" do
set_vereinfacht_env()
system_actor = Mv.Helpers.SystemActor.get_system_actor()
attrs = %{
first_name: "API",
last_name: "Test",
email: "api_test_#{System.unique_integer([:positive])}@example.com",
street: "Test St",
postal_code: "12345",
city: "Test City"
}
assert {:ok, member} = Membership.create_member(attrs, actor: system_actor)
assert member.id
# Sync may fail (e.g. connection refused), so contact_id can stay nil
after
clear_vereinfacht_env()
end
test "update_member succeeds and after_transaction runs without error (API may fail)" do
set_vereinfacht_env()
member =
Mv.Fixtures.member_fixture(%{
street: "Test St",
postal_code: "12345",
city: "Test City"
})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
assert {:ok, updated} =
Membership.update_member(member, %{first_name: "Updated"}, actor: system_actor)
assert updated.id == member.id
after
clear_vereinfacht_env()
end
end
defp set_vereinfacht_env do
System.put_env("VEREINFACHT_API_URL", "http://127.0.0.1:1/api/v1")
System.put_env("VEREINFACHT_API_KEY", "test-key")
System.put_env("VEREINFACHT_CLUB_ID", "2")
end
defp clear_vereinfacht_env do
System.delete_env("VEREINFACHT_API_URL")
System.delete_env("VEREINFACHT_API_KEY")
System.delete_env("VEREINFACHT_CLUB_ID")
end
end

View file

@ -0,0 +1,50 @@
defmodule Mv.Vereinfacht.ClientTest do
@moduledoc """
Tests for Mv.Vereinfacht.Client.
Only tests the "not configured" path; no real HTTP calls. Config reads from
ENV first, then from Settings (DB), so we use DataCase so get_settings() is available.
"""
use Mv.DataCase, async: false
alias Mv.Vereinfacht.Client
setup do
clear_vereinfacht_env()
:ok
end
describe "create_contact/1" do
test "returns {:error, :not_configured} when Vereinfacht is not configured" do
member = build_member_struct()
assert Client.create_contact(member) == {:error, :not_configured}
end
end
describe "update_contact/2" do
test "returns {:error, :not_configured} when Vereinfacht is not configured" do
member = build_member_struct()
assert Client.update_contact("123", member) == {:error, :not_configured}
end
end
defp build_member_struct do
%{
first_name: "Test",
last_name: "User",
email: "test@example.com",
street: "Street 1",
house_number: "2",
postal_code: "12345",
city: "Berlin"
}
end
defp clear_vereinfacht_env do
System.delete_env("VEREINFACHT_API_URL")
System.delete_env("VEREINFACHT_API_KEY")
System.delete_env("VEREINFACHT_CLUB_ID")
end
end

View file

@ -0,0 +1,59 @@
defmodule Mv.VereinfachtTest do
@moduledoc """
Tests for Mv.Vereinfacht business logic.
No real API calls; tests "not configured" path and pure helpers (format_error).
"""
use Mv.DataCase, async: false
alias Mv.Vereinfacht
setup do
clear_vereinfacht_env()
:ok
end
describe "sync_member/1" do
test "returns :ok when Vereinfacht is not configured (no-op)" do
member = Mv.Fixtures.member_fixture()
assert Vereinfacht.sync_member(member) == :ok
end
end
describe "sync_members_without_contact/0" do
test "returns {:error, :not_configured} when Vereinfacht is not configured" do
assert Vereinfacht.sync_members_without_contact() == {:error, :not_configured}
end
end
describe "format_error/1" do
test "formats HTTP error with detail" do
assert Vereinfacht.format_error({:http, 422, "The email field is required."}) ==
"Vereinfacht: The email field is required."
end
test "formats HTTP error without detail" do
assert Vereinfacht.format_error({:http, 500, nil}) ==
"Vereinfacht: API error (HTTP 500)."
end
test "formats request_failed" do
assert Vereinfacht.format_error({:request_failed, %{reason: :econnrefused}}) ==
"Vereinfacht: Request failed (e.g. connection error)."
end
test "formats invalid_response and other terms" do
assert Vereinfacht.format_error({:invalid_response, %{}}) ==
"Vereinfacht: Invalid API response."
assert Vereinfacht.format_error(:timeout) == "Vereinfacht: :timeout"
end
end
defp clear_vereinfacht_env do
System.delete_env("VEREINFACHT_API_URL")
System.delete_env("VEREINFACHT_API_KEY")
System.delete_env("VEREINFACHT_CLUB_ID")
end
end

View file

@ -0,0 +1,29 @@
# Vereinfacht tests scope and rationale
## Constraint: no real API in CI
Tests do **not** call the real Vereinfacht API or a shared test endpoint. All tests use dummy data and either:
- Assert behaviour when **Vereinfacht is not configured** (ENV + Settings unset), or
- Run the **full Member/User flow** with a **unreachable URL** (e.g. `http://127.0.0.1:1`) so the HTTP client fails fast (e.g. `:econnrefused`) and we only assert that the application path does not crash.
## What the tests cover
| Test file | What it tests | Why its enough without an API |
|-----------|----------------|---------------------------------|
| **ConfigVereinfachtTest** | `vereinfacht_env_configured?`, `vereinfacht_configured?`, `vereinfacht_contact_view_url` with ENV set/cleared | Pure config logic; no HTTP. |
| **ClientTest** | `create_contact/1` and `update_contact/2` return `{:error, :not_configured}` when nothing is configured | Ensures the client does not call Req when config is missing. |
| **VereinfachtTest** | `sync_members_without_contact/0` returns `{:error, :not_configured}` when not configured | Ensures bulk sync is a no-op when config is missing. |
| **SyncContactTest** | Member create/update with SyncContact change: not configured → no sync; configured with bad URL → action still succeeds, sync may fail | Ensures the Ash change and after_transaction arity are correct and the action result is not broken by sync failures. |
## What is *not* tested (and would need a stub or real endpoint)
- Actual HTTP request shape (body, headers) and response handling (201/200, error codes).
- Persistence of `vereinfacht_contact_id` after a successful create.
- Translation of specific API error payloads into user messages.
Those would require either a **Bypass** (or similar) stub in front of Req or a dedicated test endpoint; both are out of scope for the current “no real API” setup.
## Conclusion
Given the constraint that the API is not called in CI, the tests are **meaningful**: they cover config, “not configured” paths, and integration of SyncContact with Member create/update without crashing. They are **sufficient** for regression safety and refactoring; extending them with a Bypass stub would be an optional next step if we want to assert on request/response shape without hitting the real API.

View file

@ -71,4 +71,18 @@ defmodule MvWeb.GlobalSettingsLiveConfigTest do
end end
end end
end end
describe "Vereinfacht Integration section" do
setup %{conn: conn} do
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
{:ok, conn: conn}
end
@tag :ui
test "settings page shows Vereinfacht Integration section", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
assert html =~ "Vereinfacht"
end
end
end end

View file

@ -19,6 +19,7 @@ defmodule MvWeb.GroupLive.FormTest do
test "form renders with empty fields", %{conn: conn} do test "form renders with empty fields", %{conn: conn} do
{:ok, view, html} = live(conn, "/groups/new") {:ok, view, html} = live(conn, "/groups/new")
# OR-chain for i18n (Create Group / Gruppe erstellen)
assert html =~ gettext("Create Group") or html =~ "create" or html =~ "Gruppe erstellen" assert html =~ gettext("Create Group") or html =~ "create" or html =~ "Gruppe erstellen"
assert has_element?(view, "form") assert has_element?(view, "form")
end end
@ -65,6 +66,7 @@ defmodule MvWeb.GroupLive.FormTest do
|> form("#group-form", group: form_data) |> form("#group-form", group: form_data)
|> render_submit() |> render_submit()
# OR-chain for i18n (required/erforderlich) and validation message wording
assert html =~ gettext("required") or html =~ "name" or html =~ "error" or assert html =~ gettext("required") or html =~ "name" or html =~ "error" or
html =~ "erforderlich" html =~ "erforderlich"
end end
@ -80,6 +82,7 @@ defmodule MvWeb.GroupLive.FormTest do
|> form("#group-form", group: form_data) |> form("#group-form", group: form_data)
|> render_submit() |> render_submit()
# OR-chain for i18n (length/Länge) and validation message
assert html =~ "100" or html =~ "length" or html =~ "error" or html =~ "Länge" assert html =~ "100" or html =~ "length" or html =~ "error" or html =~ "Länge"
end end
@ -98,6 +101,7 @@ defmodule MvWeb.GroupLive.FormTest do
|> form("#group-form", group: form_data) |> form("#group-form", group: form_data)
|> render_submit() |> render_submit()
# OR-chain for i18n (length/Länge) and validation message
assert html =~ "500" or html =~ "length" or html =~ "error" or html =~ "Länge" assert html =~ "500" or html =~ "length" or html =~ "error" or html =~ "Länge"
end end
@ -116,6 +120,7 @@ defmodule MvWeb.GroupLive.FormTest do
|> render_submit() |> render_submit()
# Check for a validation error on the name field in a robust way # Check for a validation error on the name field in a robust way
# OR-chain for i18n and validation message (already taken)
assert html =~ "name" or html =~ gettext("has already been taken") assert html =~ "name" or html =~ gettext("has already been taken")
end end
@ -131,6 +136,7 @@ defmodule MvWeb.GroupLive.FormTest do
|> form("#group-form", group: form_data) |> form("#group-form", group: form_data)
|> render_submit() |> render_submit()
# OR-chain for i18n (error/Fehler, invalid/ungültig)
assert html =~ "error" or html =~ "invalid" or html =~ "Fehler" or html =~ "ungültig" assert html =~ "error" or html =~ "invalid" or html =~ "Fehler" or html =~ "ungültig"
end end
end end
@ -196,6 +202,7 @@ defmodule MvWeb.GroupLive.FormTest do
|> form("#group-form", group: form_data) |> form("#group-form", group: form_data)
|> render_submit() |> render_submit()
# OR-chain for i18n (already taken / bereits vergeben) and validation wording
assert html =~ "already" or html =~ "taken" or html =~ "exists" or html =~ "error" or assert html =~ "already" or html =~ "taken" or html =~ "exists" or html =~ "error" or
html =~ "bereits" or html =~ "vergeben" html =~ "bereits" or html =~ "vergeben"
end end
@ -205,7 +212,7 @@ defmodule MvWeb.GroupLive.FormTest do
{:ok, _view, html} = live(conn, "/groups/#{group.slug}/edit") {:ok, _view, html} = live(conn, "/groups/#{group.slug}/edit")
# Slug should not be in form (it's immutable) # Slug should not be in form (it's immutable); regex for input element
refute html =~ ~r/slug.*input/i or html =~ ~r/input.*slug/i refute html =~ ~r/slug.*input/i or html =~ ~r/input.*slug/i
end end
end end

View file

@ -40,13 +40,14 @@ defmodule MvWeb.GroupLive.IndexTest do
assert html =~ "Test Group" assert html =~ "Test Group"
assert html =~ "Test description" assert html =~ "Test description"
# Member count should be displayed (0 for empty group) # OR-chain for i18n (Members/Mitglieder) and alternate copy for count
assert html =~ "0" or html =~ gettext("Members") or html =~ "Mitglieder" assert html =~ "0" or html =~ gettext("Members") or html =~ "Mitglieder"
end end
test "displays 'Create Group' button for admin users", %{conn: conn} do test "displays 'Create Group' button for admin users", %{conn: conn} do
{:ok, _view, html} = live(conn, "/groups") {:ok, _view, html} = live(conn, "/groups")
# OR-chain for i18n (Create Group / Gruppe erstellen) and alternate wording
assert html =~ gettext("Create Group") or html =~ "create" or html =~ "new" or assert html =~ gettext("Create Group") or html =~ "create" or html =~ "new" or
html =~ "Gruppe erstellen" html =~ "Gruppe erstellen"
end end
@ -54,7 +55,7 @@ defmodule MvWeb.GroupLive.IndexTest do
test "displays empty state when no groups exist", %{conn: conn} do test "displays empty state when no groups exist", %{conn: conn} do
{:ok, _view, html} = live(conn, "/groups") {:ok, _view, html} = live(conn, "/groups")
# Should show empty state or empty list message # OR-chain for i18n (No groups / Keine Gruppen) and alternate empty state copy
assert html =~ gettext("No groups") or html =~ "0" or html =~ "empty" or assert html =~ gettext("No groups") or html =~ "0" or html =~ "empty" or
html =~ "Keine Gruppen" html =~ "Keine Gruppen"
end end
@ -76,6 +77,7 @@ defmodule MvWeb.GroupLive.IndexTest do
{:ok, _view, html} = live(conn, "/groups") {:ok, _view, html} = live(conn, "/groups")
# Long description may be truncated in UI
assert html =~ long_description or html =~ String.slice(long_description, 0, 100) assert html =~ long_description or html =~ String.slice(long_description, 0, 100)
end end
end end
@ -109,7 +111,7 @@ defmodule MvWeb.GroupLive.IndexTest do
# Should be able to see groups # Should be able to see groups
assert html =~ gettext("Groups") assert html =~ gettext("Groups")
# Should NOT see create button # Read-only must not see create button (OR for i18n)
refute html =~ gettext("Create Group") or html =~ "create" refute html =~ gettext("Create Group") or html =~ "create"
end end
end end
@ -177,7 +179,7 @@ defmodule MvWeb.GroupLive.IndexTest do
final_count = Agent.get(query_count_agent, & &1) final_count = Agent.get(query_count_agent, & &1)
:telemetry.detach(handler_id) :telemetry.detach(handler_id)
# Member count should be displayed (should be 2) # OR-chain for i18n (Members/Mitglieder) and count display
assert html =~ "2" or html =~ gettext("Members") or html =~ "Mitglieder" assert html =~ "2" or html =~ gettext("Members") or html =~ "Mitglieder"
# Verify query count is reasonable (member count should be calculated efficiently) # Verify query count is reasonable (member count should be calculated efficiently)

View file

@ -62,7 +62,7 @@ defmodule MvWeb.GroupLive.IntegrationTest do
assert html =~ "Updated Workflow Test Group" assert html =~ "Updated Workflow Test Group"
assert html =~ "Updated description" assert html =~ "Updated description"
# Slug should remain unchanged # OR-chain: slug may appear as UUID or normalized slug in copy
assert html =~ original_slug or html =~ "workflow-test-group" assert html =~ original_slug or html =~ "workflow-test-group"
end end
@ -101,7 +101,7 @@ defmodule MvWeb.GroupLive.IntegrationTest do
# View group via slug # View group via slug
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, _view, html} = live(conn, "/groups/#{group.slug}")
# Member count should be 2 # OR-chain for i18n (Members/Mitglieder); member names may be first or last
assert html =~ "2" or html =~ gettext("Members") or html =~ "Mitglieder" assert html =~ "2" or html =~ gettext("Members") or html =~ "Mitglieder"
assert html =~ member1.first_name or html =~ member1.last_name assert html =~ member1.first_name or html =~ member1.last_name
assert html =~ member2.first_name or html =~ member2.last_name assert html =~ member2.first_name or html =~ member2.last_name

View file

@ -22,12 +22,13 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
|> element("button", "Add Member") |> element("button", "Add Member")
|> render_click() |> render_click()
html = render(view) # OR-chain: at least one of these ARIA/role attributes must be present
assert has_element?(view, "[data-testid=group-show-member-search-input][aria-label]") or
# Search input should have proper ARIA attributes has_element?(
assert html =~ ~r/aria-label/ || view,
html =~ ~r/aria-autocomplete/ || "[data-testid=group-show-member-search-input][aria-autocomplete]"
html =~ ~r/role=["']combobox["']/ ) or
has_element?(view, "[data-testid=group-show-member-search-input][role=combobox]")
end end
test "search input has correct aria-label and aria-autocomplete attributes", %{conn: conn} do test "search input has correct aria-label and aria-autocomplete attributes", %{conn: conn} do
@ -35,16 +36,14 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
html = render(view) assert has_element?(
view,
# Search input should have ARIA attributes "[data-testid=group-show-member-search-input][aria-autocomplete=list]"
assert html =~ ~r/aria-label.*[Ss]earch.*member/ || )
html =~ ~r/aria-autocomplete=["']list["']/
end end
test "remove button has aria-label with tooltip text", %{conn: conn} do test "remove button has aria-label with tooltip text", %{conn: conn} do
@ -67,11 +66,7 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
html = render(view) assert has_element?(view, "[data-testid=group-show-remove-member][aria-label]")
# Remove button should have aria-label
assert html =~ ~r/aria-label.*[Rr]emove/ ||
html =~ ~r/aria-label.*member/i
end end
test "add button has correct aria-label", %{conn: conn} do test "add button has correct aria-label", %{conn: conn} do
@ -79,16 +74,11 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
html = render(view) assert has_element?(view, "[data-testid=group-show-add-selected-members-btn][aria-label]")
# Add button should have aria-label
assert html =~ ~r/aria-label.*[Aa]dd/ ||
html =~ ~r/button.*[Aa]dd/
end end
end end
@ -100,16 +90,11 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
html = render(view) assert has_element?(view, "[data-testid=group-show-member-search-input]")
# Inline add member area should have focusable elements
assert html =~ ~r/input|button/ ||
html =~ "#member-search-input"
end end
test "inline input can be closed", %{conn: conn} do test "inline input can be closed", %{conn: conn} do
@ -117,17 +102,11 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
assert has_element?(view, "#member-search-input") assert has_element?(view, "[data-testid=group-show-member-search-input]")
# Click Add Member button again to close (or add a member to close it)
# For now, we verify the input is visible when opened
html = render(view)
assert html =~ "#member-search-input" || has_element?(view, "#member-search-input")
end end
test "enter/space activates buttons when focused", %{conn: conn} do test "enter/space activates buttons when focused", %{conn: conn} do
@ -148,17 +127,14 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
# Select member
view view
|> element("#member-search-input") |> element("[data-testid=group-show-member-search-input]")
|> render_focus() |> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Bob"}) |> render_change(%{"member_search" => "Bob"})
@ -167,14 +143,11 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
|> element("[data-member-id='#{member.id}']") |> element("[data-member-id='#{member.id}']")
|> render_click() |> render_click()
# Add button should be enabled and clickable
view view
|> element("button[phx-click='add_selected_members']") |> element("[data-testid=group-show-add-selected-members-btn]")
|> render_click() |> render_click()
# Should succeed (member should appear in list) assert has_element?(view, "[data-testid=group-show-members-table]", "Bob")
html = render(view)
assert html =~ "Bob"
end end
test "focus management: focus is set to input when opened", %{conn: conn} do test "focus management: focus is set to input when opened", %{conn: conn} do
@ -184,16 +157,11 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
html = render(view) assert has_element?(view, "[data-testid=group-show-member-search-input]")
# Input should be visible and focusable
assert html =~ "#member-search-input" ||
html =~ ~r/autofocus|tabindex/
end end
end end
@ -203,16 +171,11 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
html = render(view) assert has_element?(view, "[data-testid=group-show-member-search-input][aria-label]")
# Input should have aria-label
assert html =~ ~r/aria-label.*[Ss]earch.*member/ ||
html =~ ~r/aria-label/
end end
test "search results are properly announced", %{conn: conn} do test "search results are properly announced", %{conn: conn} do
@ -231,27 +194,20 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
# Search
view view
|> element("#member-search-input") |> element("[data-testid=group-show-member-search-input]")
|> render_focus() |> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Charlie"}) |> render_change(%{"member_search" => "Charlie"})
html = render(view) assert has_element?(view, "#member-dropdown[role=listbox]")
assert has_element?(view, "#member-dropdown", "Charlie")
# Search results should have proper ARIA attributes
assert html =~ ~r/role=["']listbox["']/ ||
html =~ ~r/role=["']option["']/ ||
html =~ "Charlie"
end end
test "flash messages are properly announced", %{conn: conn} do test "flash messages are properly announced", %{conn: conn} do
@ -270,16 +226,14 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Add member
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
view view
|> element("#member-search-input") |> element("[data-testid=group-show-member-search-input]")
|> render_focus() |> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "David"}) |> render_change(%{"member_search" => "David"})
@ -289,13 +243,10 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
|> render_click() |> render_click()
view view
|> element("button[phx-click='add_selected_members']") |> element("[data-testid=group-show-add-selected-members-btn]")
|> render_click() |> render_click()
html = render(view) assert has_element?(view, "[data-testid=group-show-members-table]", "David")
# Member should appear in list (no flash message)
assert html =~ "David"
end end
end end
end end

View file

@ -34,9 +34,8 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
select_member(view, member) select_member(view, member)
add_selected(view) add_selected(view)
html = render(view) assert has_element?(view, "[data-testid=group-show-members-table]", "Alice")
assert html =~ "Alice" assert has_element?(view, "[data-testid=group-show-members-table]", "Johnson")
assert html =~ "Johnson"
end end
test "member is successfully added to group (verified in list)", %{conn: conn} do test "member is successfully added to group (verified in list)", %{conn: conn} do
@ -55,16 +54,14 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input and add member
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
view view
|> element("#member-search-input") |> element("[data-testid=group-show-member-search-input]")
|> render_focus() |> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Bob"}) |> render_change(%{"member_search" => "Bob"})
@ -74,14 +71,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("button[phx-click='add_selected_members']") |> element("[data-testid=group-show-add-selected-members-btn]")
|> render_click() |> render_click()
html = render(view) assert has_element?(view, "[data-testid=group-show-members-table]", "Bob")
assert has_element?(view, "[data-testid=group-show-members-table]", "Smith")
# Verify member appears in group list (no success flash message)
assert html =~ "Bob"
assert html =~ "Smith"
end end
test "group member list updates automatically after add", %{conn: conn} do test "group member list updates automatically after add", %{conn: conn} do
@ -98,21 +92,18 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
actor: system_actor actor: system_actor
) )
{:ok, view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Initially member should NOT be in list refute has_element?(view, "[data-testid=group-show-members-table]", "Charlie")
refute html =~ "Charlie"
# Add member
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
view view
|> element("#member-search-input") |> element("[data-testid=group-show-member-search-input]")
|> render_focus() |> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Charlie"}) |> render_change(%{"member_search" => "Charlie"})
@ -122,13 +113,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("button[phx-click='add_selected_members']") |> element("[data-testid=group-show-add-selected-members-btn]")
|> render_click() |> render_click()
# Member should now appear in list assert has_element?(view, "[data-testid=group-show-members-table]", "Charlie")
html = render(view) assert has_element?(view, "[data-testid=group-show-members-table]", "Brown")
assert html =~ "Charlie"
assert html =~ "Brown"
end end
test "member count updates automatically after add", %{conn: conn} do test "member count updates automatically after add", %{conn: conn} do
@ -152,11 +141,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Add member # Add member
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
view view
|> element("#member-search-input") |> element("[data-testid=group-show-member-search-input]")
|> render_focus() |> render_focus()
# phx-change is on the form, so we need to trigger it via the form # phx-change is on the form, so we need to trigger it via the form
@ -169,7 +158,7 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("button[phx-click='add_selected_members']") |> element("[data-testid=group-show-add-selected-members-btn]")
|> render_click() |> render_click()
# Count should have increased # Count should have increased
@ -196,14 +185,14 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Open inline input # Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
assert has_element?(view, "#member-search-input") assert has_element?(view, "[data-testid=group-show-member-search-input]")
# Add member # Add member
view view
|> element("#member-search-input") |> element("[data-testid=group-show-member-search-input]")
|> render_focus() |> render_focus()
# phx-change is on the form, so we need to trigger it via the form # phx-change is on the form, so we need to trigger it via the form
@ -216,11 +205,10 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("button[phx-click='add_selected_members']") |> element("[data-testid=group-show-add-selected-members-btn]")
|> render_click() |> render_click()
# Inline input should be closed (Add Member button should be visible again) refute has_element?(view, "[data-testid=group-show-member-search-input]")
refute has_element?(view, "#member-search-input")
end end
test "Cancel button closes inline add member area without adding", %{conn: conn} do test "Cancel button closes inline add member area without adding", %{conn: conn} do
@ -229,7 +217,7 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
open_add_member(view) open_add_member(view)
assert has_element?(view, "#member-search-input") assert has_element?(view, "[data-testid=group-show-member-search-input]")
assert has_element?(view, "button[phx-click='hide_add_member_input']") assert has_element?(view, "button[phx-click='hide_add_member_input']")
cancel_add_member(view) cancel_add_member(view)
@ -263,7 +251,7 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Try to add same member again # Try to add same member again
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
# Member should not appear in search (filtered out) # Member should not appear in search (filtered out)
@ -281,12 +269,12 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("button", "Add") |> element("[data-testid=group-show-add-selected-members-btn]")
|> render_click() |> render_click()
# Should show error # OR-chain for i18n and alternate error wording (already in group / duplicate)
html = render(view) html = render(view)
assert html =~ gettext("already in group") || html =~ ~r/already.*group|duplicate/i assert html =~ gettext("already in group") or html =~ ~r/already.*group|duplicate/i
end end
end end
@ -300,7 +288,7 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Open inline input # Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
# Try to add with invalid member ID (if possible) # Try to add with invalid member ID (if possible)
@ -331,11 +319,10 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Open inline input # Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
# Inline input should be open assert has_element?(view, "[data-testid=group-show-member-search-input]")
assert has_element?(view, "#member-search-input")
# If error occurs, inline input should remain open # If error occurs, inline input should remain open
# (Implementation will handle this) # (Implementation will handle this)
@ -348,11 +335,10 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Open inline input # Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
# Add button should be disabled assert has_element?(view, "[data-testid=group-show-add-selected-members-btn][disabled]")
assert has_element?(view, "button[phx-click='add_selected_members'][disabled]")
end end
end end
@ -375,11 +361,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Add member to empty group # Add member to empty group
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
view view
|> element("#member-search-input") |> element("[data-testid=group-show-member-search-input]")
|> render_focus() |> render_focus()
# phx-change is on the form, so we need to trigger it via the form # phx-change is on the form, so we need to trigger it via the form
@ -392,12 +378,10 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("button[phx-click='add_selected_members']") |> element("[data-testid=group-show-add-selected-members-btn]")
|> render_click() |> render_click()
# Member should be added assert has_element?(view, "[data-testid=group-show-members-table]", "Henry")
html = render(view)
assert html =~ "Henry"
end end
test "add works when member is already in other groups", %{conn: conn} do test "add works when member is already in other groups", %{conn: conn} do
@ -424,11 +408,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Add same member to group2 (should work) # Add same member to group2 (should work)
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
view view
|> element("#member-search-input") |> element("[data-testid=group-show-member-search-input]")
|> render_focus() |> render_focus()
# phx-change is on the form, so we need to trigger it via the form # phx-change is on the form, so we need to trigger it via the form
@ -441,12 +425,10 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("button[phx-click='add_selected_members']") |> element("[data-testid=group-show-add-selected-members-btn]")
|> render_click() |> render_click()
# Member should be added to group2 assert has_element?(view, "[data-testid=group-show-members-table]", "Isabel")
html = render(view)
assert html =~ "Isabel"
end end
end end

View file

@ -22,18 +22,18 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
test "Add Member button is visible for users with :update permission", %{conn: conn} do test "Add Member button is visible for users with :update permission", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert html =~ gettext("Add Member") or html =~ "Add Member" assert has_element?(view, "button[phx-click='show_add_member_input']")
end end
@tag role: :read_only @tag role: :read_only
test "Add Member button is NOT visible for users without :update permission", %{conn: conn} do test "Add Member button is NOT visible for users without :update permission", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
refute html =~ gettext("Add Member") refute has_element?(view, "button[phx-click='show_add_member_input']")
end end
test "Add Member button is positioned above member table", %{conn: conn} do test "Add Member button is positioned above member table", %{conn: conn} do
@ -61,11 +61,7 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Remove button should exist (can be icon button with trash icon) assert has_element?(view, "[data-testid=group-show-remove-member]")
html = render(view)
assert html =~ "Remove" or html =~ "remove" or html =~ "trash" or
html =~ ~r/hero-trash|hero-x-mark/
end end
@tag role: :read_only @tag role: :read_only
@ -78,10 +74,9 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
actor: system_actor actor: system_actor
) )
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Remove button should NOT exist (check for trash icon or remove button specifically) refute has_element?(view, "[data-testid=group-show-remove-member]")
refute html =~ "hero-trash" or html =~ ~r/<button[^>]*remove_member/
end end
end end
@ -110,10 +105,7 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
|> element("button", gettext("Add Member")) |> element("button", gettext("Add Member"))
|> render_click() |> render_click()
html = render(view) assert has_element?(view, "[data-testid=group-show-member-search-input]")
assert html =~ gettext("Search for a member...") ||
html =~ ~r/search.*member/i
end end
test "Add button (plus icon) is disabled until member selected", %{conn: conn} do test "Add button (plus icon) is disabled until member selected", %{conn: conn} do
@ -121,15 +113,11 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", gettext("Add Member")) |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
html = render(view) assert has_element?(view, "[data-testid=group-show-add-selected-members-btn][disabled]")
# Add button should exist and be disabled initially
assert has_element?(view, "button[phx-click='add_selected_members'][disabled]") ||
html =~ ~r/disabled/
end end
end end
end end

View file

@ -52,8 +52,7 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
|> render_click() |> render_click()
# Should succeed (admin has :update permission, member should appear in list) # Should succeed (admin has :update permission, member should appear in list)
html = render(view) assert has_element?(view, "[data-testid=group-show-members-table]", "Alice")
assert html =~ "Alice"
end end
@tag role: :read_only @tag role: :read_only
@ -78,9 +77,7 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
# Note: If button is hidden, we can't click it, but we test the event handler # Note: If button is hidden, we can't click it, but we test the event handler
# by trying to send the event directly if possible # by trying to send the event directly if possible
# For now, we verify that the button is not visible refute has_element?(view, "button[phx-click='show_add_member_input']")
html = render(view)
refute html =~ "Add Member"
end end
test "remove member event handler checks :update permission", %{conn: conn} do test "remove member event handler checks :update permission", %{conn: conn} do
@ -103,14 +100,11 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Remove member (should succeed for admin)
view view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
# Should succeed (member should no longer be in list) refute has_element?(view, "[data-testid=group-show-members-table]", "Charlie")
html = render(view)
refute html =~ "Charlie"
end end
@tag role: :read_only @tag role: :read_only
@ -134,11 +128,7 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Remove button should not be visible refute has_element?(view, "[data-testid=group-show-remove-member]")
html = render(view)
# Read-only user should NOT see Remove button (check for trash icon or remove button specifically)
refute html =~ "hero-trash" or html =~ ~r/<button[^>]*remove_member/
end end
test "error flash message on unauthorized access", %{conn: conn} do test "error flash message on unauthorized access", %{conn: conn} do
@ -174,10 +164,10 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
actor: system_actor actor: system_actor
) )
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Admin should see buttons assert has_element?(view, "button[phx-click='show_add_member_input']")
assert html =~ "Add Member" || html =~ "Remove" assert has_element?(view, "[data-testid=group-show-remove-member]")
end end
@tag role: :read_only @tag role: :read_only
@ -185,10 +175,9 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
_system_actor = Mv.Helpers.SystemActor.get_system_actor() _system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Read-only user should NOT see Add Member button refute has_element?(view, "button[phx-click='show_add_member_input']")
refute html =~ "Add Member"
end end
@tag role: :read_only @tag role: :read_only
@ -210,21 +199,18 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
actor: system_actor actor: system_actor
) )
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Read-only user should NOT see Remove button (check for trash icon or remove button specifically) refute has_element?(view, "[data-testid=group-show-remove-member]")
refute html =~ "hero-trash" or html =~ ~r/<button[^>]*remove_member/
end end
@tag role: :read_only @tag role: :read_only
test "inline add member area cannot be opened for unauthorized users", %{conn: conn} do test "inline add member area cannot be opened for unauthorized users", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Inline input should not be accessible (button hidden) refute has_element?(view, "button[phx-click='show_add_member_input']")
refute html =~ "Add Member"
refute html =~ "#member-search-input"
end end
end end

View file

@ -305,9 +305,8 @@ defmodule MvWeb.GroupLive.ShowIntegrationTest do
|> render_click() |> render_click()
# Both members should be in list # Both members should be in list
html = render(view) assert has_element?(view, "[data-testid=group-show-members-table]", "Frank")
assert html =~ "Frank" assert has_element?(view, "[data-testid=group-show-members-table]", "Grace")
assert html =~ "Grace"
end end
test "multiple members can be removed sequentially", %{conn: conn} do test "multiple members can be removed sequentially", %{conn: conn} do
@ -343,11 +342,11 @@ defmodule MvWeb.GroupLive.ShowIntegrationTest do
actor: system_actor actor: system_actor
) )
{:ok, view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Both should be in list initially # Both should be in list initially
assert html =~ "Henry" assert has_element?(view, "[data-testid=group-show-members-table]", "Henry")
assert html =~ "Isabel" assert has_element?(view, "[data-testid=group-show-members-table]", "Isabel")
# Remove first member # Remove first member
view view
@ -360,9 +359,8 @@ defmodule MvWeb.GroupLive.ShowIntegrationTest do
|> render_click() |> render_click()
# Both should be removed # Both should be removed
html = render(view) refute has_element?(view, "[data-testid=group-show-members-table]", "Henry")
refute html =~ "Henry" refute has_element?(view, "[data-testid=group-show-members-table]", "Isabel")
refute html =~ "Isabel"
end end
test "add and remove can be mixed", %{conn: conn} do test "add and remove can be mixed", %{conn: conn} do
@ -424,9 +422,8 @@ defmodule MvWeb.GroupLive.ShowIntegrationTest do
|> render_click() |> render_click()
# Only member2 should remain # Only member2 should remain
html = render(view) refute has_element?(view, "[data-testid=group-show-members-table]", "Jack")
refute html =~ "Jack" assert has_element?(view, "[data-testid=group-show-members-table]", "Kate")
assert html =~ "Kate"
end end
end end
end end

View file

@ -34,21 +34,16 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
# Type exact name
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Jonathan"}) |> render_change(%{"member_search" => "Jonathan"})
html = render(view) assert has_element?(view, "#member-dropdown", "Jonathan")
assert has_element?(view, "#member-dropdown", "Smith")
assert html =~ "Jonathan"
assert html =~ "Smith"
end end
test "search finds member by partial name (fuzzy)", %{conn: conn} do test "search finds member by partial name (fuzzy)", %{conn: conn} do
@ -68,22 +63,16 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
# Type partial name
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Jon"}) |> render_change(%{"member_search" => "Jon"})
html = render(view) assert has_element?(view, "#member-dropdown", "Jonathan")
assert has_element?(view, "#member-dropdown", "Smith")
# Fuzzy search should find Jonathan
assert html =~ "Jonathan"
assert html =~ "Smith"
end end
test "search finds member by email", %{conn: conn} do test "search finds member by email", %{conn: conn} do
@ -103,22 +92,17 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
# Search by email
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "alice.johnson"}) |> render_change(%{"member_search" => "alice.johnson"})
html = render(view) assert has_element?(view, "#member-dropdown", "Alice")
assert has_element?(view, "#member-dropdown", "Johnson")
assert html =~ "Alice" assert has_element?(view, "#member-dropdown", "alice.johnson@example.com")
assert html =~ "Johnson"
assert html =~ "alice.johnson@example.com"
end end
test "dropdown shows member name and email", %{conn: conn} do test "dropdown shows member name and email", %{conn: conn} do
@ -153,11 +137,9 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Bob"}) |> render_change(%{"member_search" => "Bob"})
html = render(view) assert has_element?(view, "#member-dropdown", "Bob")
assert has_element?(view, "#member-dropdown", "Williams")
assert html =~ "Bob" assert has_element?(view, "#member-dropdown", "bob@example.com")
assert html =~ "Williams"
assert html =~ "bob@example.com"
end end
test "ComboBox hook works (focus opens dropdown)", %{conn: conn} do test "ComboBox hook works (focus opens dropdown)", %{conn: conn} do
@ -177,20 +159,15 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
# Focus input
view view
|> element("#member-search-input") |> element("[data-testid=group-show-member-search-input]")
|> render_focus() |> render_focus()
html = render(view) assert has_element?(view, "#member-dropdown[role=listbox]")
# Dropdown should be visible
assert html =~ ~r/role="listbox"/ || html =~ "listbox"
end end
end end
@ -228,21 +205,16 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
# Search for "David"
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "David"}) |> render_change(%{"member_search" => "David"})
# Assert only on dropdown (available members), not the members table assert has_element?(view, "#member-dropdown", "Anderson")
dropdown_html = view |> element("#member-dropdown") |> render() refute has_element?(view, "#member-dropdown", "Miller")
assert dropdown_html =~ "Anderson"
refute dropdown_html =~ "Miller"
end end
test "search filters correctly when group has many members", %{conn: conn} do test "search filters correctly when group has many members", %{conn: conn} do
@ -280,23 +252,18 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
# Search
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Available"}) |> render_change(%{"member_search" => "Available"})
# Assert only on dropdown (available members), not the members table assert has_element?(view, "#member-dropdown", "Available")
dropdown_html = view |> element("#member-dropdown") |> render() assert has_element?(view, "#member-dropdown", "Member")
assert dropdown_html =~ "Available" refute has_element?(view, "#member-dropdown", "Member1")
assert dropdown_html =~ "Member" refute has_element?(view, "#member-dropdown", "Member2")
refute dropdown_html =~ "Member1"
refute dropdown_html =~ "Member2"
end end
test "search shows no results when all available members are already in group", %{conn: conn} do test "search shows no results when all available members are already in group", %{conn: conn} do
@ -321,18 +288,14 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
# Search
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Only"}) |> render_change(%{"member_search" => "Only"})
# When no available members, dropdown is not rendered (length(@available_members) == 0)
refute has_element?(view, "#member-dropdown") refute has_element?(view, "#member-dropdown")
end end
end end

View file

@ -31,19 +31,15 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
actor: system_actor actor: system_actor
) )
{:ok, view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Member should be in list initially assert has_element?(view, "[data-testid=group-show-members-table]", "Alice")
assert html =~ "Alice"
# Click Remove button
view view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
# Member should no longer be in list (no success flash message) refute has_element?(view, "[data-testid=group-show-members-table]", "Alice")
html = render(view)
refute html =~ "Alice"
end end
test "member is successfully removed from group (verified in list)", %{conn: conn} do test "member is successfully removed from group (verified in list)", %{conn: conn} do
@ -64,20 +60,15 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
actor: system_actor actor: system_actor
) )
{:ok, view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Member should be in list initially assert has_element?(view, "[data-testid=group-show-members-table]", "Bob")
assert html =~ "Bob"
# Remove member
view view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
html = render(view) refute has_element?(view, "[data-testid=group-show-members-table]", "Bob")
# Member should no longer be in list (no success flash message)
refute html =~ "Bob"
end end
test "group member list updates automatically after remove", %{conn: conn} do test "group member list updates automatically after remove", %{conn: conn} do
@ -98,19 +89,15 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
actor: system_actor actor: system_actor
) )
{:ok, view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Member should be in list initially assert has_element?(view, "[data-testid=group-show-members-table]", "Charlie")
assert html =~ "Charlie"
# Remove member
view view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
# Member should no longer be in list refute has_element?(view, "[data-testid=group-show-members-table]", "Charlie")
html = render(view)
refute html =~ "Charlie"
end end
test "member count updates automatically after remove", %{conn: conn} do test "member count updates automatically after remove", %{conn: conn} do
@ -158,7 +145,7 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
# Extract first member ID from the rendered HTML or use a different approach # Extract first member ID from the rendered HTML or use a different approach
# Since we have member1 and member2, we can target member1 specifically # Since we have member1 and member2, we can target member1 specifically
view view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member1.id}']") |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member1.id}']")
|> render_click() |> render_click()
# Count should have decreased # Count should have decreased
@ -187,17 +174,11 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Click Remove - should remove immediately without confirmation
view view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
# No confirmation dialog should appear (immediate removal) refute has_element?(view, "[data-testid=group-show-members-table]", "Frank")
# This is verified by the member being removed without any dialog
# Member should be removed
html = render(view)
refute html =~ "Frank"
end end
end end
@ -220,23 +201,17 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
actor: system_actor actor: system_actor
) )
{:ok, view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Member should be in list assert has_element?(view, "[data-testid=group-show-members-table]", "Grace")
assert html =~ "Grace"
# Remove last member
view view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
# Group should show empty state assert has_element?(view, "[data-testid=group-show-no-members]")
html = render(view) html = render(view)
assert html =~ gettext("No members in this group") ||
html =~ ~r/no.*members/i
# Count should be 0
count = extract_member_count(html) count = extract_member_count(html)
assert count == 0 assert count == 0
end end
@ -269,18 +244,14 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
{:ok, view, _html} = live(conn, "/groups/#{group1.slug}") {:ok, view, _html} = live(conn, "/groups/#{group1.slug}")
# Remove from group1
view view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
# Member should be removed from group1 refute has_element?(view, "[data-testid=group-show-members-table]", "Henry")
html = render(view)
refute html =~ "Henry"
# Verify member is still in group2 {:ok, view2, _html2} = live(conn, "/groups/#{group2.slug}")
{:ok, _view2, html2} = live(conn, "/groups/#{group2.slug}") assert has_element?(view2, "[data-testid=group-show-members-table]", "Henry")
assert html2 =~ "Henry"
end end
test "remove is idempotent (no error if member already removed)", %{conn: conn} do test "remove is idempotent (no error if member already removed)", %{conn: conn} do
@ -303,22 +274,15 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Remove member first time
view view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
# Try to remove again (should not error, just be idempotent) if has_element?(view, "[data-testid=group-show-members-table]", "Isabel") do
# Note: Implementation should handle this gracefully
# If button is still visible somehow, try to click again
html = render(view)
if html =~ "Isabel" do
view view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") |> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
# Should not crash
assert render(view) assert render(view)
end end
end end

View file

@ -22,34 +22,33 @@ defmodule MvWeb.GroupLive.ShowTest do
test "page renders successfully", %{conn: conn} do test "page renders successfully", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert html =~ group.name assert has_element?(view, "h1", group.name)
end end
test "displays group name", %{conn: conn} do test "displays group name", %{conn: conn} do
group = Fixtures.group_fixture(%{name: "Test Group Name"}) group = Fixtures.group_fixture(%{name: "Test Group Name"})
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert html =~ "Test Group Name" assert has_element?(view, "h1", "Test Group Name")
end end
test "displays group description when present", %{conn: conn} do test "displays group description when present", %{conn: conn} do
group = Fixtures.group_fixture(%{description: "This is a test description"}) group = Fixtures.group_fixture(%{description: "This is a test description"})
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert html =~ "This is a test description" assert has_element?(view, "p", "This is a test description")
end end
test "displays member count", %{conn: conn} do test "displays member count", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Member count should be displayed (might be 0 or more) assert has_element?(view, "[data-testid=group-show-member-count]")
assert html =~ "0" or html =~ gettext("Members") or html =~ "member" or html =~ "Mitglied"
end end
test "displays list of members in group", %{conn: conn} do test "displays list of members in group", %{conn: conn} do
@ -67,26 +66,26 @@ defmodule MvWeb.GroupLive.ShowTest do
actor: system_actor actor: system_actor
) )
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert html =~ "Alice" or html =~ "Smith" assert has_element?(view, "[data-testid=group-show-members-table]", "Alice")
assert html =~ "Bob" or html =~ "Jones" assert has_element?(view, "[data-testid=group-show-members-table]", "Bob")
end end
test "displays edit button for admin users", %{conn: conn} do test "displays edit button for admin users", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert html =~ gettext("Edit") or html =~ "edit" or html =~ "Bearbeiten" assert has_element?(view, "[data-testid=group-show-edit-btn]")
end end
test "displays delete button for admin users", %{conn: conn} do test "displays delete button for admin users", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert html =~ gettext("Delete") or html =~ "delete" or html =~ "Löschen" assert has_element?(view, "[data-testid=group-show-delete-btn]")
end end
end end
@ -94,19 +93,17 @@ defmodule MvWeb.GroupLive.ShowTest do
test "route /groups/:slug works correctly", %{conn: conn} do test "route /groups/:slug works correctly", %{conn: conn} do
group = Fixtures.group_fixture(%{name: "Board Members"}) group = Fixtures.group_fixture(%{name: "Board Members"})
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert html =~ "Board Members" assert has_element?(view, "h1", "Board Members")
# Verify slug is in URL
assert html =~ group.slug or html =~ "board-members"
end end
test "group is found by slug via unique_slug identity", %{conn: conn} do test "group is found by slug via unique_slug identity", %{conn: conn} do
group = Fixtures.group_fixture(%{name: "Test Group"}) group = Fixtures.group_fixture(%{name: "Test Group"})
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert html =~ group.name assert has_element?(view, "h1", group.name)
end end
test "non-existent slug returns 404", %{conn: conn} do test "non-existent slug returns 404", %{conn: conn} do
@ -145,28 +142,26 @@ defmodule MvWeb.GroupLive.ShowTest do
test "displays empty group correctly (0 members)", %{conn: conn} do test "displays empty group correctly (0 members)", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert html =~ "0" or html =~ gettext("No members") or html =~ "empty" or assert has_element?(view, "[data-testid=group-show-no-members]")
html =~ "Keine Mitglieder"
end end
test "handles group without description correctly", %{conn: conn} do test "handles group without description correctly", %{conn: conn} do
group = Fixtures.group_fixture(%{description: nil}) group = Fixtures.group_fixture(%{description: nil})
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Should not crash, description should be optional assert has_element?(view, "h1", group.name)
assert html =~ group.name
end end
test "handles slug with special characters correctly", %{conn: conn} do test "handles slug with special characters correctly", %{conn: conn} do
# Create group with name that generates slug with hyphens # Create group with name that generates slug with hyphens
group = Fixtures.group_fixture(%{name: "Test-Group-Name"}) group = Fixtures.group_fixture(%{name: "Test-Group-Name"})
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert html =~ "Test-Group-Name" or html =~ group.name assert has_element?(view, "h1", group.name)
end end
end end
@ -177,11 +172,11 @@ defmodule MvWeb.GroupLive.ShowTest do
read_only_user = Fixtures.user_with_role_fixture("read_only") read_only_user = Fixtures.user_with_role_fixture("read_only")
conn = conn_with_password_user(conn, read_only_user) conn = conn_with_password_user(conn, read_only_user)
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert html =~ group.name assert has_element?(view, "h1", group.name)
# Should NOT see edit/delete buttons refute has_element?(view, "[data-testid=group-show-edit-btn]")
refute html =~ gettext("Edit") or html =~ gettext("Delete") refute has_element?(view, "[data-testid=group-show-delete-btn]")
end end
@tag role: :unauthenticated @tag role: :unauthenticated
@ -246,14 +241,14 @@ defmodule MvWeb.GroupLive.ShowTest do
handler_id = "test-query-counter-#{System.unique_integer([:positive])}" handler_id = "test-query-counter-#{System.unique_integer([:positive])}"
:telemetry.attach(handler_id, [:ash, :query, :start], handler, nil) :telemetry.attach(handler_id, [:ash, :query, :start], handler, nil)
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
final_count = Agent.get(query_count_agent, & &1) final_count = Agent.get(query_count_agent, & &1)
:telemetry.detach(handler_id) :telemetry.detach(handler_id)
# All members should be displayed
Enum.each(members, fn member -> Enum.each(members, fn member ->
assert html =~ member.first_name or html =~ member.last_name assert has_element?(view, "[data-testid=group-show-members-table]", member.first_name) or
has_element?(view, "[data-testid=group-show-members-table]", member.last_name)
end) end)
# Verify query count is reasonable (should avoid N+1 queries) # Verify query count is reasonable (should avoid N+1 queries)
@ -267,10 +262,9 @@ defmodule MvWeb.GroupLive.ShowTest do
test "slug lookup is efficient (uses unique_slug index)", %{conn: conn} do test "slug lookup is efficient (uses unique_slug index)", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
# Should use index for fast lookup {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert html =~ group.name assert has_element?(view, "h1", group.name)
end end
end end

View file

@ -5,8 +5,8 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do
Tests cover: Tests cover:
- Rendering all member fields from Mv.Constants.member_fields() - Rendering all member fields from Mv.Constants.member_fields()
- Displaying show_in_overview status as badge (Yes/No) - Displaying show_in_overview status as badge (Yes/No)
- Displaying required status for required fields (first_name, last_name, email) - Displaying required status from settings.member_field_required (email is always required)
- Current status is displayed based on settings.member_field_visibility - Current status is displayed based on settings.member_field_visibility and member_field_required
- Default status is "Yes" (visible) when not configured in settings - Default status is "Yes" (visible) when not configured in settings
""" """
use MvWeb.ConnCase, async: false use MvWeb.ConnCase, async: false
@ -45,11 +45,10 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do
assert html =~ "badge" or html =~ "Yes" or html =~ "No" assert html =~ "badge" or html =~ "Yes" or html =~ "No"
end end
test "displays required status for required fields", %{conn: conn} do test "displays required status column", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings") {:ok, _view, html} = live(conn, ~p"/settings")
# Required fields: first_name, last_name, email # Should have "Required" column; email is always required
# Should have "Required" column or indicator
assert html =~ "Required" or html =~ "required" assert html =~ "Required" or html =~ "required"
end end
@ -85,40 +84,54 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do
end end
describe "required fields" do describe "required fields" do
test "marks first_name as required", %{conn: conn} do setup do
{:ok, _view, html} = live(conn, ~p"/settings") {:ok, settings} = Membership.get_settings()
saved_visibility = settings.member_field_visibility || %{}
saved_required = settings.member_field_required || %{}
# first_name should be marked as required on_exit(fn ->
assert html =~ "first_name" or html =~ "First name" {:ok, s} = Membership.get_settings()
# Should have required indicator
assert html =~ "required" or html =~ "Required" Membership.update_settings(s, %{
member_field_visibility: saved_visibility,
member_field_required: saved_required
})
end)
:ok
end end
test "marks last_name as required", %{conn: conn} do test "marks email as required (always from settings)", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings") {:ok, _view, html} = live(conn, ~p"/settings")
# last_name should be marked as required # Email is always required
assert html =~ "last_name" or html =~ "Last name"
# Should have required indicator
assert html =~ "required" or html =~ "Required"
end
test "marks email as required", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# email should be marked as required
assert html =~ "email" or html =~ "Email" assert html =~ "email" or html =~ "Email"
# Should have required indicator assert html =~ "Required" or html =~ "Optional"
assert html =~ "required" or html =~ "Required"
end end
test "does not mark optional fields as required", %{conn: conn} do test "when first_name is set required in settings, table shows Required", %{conn: conn} do
{:ok, settings} = Membership.get_settings()
{:ok, _} =
Membership.update_single_member_field(settings,
field: "first_name",
show_in_overview: true,
required: true
)
{:ok, _view, html} = live(conn, ~p"/settings") {:ok, _view, html} = live(conn, ~p"/settings")
# Optional fields should not have required indicator # First name row should show Required (and Optional for others)
# Check that street (optional) doesn't have required badge assert html =~ "First name" or html =~ "first_name"
# This test verifies that only required fields show the indicator assert html =~ "Required"
assert html =~ "street" or html =~ "Street" end
test "optional fields show Optional when not required in settings", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/settings")
# Email is required; other fields default to optional
assert html =~ "Optional"
assert html =~ "Required"
end end
end end
end end

View file

@ -9,6 +9,23 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
require Ash.Query require Ash.Query
describe "error handling - flash messages" do describe "error handling - flash messages" do
setup do
{:ok, settings} = Mv.Membership.get_settings()
saved_visibility = settings.member_field_visibility || %{}
saved_required = settings.member_field_required || %{}
on_exit(fn ->
{:ok, s} = Mv.Membership.get_settings()
Mv.Membership.update_settings(s, %{
member_field_visibility: saved_visibility,
member_field_required: saved_required
})
end)
:ok
end
@describetag :ui @describetag :ui
test "shows flash message when member creation fails with validation error", %{conn: conn} do test "shows flash message when member creation fails with validation error", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = Mv.Helpers.SystemActor.get_system_actor()
@ -74,6 +91,36 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
html =~ "Please correct" or html =~ "Bitte korrigieren" html =~ "Please correct" or html =~ "Bitte korrigieren"
end end
@tag :ui
test "shows validation error when settings-required field is missing", %{conn: conn} do
{:ok, settings} = Mv.Membership.get_settings()
{:ok, _} =
Mv.Membership.update_single_member_field(settings,
field: "first_name",
show_in_overview: true,
required: true
)
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members/new")
# Submit without first_name (required in settings)
form_data = %{
"member[last_name]" => "User",
"member[email]" => "newuser#{System.unique_integer([:positive])}@example.com"
}
html =
view
|> form("#member-form", form_data)
|> render_submit()
assert html =~ "error" or html =~ "Error" or html =~ "Fehler" or
html =~ "first_name" or html =~ "First name" or html =~ "can't be blank" or
html =~ "darf nicht leer sein"
end
test "shows flash message when member update fails", %{conn: conn} do test "shows flash message when member update fails", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = Mv.Helpers.SystemActor.get_system_actor()

View file

@ -13,7 +13,7 @@ defmodule MvWeb.GroupLiveHelpers do
""" """
def open_add_member(view) do def open_add_member(view) do
view view
|> element("button", "Add Member") |> element("button[phx-click='show_add_member_input']")
|> render_click() |> render_click()
end end
@ -22,7 +22,7 @@ defmodule MvWeb.GroupLiveHelpers do
""" """
def search_member(view, query) do def search_member(view, query) do
view view
|> element("#member-search-input") |> element("[data-testid=group-show-member-search-input]")
|> render_focus() |> render_focus()
view view
@ -44,7 +44,7 @@ defmodule MvWeb.GroupLiveHelpers do
""" """
def add_selected(view) do def add_selected(view) do
view view
|> element("button[phx-click='add_selected_members']") |> element("[data-testid=group-show-add-selected-members-btn]")
|> render_click() |> render_click()
end end

View file

@ -1,3 +1,9 @@
# Ensure tests never hit the real Vereinfacht API (e.g. when .env is loaded by just).
# Tests that need "configured" sync set a fake URL (127.0.0.1:1) in their own setup.
System.delete_env("VEREINFACHT_API_URL")
System.delete_env("VEREINFACHT_API_KEY")
System.delete_env("VEREINFACHT_CLUB_ID")
ExUnit.start( ExUnit.start(
# shows 10 slowest tests at the end of the test run # shows 10 slowest tests at the end of the test run
# slowest: 10 # slowest: 10