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:")}

<% end %>
""" end end