defmodule MvWeb.GlobalSettingsLive do
@moduledoc """
LiveView for managing global application settings (Vereinsdaten).
## Features
- Edit the association/club name
- Manage custom fields
- Real-time form validation
- Success/error feedback
## Settings
- `club_name` - The name of the association/club (required)
## Events
- `validate` - Real-time form validation
- `save` - Save settings changes
## Note
Settings is a singleton resource - there is only one settings record.
The club_name can also be set via the `ASSOCIATION_NAME` environment variable.
CSV member import has been moved to the Import/Export page (`/admin/import-export`).
"""
use MvWeb, :live_view
require Ash.Query
import Ash.Expr
alias Mv.Membership
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@impl true
def mount(_params, session, socket) do
{:ok, settings} = Membership.get_settings()
# Get locale from session for translations
locale = session["locale"] || "de"
Gettext.put_locale(MvWeb.Gettext, locale)
socket =
socket
|> assign(:page_title, gettext("Settings"))
|> assign(:settings, settings)
|> assign(:active_editing_section, nil)
|> assign(:locale, locale)
|> assign(:vereinfacht_env_configured, Mv.Config.vereinfacht_env_configured?())
|> assign(:last_vereinfacht_sync_result, nil)
|> assign_form()
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<.header>
{gettext("Settings")}
<:subtitle>
{gettext("Manage global settings for the association.")}
<%!-- Club Settings Section --%>
<.form_section title={gettext("Club Settings")}>
<.form for={@form} id="settings-form" phx-change="validate" phx-submit="save">
<.input
field={@form[:club_name]}
type="text"
label={gettext("Association Name")}
required
/>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Settings")}
<%!-- Vereinfacht Integration Section --%>
<.form_section title={gettext("Vereinfacht Integration")}>
<%= if @vereinfacht_env_configured do %>
{gettext(
"Configured via environment variables (VEREINFACHT_API_URL, VEREINFACHT_API_KEY, VEREINFACHT_CLUB_ID). Fields below are read-only."
)}
<% end %>
<.form for={@form} id="vereinfacht-form" phx-change="validate" phx-submit="save">
<.input
field={@form[:vereinfacht_api_url]}
type="text"
label={gettext("API URL")}
disabled={@vereinfacht_env_configured}
placeholder={
if(@vereinfacht_env_configured,
do: gettext("From VEREINFACHT_API_URL"),
else: "https://api.verein.visuel.dev/api/v1"
)
}
/>
<.input
field={@form[:vereinfacht_api_key]}
type="password"
label={gettext("API Key")}
disabled={@vereinfacht_env_configured}
placeholder={
if(@vereinfacht_env_configured, do: gettext("From VEREINFACHT_API_KEY"), else: nil)
}
/>
<.input
field={@form[:vereinfacht_club_id]}
type="text"
label={gettext("Club ID")}
disabled={@vereinfacht_env_configured}
placeholder={
if(@vereinfacht_env_configured, do: gettext("From VEREINFACHT_CLUB_ID"), else: "2")
}
/>
<.button
:if={not @vereinfacht_env_configured}
phx-disable-with={gettext("Saving...")}
variant="primary"
class="mt-2"
>
{gettext("Save Vereinfacht Settings")}
<.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")}
<%= if @last_vereinfacht_sync_result do %>
<.vereinfacht_sync_result result={@last_vereinfacht_sync_result} />
<% end %>
<%!-- Memberdata Section --%>
<.form_section title={gettext("Memberdata")}>
<.live_component
:if={@active_editing_section != :custom_fields}
module={MvWeb.MemberFieldLive.IndexComponent}
id="member-fields-component"
settings={@settings}
/>
<%!-- Custom Fields Section --%>
<.live_component
:if={@active_editing_section != :member_fields}
module={MvWeb.CustomFieldLive.IndexComponent}
id="custom-fields-component"
actor={@current_user}
/>
"""
end
@impl true
def handle_event("validate", %{"setting" => setting_params}, socket) do
{:noreply,
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
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
def handle_event("save", %{"setting" => setting_params}, socket) do
actor = MvWeb.LiveHelpers.current_actor(socket)
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params, actor) do
{:ok, _updated_settings} ->
# Reload settings from database to ensure all dependent data is updated
{:ok, fresh_settings} = Membership.get_settings()
socket =
socket
|> assign(:settings, fresh_settings)
|> put_flash(:info, gettext("Settings updated successfully"))
|> assign_form()
{:noreply, socket}
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
end
@impl true
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
send_update(MvWeb.CustomFieldLive.IndexComponent,
id: "custom-fields-component",
show_form: false
)
{:noreply,
socket
|> assign(:active_editing_section, nil)
|> put_flash(:info, gettext("Data field %{action} successfully", action: action))}
end
@impl true
def handle_info({:custom_field_deleted, _custom_field}, socket) do
{:noreply, put_flash(socket, :info, gettext("Data field deleted successfully"))}
end
@impl true
def handle_info({:custom_field_delete_error, error}, socket) do
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to delete data field: %{error}", error: inspect(error))
)}
end
@impl true
def handle_info(:custom_field_slug_mismatch, socket) do
{:noreply, put_flash(socket, :error, gettext("Slug does not match. Deletion cancelled."))}
end
def handle_info({:custom_fields_load_error, _error}, socket) do
{:noreply,
put_flash(
socket,
:error,
gettext("Could not load data fields. Please check your permissions.")
)}
end
@impl true
def handle_info({:editing_section_changed, section}, socket) do
{:noreply, assign(socket, :active_editing_section, section)}
end
@impl true
def handle_info({:member_field_saved, _member_field, action}, socket) do
# Reload settings to get updated member_field_visibility
{:ok, updated_settings} = Membership.get_settings()
# Send update to member fields component to close form
send_update(MvWeb.MemberFieldLive.IndexComponent,
id: "member-fields-component",
show_form: false,
settings: updated_settings
)
{:noreply,
socket
|> assign(:settings, updated_settings)
|> assign(:active_editing_section, nil)
|> put_flash(:info, gettext("Member field %{action} successfully", action: action))}
end
@impl true
def handle_info({:member_field_visibility_updated}, socket) do
# Legacy event - reload settings and update component
{:ok, updated_settings} = Membership.get_settings()
send_update(MvWeb.MemberFieldLive.IndexComponent,
id: "member-fields-component",
settings: updated_settings
)
{:noreply, assign(socket, :settings, updated_settings)}
end
defp assign_form(%{assigns: %{settings: settings}} = socket) do
form =
AshPhoenix.Form.for_update(
settings,
:update,
api: Membership,
as: "setting",
forms: [auto?: true]
)
assign(socket, form: to_form(form))
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"""
{gettext("Last sync result:")}
{gettext("%{count} synced", count: @result.synced)}
<%= if @result.errors != [] do %>
{gettext("%{count} failed", count: length(@result.errors))}
<% end %>
<%= if @result.errors != [] do %>
{gettext("Failed members:")}
<%= for err <- @result.errors do %>
-
{err.member_name}: {translate_vereinfacht_message(err)}
<% end %>
<% end %>
"""
end
end