defmodule MvWeb.MembershipFeeSettingsLive do @moduledoc """ LiveView for membership fee settings and fee types (Admin). Combines: - Global settings (default fee type, include joining cycle) - Membership fee types table (CRUD links to new/edit routes; delete inline) Examples and info are collapsible to save space. """ use MvWeb, :live_view require Ash.Query import MvWeb.LiveHelpers, only: [current_actor: 1] alias Mv.Membership alias Mv.Membership.Member alias Mv.MembershipFees alias Mv.MembershipFees.MembershipFeeType alias MvWeb.Helpers.MembershipFeeHelpers @impl true def mount(_params, _session, socket) do actor = current_actor(socket) {:ok, settings} = Membership.get_settings() membership_fee_types = MembershipFeeType |> Ash.Query.sort(name: :asc) |> Ash.read!(domain: Mv.MembershipFees, actor: actor) member_counts = load_member_counts(membership_fee_types, actor) {:ok, socket |> assign(:page_title, gettext("Membership Fee Settings")) |> assign(:settings, settings) |> assign(:membership_fee_types, membership_fee_types) |> assign(:member_counts, member_counts) |> assign_form()} end @impl true def handle_event("validate", %{"settings" => params}, socket) do # Normalize checkbox value: "on" -> true, missing -> false normalized_params = if Map.has_key?(params, "include_joining_cycle") do params |> Map.update("include_joining_cycle", false, fn "on" -> true "true" -> true true -> true _ -> false end) else Map.put(params, "include_joining_cycle", false) end {:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, normalized_params))} end def handle_event("save", %{"settings" => params}, socket) do # Normalize checkbox value: "on" -> true, missing -> false normalized_params = if Map.has_key?(params, "include_joining_cycle") do params |> Map.update("include_joining_cycle", false, fn "on" -> true "true" -> true true -> true _ -> false end) else Map.put(params, "include_joining_cycle", false) end actor = MvWeb.LiveHelpers.current_actor(socket) case MvWeb.LiveHelpers.submit_form(socket.assigns.form, normalized_params, actor) do {:ok, updated_settings} -> {:noreply, socket |> assign(:settings, updated_settings) |> put_flash(:success, gettext("Settings saved successfully.")) |> assign_form()} {:error, form} -> {:noreply, assign(socket, form: form)} end end @impl true def handle_event("delete", %{"id" => id}, socket) do actor = current_actor(socket) case Ash.get(MembershipFeeType, id, domain: MembershipFees, actor: actor) do {:ok, fee_type} -> case Ash.destroy(fee_type, domain: MembershipFees, actor: actor) do :ok -> updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id)) updated_counts = Map.delete(socket.assigns.member_counts, id) {:noreply, socket |> assign(:membership_fee_types, updated_types) |> assign(:member_counts, updated_counts) |> put_flash(:success, gettext("Membership fee type deleted"))} {:error, %Ash.Error.Forbidden{}} -> {:noreply, put_flash( socket, :error, gettext("You do not have permission to delete this membership fee type") )} {:error, error} -> {:noreply, put_flash(socket, :error, format_error(error))} end {:error, %Ash.Error.Query.NotFound{}} -> {:noreply, put_flash(socket, :error, gettext("Membership fee type not found"))} {:error, %Ash.Error.Forbidden{}} -> {:noreply, put_flash( socket, :error, gettext("You do not have permission to access this membership fee type") )} {:error, error} -> {:noreply, put_flash(socket, :error, format_error(error))} end end @impl true def render(assigns) do ~H""" <.header> {gettext("Membership Fee Settings")} <:subtitle> {gettext("Configure global settings and fee types for membership fees.")} <:actions> <.button variant="primary" navigate={~p"/membership_fee_settings/new_fee_type"}> <.icon name="hero-plus" /> {gettext("New Membership Fee Type")}
<%!-- Settings Form --%>

<.icon name="hero-cog-6-tooth" class="size-5" /> {gettext("Global Settings")}

<.form for={@form} phx-change="validate" phx-submit="save" class="space-y-6" > <%!-- Default Membership Fee Type --%>
<%= if @form.errors[:default_membership_fee_type_id] do %> <%= for error <- List.wrap(@form.errors[:default_membership_fee_type_id]) do %> <% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>

{msg}

<% end %> <% end %>

{gettext( "This membership fee type is automatically assigned to all new members. Can be changed individually per member." )}

<%!-- Include Joining Cycle --%>
<%= if @form.errors[:include_joining_cycle] do %> <%= for error <- List.wrap(@form.errors[:include_joining_cycle]) do %> <% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>

{msg}

<% end %> <% end %>

{gettext("When active: Members pay from the cycle of their joining.")}

{gettext("When inactive: Members pay from the next full cycle after joining.")}

