68 changed files with 4858 additions and 743 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
```
|
```
|
||||||
|
|
|
||||||
14
Dockerfile
14
Dockerfile
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 member–user link unless admin, then check permissions
|
# CREATE/UPDATE: Forbid member–user 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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
179
lib/membership/setting/changes/update_single_member_field.ex
Normal file
179
lib/membership/setting/changes/update_single_member_field.ex
Normal 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
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
15
lib/mv/authorization/checks/actor_is_system_user.ex
Normal file
15
lib/mv/authorization/checks/actor_is_system_user.ex
Normal 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
|
||||||
156
lib/mv/config.ex
156
lib/mv/config.ex
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")),
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
91
lib/mv/vereinfacht/changes/sync_contact.ex
Normal file
91
lib/mv/vereinfacht/changes/sync_contact.ex
Normal 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
|
||||||
|
|
@ -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
|
||||||
364
lib/mv/vereinfacht/client.ex
Normal file
364
lib/mv/vereinfacht/client.ex
Normal 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
|
||||||
46
lib/mv/vereinfacht/sync_flash.ex
Normal file
46
lib/mv/vereinfacht/sync_flash.ex
Normal 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
|
||||||
165
lib/mv/vereinfacht/vereinfacht.ex
Normal file
165
lib/mv/vereinfacht/vereinfacht.ex
Normal 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
|
||||||
|
|
@ -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"""
|
||||||
|
|
|
||||||
|
|
@ -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("_", " ")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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} ->
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
234
priv/resource_snapshots/repo/members/20260218185510.json
Normal file
234
priv/resource_snapshots/repo/members/20260218185510.json
Normal 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"
|
||||||
|
}
|
||||||
140
priv/resource_snapshots/repo/settings/20260218185541.json
Normal file
140
priv/resource_snapshots/repo/settings/20260218185541.json
Normal 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"
|
||||||
|
}
|
||||||
152
priv/resource_snapshots/repo/settings/20260223195453.json
Normal file
152
priv/resource_snapshots/repo/settings/20260223195453.json
Normal 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"
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
83
test/mv/config_vereinfacht_test.exs
Normal file
83
test/mv/config_vereinfacht_test.exs
Normal 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
|
||||||
102
test/mv/vereinfacht/changes/sync_contact_test.exs
Normal file
102
test/mv/vereinfacht/changes/sync_contact_test.exs
Normal 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
|
||||||
50
test/mv/vereinfacht/client_test.exs
Normal file
50
test/mv/vereinfacht/client_test.exs
Normal 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
|
||||||
59
test/mv/vereinfacht/vereinfacht_test.exs
Normal file
59
test/mv/vereinfacht/vereinfacht_test.exs
Normal 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
|
||||||
29
test/mv/vereinfacht/vereinfacht_test_README.md
Normal file
29
test/mv/vereinfacht/vereinfacht_test_README.md
Normal 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 it’s 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.
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue