501 lines
18 KiB
Elixir
501 lines
18 KiB
Elixir
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"""
|
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
|
<.header>
|
|
{gettext("Membership Fee Settings")}
|
|
<:subtitle>
|
|
{gettext("Configure global settings and fee types for membership fees.")}
|
|
</:subtitle>
|
|
<:actions>
|
|
<.button variant="primary" navigate={~p"/membership_fee_settings/new_fee_type"}>
|
|
<.icon name="hero-plus" /> {gettext("New Membership Fee Type")}
|
|
</.button>
|
|
</:actions>
|
|
</.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={@form}
|
|
phx-change="validate"
|
|
phx-submit="save"
|
|
class="space-y-6"
|
|
>
|
|
<%!-- Default Membership Fee Type --%>
|
|
<fieldset class="fieldset">
|
|
<label for="default_membership_fee_type_id" class="label">
|
|
<span class="label-text font-semibold">
|
|
{gettext("Default Membership Fee Type")}
|
|
</span>
|
|
</label>
|
|
<select
|
|
id="default_membership_fee_type_id"
|
|
name="settings[default_membership_fee_type_id]"
|
|
class={[
|
|
"select select-bordered w-full",
|
|
if(@form.errors[:default_membership_fee_type_id], do: "select-error", else: "")
|
|
]}
|
|
phx-debounce="blur"
|
|
aria-label={gettext("Default Membership Fee Type")}
|
|
>
|
|
<option value="">{gettext("None (no default)")}</option>
|
|
<option
|
|
:for={fee_type <- @membership_fee_types}
|
|
value={fee_type.id}
|
|
selected={fee_type.id == @form[:default_membership_fee_type_id].value}
|
|
>
|
|
{fee_type.name} ({format_currency(fee_type.amount)}, {format_interval(
|
|
fee_type.interval
|
|
)})
|
|
</option>
|
|
</select>
|
|
<%= 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, []} %>
|
|
<p class="text-error text-sm mt-1">{msg}</p>
|
|
<% end %>
|
|
<% end %>
|
|
<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={@form[:include_joining_cycle].value}
|
|
phx-debounce="blur"
|
|
/>
|
|
<span class="label-text font-semibold">
|
|
{gettext("Include joining cycle")}
|
|
</span>
|
|
</label>
|
|
<%= 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, []} %>
|
|
<p class="text-error text-sm ml-9 mt-1">{msg}</p>
|
|
<% end %>
|
|
<% end %>
|
|
<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" variant="primary" class="w-full">
|
|
<.icon name="hero-check" class="size-5" />
|
|
{gettext("Save Settings")}
|
|
</.button>
|
|
</.form>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Examples Card (collapsible) --%>
|
|
<div class="card bg-base-200">
|
|
<div class="card-body">
|
|
<details class="group">
|
|
<summary class="card-title cursor-pointer list-none flex items-center gap-2">
|
|
<.icon name="hero-chevron-right" class="size-5 transition group-open:rotate-90" />
|
|
<.icon name="hero-light-bulb" class="size-5" />
|
|
{gettext("Examples")}
|
|
</summary>
|
|
|
|
<div class="pt-4 space-y-4">
|
|
<.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>
|
|
</details>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Fee Types Table --%>
|
|
<div class="mt-8">
|
|
<h2 class="text-lg font-semibold mb-4">{gettext("Membership Fee Types")}</h2>
|
|
<.table
|
|
id="membership_fee_types"
|
|
rows={@membership_fee_types}
|
|
row_id={fn mft -> "mft-#{mft.id}" end}
|
|
>
|
|
<:col :let={mft} label={gettext("Name")}>
|
|
<span class="font-medium">{mft.name}</span>
|
|
<p :if={mft.description} class="text-sm text-base-content/70">{mft.description}</p>
|
|
</:col>
|
|
|
|
<:col :let={mft} label={gettext("Amount")}>
|
|
<span class="font-mono">{MembershipFeeHelpers.format_currency(mft.amount)}</span>
|
|
</:col>
|
|
|
|
<:col :let={mft} label={gettext("Interval")}>
|
|
<span class="badge badge-outline">
|
|
{MembershipFeeHelpers.format_interval(mft.interval)}
|
|
</span>
|
|
</:col>
|
|
|
|
<:col :let={mft} label={gettext("Members")}>
|
|
<span class="badge badge-ghost">{get_member_count(mft, @member_counts)}</span>
|
|
</:col>
|
|
|
|
<: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" />
|
|
</.button>
|
|
</.tooltip>
|
|
</:action>
|
|
|
|
<: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
|
|
phx-click="delete"
|
|
phx-value-id={mft.id}
|
|
data-confirm={gettext("Are you sure?")}
|
|
class="btn btn-ghost btn-xs text-error opacity-50 cursor-not-allowed"
|
|
aria-label={
|
|
gettext("Cannot delete - %{count} member(s) assigned",
|
|
count: get_member_count(mft, @member_counts)
|
|
)
|
|
}
|
|
disabled={true}
|
|
>
|
|
<.icon name="hero-trash" class="size-4" />
|
|
</button>
|
|
</.tooltip>
|
|
<.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" />
|
|
</.button>
|
|
</:action>
|
|
</.table>
|
|
|
|
<details class="mt-6 card bg-base-200">
|
|
<summary class="card-body cursor-pointer list-none card-title">
|
|
<.icon name="hero-information-circle" class="size-5" />
|
|
{gettext("About Membership Fee Types")}
|
|
</summary>
|
|
<div class="card-body pt-0 prose prose-sm max-w-none">
|
|
<p>
|
|
{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."
|
|
)}
|
|
</p>
|
|
<ul>
|
|
<li>
|
|
<strong>{gettext("Name & Amount")}</strong>
|
|
- {gettext("Can be changed at any time. Amount changes affect future periods only.")}
|
|
</li>
|
|
<li>
|
|
<strong>{gettext("Interval")}</strong>
|
|
- {gettext(
|
|
"Fixed after creation. Members can only switch between types with the same interval."
|
|
)}
|
|
</li>
|
|
<li>
|
|
<strong>{gettext("Deletion")}</strong>
|
|
- {gettext("Only possible if no members are assigned to this type.")}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</details>
|
|
</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/80">{gettext("Joining date")}:</span>
|
|
<span class="font-mono">{@joining_date}</span>
|
|
</p>
|
|
<p>
|
|
<span class="text-base-content/80">{gettext("Membership fee start")}:</span>
|
|
<span class="font-mono font-semibold text-base-content">{@start_date}</span>
|
|
</p>
|
|
<p>
|
|
<span class="text-base-content/80">{gettext("Generated cycles")}:</span>
|
|
<span class="font-mono">
|
|
{Enum.join(@periods, ", ")}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
<p class="text-xs text-base-content/80 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 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
|