<.button type="submit" variant="primary" class="w-full"> <.icon name="hero-check" class="size-5" /> {gettext("Save Settings")}
<%!-- Examples Card (collapsible) --%>
<.icon name="hero-chevron-right" class="size-5 transition group-open:rotate-90" /> <.icon name="hero-light-bulb" class="size-5" /> {gettext("Examples")}
<.example_section title={gettext("Yearly Interval - Joining Cycle Included")} joining_date="15.03.2023" include_joining={true} start_date="01.01.2023" periods={["2023", "2024", "2025"]} note={gettext("Member pays for the year they joined")} />
<.example_section title={gettext("Yearly Interval - Joining Cycle Excluded")} joining_date="15.03.2023" include_joining={false} start_date="01.01.2024" periods={["2024", "2025"]} note={gettext("Member pays from the next full year")} />
<.example_section title={gettext("Quarterly Interval - Joining Cycle Excluded")} joining_date="15.05.2024" include_joining={false} start_date="01.07.2024" periods={["Q3/2024", "Q4/2024", "Q1/2025"]} note={gettext("Member pays from the next full quarter")} />
<.example_section title={gettext("Monthly Interval - Joining Cycle Included")} joining_date="15.03.2024" include_joining={true} start_date="01.03.2024" periods={["03/2024", "04/2024", "05/2024", "..."]} note={gettext("Member pays from the joining month")} />
<%!-- Fee Types Table --%>

{gettext("Membership Fee Types")}

<.table id="membership_fee_types" rows={@membership_fee_types} row_id={fn mft -> "mft-#{mft.id}" end} > <:col :let={mft} label={gettext("Name")}> {mft.name}

{mft.description}

<:col :let={mft} label={gettext("Amount")}> {MembershipFeeHelpers.format_currency(mft.amount)} <:col :let={mft} label={gettext("Interval")}> {MembershipFeeHelpers.format_interval(mft.interval)} <:col :let={mft} label={gettext("Members")}> {get_member_count(mft, @member_counts)} <:action :let={mft}> <.tooltip content={gettext("Edit membership fee type")} position="left"> <.button variant="ghost" size="sm" navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"} aria-label={gettext("Edit membership fee type")} > <.icon name="hero-pencil" class="size-4" /> <:action :let={mft}> <.tooltip :if={get_member_count(mft, @member_counts) > 0} content={ gettext("Cannot delete - %{count} member(s) assigned", count: get_member_count(mft, @member_counts) ) } position="left" > <.button :if={get_member_count(mft, @member_counts) == 0} variant="danger" size="sm" phx-click="delete" phx-value-id={mft.id} data-confirm={gettext("Are you sure?")} aria-label={gettext("Delete Membership Fee Type")} > <.icon name="hero-trash" class="size-4" />
<.icon name="hero-information-circle" class="size-5" /> {gettext("About Membership Fee Types")}

{gettext( "Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation." )}

  • {gettext("Name & Amount")} - {gettext("Can be changed at any time. Amount changes affect future periods only.")}
  • {gettext("Interval")} - {gettext( "Fixed after creation. Members can only switch between types with the same interval." )}
  • {gettext("Deletion")} - {gettext("Only possible if no members are assigned to this type.")}
""" end # Example section component attr :title, :string, required: true attr :joining_date, :string, required: true attr :include_joining, :boolean, required: true attr :start_date, :string, required: true attr :periods, :list, required: true attr :note, :string, required: true defp example_section(assigns) do ~H"""

{@title}

{gettext("Joining date")}: {@joining_date}

{gettext("Membership fee start")}: {@start_date}

{gettext("Generated cycles")}: {Enum.join(@periods, ", ")}

→ {@note}

""" end defp format_currency(%Decimal{} = amount) do "#{Decimal.to_string(amount)} €" end defp format_interval(:monthly), do: gettext("Monthly") defp format_interval(:quarterly), do: gettext("Quarterly") defp format_interval(:half_yearly), do: gettext("Half-yearly") defp format_interval(:yearly), do: gettext("Yearly") defp load_member_counts(fee_types, actor) do fee_type_ids = Enum.map(fee_types, & &1.id) members = Member |> Ash.Query.filter(membership_fee_type_id in ^fee_type_ids) |> Ash.Query.select([:membership_fee_type_id]) |> Ash.read!(domain: Membership, actor: actor) members |> Enum.group_by(& &1.membership_fee_type_id) |> Enum.map(fn {fee_type_id, members_list} -> {fee_type_id, length(members_list)} end) |> Map.new() end defp get_member_count(fee_type, member_counts) do Map.get(member_counts, fee_type.id, 0) end defp format_error(%Ash.Error.Invalid{} = error) do Enum.map_join(error.errors, ", ", fn e -> e.message end) end defp format_error(error) when is_binary(error), do: error defp format_error(_error), do: gettext("An error occurred") defp assign_form(%{assigns: %{settings: settings}} = socket) do form = AshPhoenix.Form.for_update( settings, :update_membership_fee_settings, api: Membership, as: "settings", forms: [auto?: true] ) assign(socket, form: to_form(form)) end end