- Add interval immutability and deletion prevention validations - Add settings validation for default_membership_fee_type_id - Create MembershipFeeSettingsLive for admin UI with form handling - Add comprehensive test coverage (unit, integration, settings)
284 lines
9.5 KiB
Elixir
284 lines
9.5 KiB
Elixir
defmodule MvWeb.MembershipFeeSettingsLive do
|
|
@moduledoc """
|
|
LiveView for managing membership fee settings (Admin).
|
|
|
|
Allows administrators to configure:
|
|
- Default membership fee type for new members
|
|
- Whether to include the joining cycle in membership fee generation
|
|
"""
|
|
use MvWeb, :live_view
|
|
|
|
alias Mv.Membership
|
|
alias Mv.MembershipFees.MembershipFeeType
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
{:ok, settings} = Membership.get_settings()
|
|
|
|
membership_fee_types =
|
|
MembershipFeeType
|
|
|> Ash.Query.sort(name: :asc)
|
|
|> Ash.read!()
|
|
|
|
{:ok,
|
|
socket
|
|
|> assign(:page_title, gettext("Membership Fee Settings"))
|
|
|> assign(:settings, settings)
|
|
|> assign(:membership_fee_types, membership_fee_types)
|
|
|> assign(:selected_fee_type_id, settings.default_membership_fee_type_id)
|
|
|> assign(:include_joining_cycle, settings.include_joining_cycle)
|
|
|> assign(:changeset, to_form(%{}, as: :settings))}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("validate", %{"settings" => params}, socket) do
|
|
changeset =
|
|
%{}
|
|
|> validate_settings(params)
|
|
|> to_form(as: :settings)
|
|
|
|
{:noreply, assign(socket, changeset: changeset)}
|
|
end
|
|
|
|
def handle_event("save", %{"settings" => params}, socket) do
|
|
case update_settings(socket.assigns.settings, params) do
|
|
{:ok, updated_settings} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, gettext("Settings saved successfully."))
|
|
|> assign(:settings, updated_settings)
|
|
|> assign(:selected_fee_type_id, updated_settings.default_membership_fee_type_id)
|
|
|> assign(:include_joining_cycle, updated_settings.include_joining_cycle)
|
|
|> assign(:changeset, to_form(%{}, as: :settings))}
|
|
|
|
{:error, changeset} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:error, gettext("Failed to save settings. Please check the errors below."))
|
|
|> assign(:changeset, to_form(changeset, as: :settings))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
|
<.header>
|
|
{gettext("Membership Fee Settings")}
|
|
<:subtitle>
|
|
{gettext("Configure global settings for membership fees.")}
|
|
</:subtitle>
|
|
</.header>
|
|
|
|
<div class="grid gap-6 lg:grid-cols-2">
|
|
<%!-- Settings Form --%>
|
|
<div class="card bg-base-100 shadow-xl">
|
|
<div class="card-body">
|
|
<h2 class="card-title">
|
|
<.icon name="hero-cog-6-tooth" class="size-5" />
|
|
{gettext("Global Settings")}
|
|
</h2>
|
|
|
|
<.form
|
|
for={@changeset}
|
|
phx-change="validate"
|
|
phx-submit="save"
|
|
class="space-y-6"
|
|
>
|
|
<%!-- Default Membership Fee Type --%>
|
|
<fieldset class="fieldset">
|
|
<label class="label">
|
|
<span class="label-text font-semibold">
|
|
{gettext("Default Membership Fee Type")}
|
|
</span>
|
|
</label>
|
|
<select
|
|
name="settings[default_membership_fee_type_id]"
|
|
class="select select-bordered w-full"
|
|
phx-debounce="blur"
|
|
>
|
|
<option value="">{gettext("None (no default)")}</option>
|
|
<option
|
|
:for={fee_type <- @membership_fee_types}
|
|
value={fee_type.id}
|
|
selected={fee_type.id == @selected_fee_type_id}
|
|
>
|
|
{fee_type.name} ({format_currency(fee_type.amount)}, {format_interval(fee_type.interval)})
|
|
</option>
|
|
</select>
|
|
<p class="text-sm text-base-content/60 mt-2">
|
|
{gettext(
|
|
"This membership fee type is automatically assigned to all new members. Can be changed individually per member."
|
|
)}
|
|
</p>
|
|
</fieldset>
|
|
|
|
<%!-- Include Joining Cycle --%>
|
|
<fieldset class="fieldset">
|
|
<label class="label cursor-pointer justify-start gap-3">
|
|
<input
|
|
type="checkbox"
|
|
name="settings[include_joining_cycle]"
|
|
class="checkbox checkbox-primary"
|
|
checked={@include_joining_cycle}
|
|
phx-debounce="blur"
|
|
/>
|
|
<span class="label-text font-semibold">
|
|
{gettext("Include joining cycle")}
|
|
</span>
|
|
</label>
|
|
<div class="ml-9 space-y-2">
|
|
<p class="text-sm text-base-content/60">
|
|
{gettext("When active: Members pay from the cycle of their joining.")}
|
|
</p>
|
|
<p class="text-sm text-base-content/60">
|
|
{gettext("When inactive: Members pay from the next full cycle after joining.")}
|
|
</p>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<div class="divider"></div>
|
|
|
|
<button type="submit" class="btn btn-primary w-full">
|
|
<.icon name="hero-check" class="size-5" />
|
|
{gettext("Save Settings")}
|
|
</button>
|
|
</.form>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Examples Card --%>
|
|
<div class="card bg-base-200">
|
|
<div class="card-body">
|
|
<h2 class="card-title">
|
|
<.icon name="hero-light-bulb" class="size-5" />
|
|
{gettext("Examples")}
|
|
</h2>
|
|
|
|
<.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")}
|
|
/>
|
|
|
|
<div class="divider"></div>
|
|
|
|
<.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")}
|
|
/>
|
|
|
|
<div class="divider"></div>
|
|
|
|
<.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")}
|
|
/>
|
|
|
|
<div class="divider"></div>
|
|
|
|
<.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")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Layouts.app>
|
|
"""
|
|
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"""
|
|
<div class="space-y-2">
|
|
<h3 class="font-semibold text-sm">{@title}</h3>
|
|
<div class="bg-base-300 rounded-lg p-3 text-sm space-y-1">
|
|
<p>
|
|
<span class="text-base-content/60">{gettext("Joining date")}:</span>
|
|
<span class="font-mono">{@joining_date}</span>
|
|
</p>
|
|
<p>
|
|
<span class="text-base-content/60">{gettext("Membership fee start")}:</span>
|
|
<span class="font-mono font-semibold text-primary">{@start_date}</span>
|
|
</p>
|
|
<p>
|
|
<span class="text-base-content/60">{gettext("Generated cycles")}:</span>
|
|
<span class="font-mono">
|
|
{Enum.join(@periods, ", ")}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
<p class="text-xs text-base-content/60 italic">→ {@note}</p>
|
|
</div>
|
|
"""
|
|
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 validate_settings(attrs, params) do
|
|
attrs
|
|
|> Map.merge(params)
|
|
|> validate_default_fee_type()
|
|
end
|
|
|
|
defp validate_default_fee_type(%{"default_membership_fee_type_id" => ""} = attrs) do
|
|
Map.put(attrs, "default_membership_fee_type_id", nil)
|
|
end
|
|
|
|
defp validate_default_fee_type(attrs), do: attrs
|
|
|
|
defp update_settings(settings, params) do
|
|
# Convert empty string to nil for optional field
|
|
params =
|
|
if params["default_membership_fee_type_id"] == "" do
|
|
Map.put(params, "default_membership_fee_type_id", nil)
|
|
else
|
|
params
|
|
end
|
|
|
|
# Convert checkbox value to boolean
|
|
params =
|
|
Map.update(params, "include_joining_cycle", false, fn
|
|
"true" -> true
|
|
"false" -> false
|
|
true -> true
|
|
false -> false
|
|
_ -> false
|
|
end)
|
|
|
|
settings
|
|
|> Ash.Changeset.for_update(:update_membership_fee_settings, params)
|
|
|> Ash.update()
|
|
end
|
|
end
|
|
|