diff --git a/lib/mv_web/live/membership_fee_type_live/form.ex b/lib/mv_web/live/membership_fee_type_live/form.ex
new file mode 100644
index 0000000..06d0dde
--- /dev/null
+++ b/lib/mv_web/live/membership_fee_type_live/form.ex
@@ -0,0 +1,331 @@
+defmodule MvWeb.MembershipFeeTypeLive.Form do
+ @moduledoc """
+ LiveView form for creating and editing membership fee types (Admin).
+
+ ## Features
+ - Create new membership fee types
+ - Edit existing membership fee types (name, amount, description - NOT interval)
+ - Amount change warning modal (shows impact on members)
+ - Interval field grayed out on edit
+
+ ## Permissions
+ - Admin only
+ """
+ use MvWeb, :live_view
+
+ require Ash.Query
+
+ alias Mv.MembershipFees
+ alias Mv.MembershipFees.MembershipFeeType
+ alias Mv.Membership.Member
+ alias MvWeb.Helpers.MembershipFeeHelpers
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ <.header>
+ {@page_title}
+ <:subtitle>
+ {gettext("Use this form to manage membership fee types in your database.")}
+
+
+
+ <.form
+ class="max-w-xl"
+ for={@form}
+ id="membership-fee-type-form"
+ phx-change="validate"
+ phx-submit="save"
+ >
+ <.input field={@form[:name]} type="text" label={gettext("Name")} required />
+
+ <.input
+ field={@form[:amount]}
+ type="number"
+ label={gettext("Amount")}
+ step="0.01"
+ min="0"
+ required
+ />
+
+
+
+ {gettext("Interval")}
+
+
+ {gettext("Select interval")}
+
+ {gettext("Monthly")}
+
+
+ {gettext("Quarterly")}
+
+
+ {gettext("Half-yearly")}
+
+
+ {gettext("Yearly")}
+
+
+ <%= if !is_nil(@membership_fee_type) do %>
+
+
+ {gettext("Interval cannot be changed after creation.")}
+
+
+ <% end %>
+
+
+ <.input
+ field={@form[:description]}
+ type="textarea"
+ label={gettext("Description")}
+ rows="3"
+ />
+
+
+ <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
+ {gettext("Save Membership Fee Type")}
+
+ <.button navigate={return_path(@return_to, @membership_fee_type)} type="button">
+ {gettext("Cancel")}
+
+
+
+
+ <%!-- Amount Change Warning Modal --%>
+ <%= if @show_amount_warning do %>
+
+
+
{gettext("Change Amount?")}
+
+
+ <.icon name="hero-exclamation-triangle" class="size-5" />
+
+
+ {gettext("Changing the amount will affect %{count} member(s).",
+ count: @affected_member_count
+ )}
+
+
+ {gettext("Future unpaid cycles will be regenerated with the new amount.")}
+
+
+ {gettext("Already paid cycles will remain with the old amount.")}
+
+
+
+
+
+
+ {gettext("Current amount")}:
+
+ {MembershipFeeHelpers.format_currency(@old_amount)}
+
+
+
+ {gettext("New amount")}:
+
+ {MembershipFeeHelpers.format_currency(@new_amount)}
+
+
+
+
+
+
+
+ {gettext("Cancel")}
+
+
+ {gettext("Confirm Change")}
+
+
+
+
+ <% end %>
+
+ """
+ end
+
+ @impl true
+ def mount(params, _session, socket) do
+ membership_fee_type =
+ case params["id"] do
+ nil -> nil
+ id -> Ash.get!(MembershipFeeType, id, domain: MembershipFees)
+ end
+
+ page_title =
+ if is_nil(membership_fee_type),
+ do: gettext("New Membership Fee Type"),
+ else: gettext("Edit Membership Fee Type")
+
+ {:ok,
+ socket
+ |> assign(:return_to, return_to(params["return_to"]))
+ |> assign(:membership_fee_type, membership_fee_type)
+ |> assign(:page_title, page_title)
+ |> assign(:show_amount_warning, false)
+ |> assign(:old_amount, nil)
+ |> assign(:new_amount, nil)
+ |> assign(:affected_member_count, 0)
+ |> assign(:pending_amount, nil)
+ |> assign_form()}
+ end
+
+ defp return_to("index"), do: "index"
+ defp return_to(_), do: "index"
+
+ @impl true
+ def handle_event("validate", %{"membership_fee_type" => params}, socket) do
+ validated_form = AshPhoenix.Form.validate(socket.assigns.form, params)
+
+ # Check if amount changed on edit
+ socket =
+ if socket.assigns.membership_fee_type && Map.has_key?(params, "amount") do
+ new_amount_str = params["amount"]
+ old_amount = socket.assigns.membership_fee_type.amount
+
+ case Decimal.parse(new_amount_str) do
+ {new_amount, _} when is_struct(new_amount, Decimal) ->
+ if Decimal.compare(new_amount, old_amount) != :eq do
+ # Amount changed - show warning
+ affected_count = get_affected_member_count(socket.assigns.membership_fee_type.id)
+
+ socket
+ |> assign(:show_amount_warning, true)
+ |> assign(:old_amount, old_amount)
+ |> assign(:new_amount, new_amount)
+ |> assign(:affected_member_count, affected_count)
+ |> assign(:pending_amount, new_amount_str)
+ else
+ # Amount unchanged - hide warning
+ socket
+ |> assign(:show_amount_warning, false)
+ |> assign(:pending_amount, nil)
+ end
+
+ :error ->
+ socket
+ end
+ else
+ socket
+ end
+
+ {:noreply, assign(socket, form: validated_form)}
+ end
+
+ def handle_event("cancel_amount_change", _params, socket) do
+ # Reset form to original amount
+ form = socket.assigns.form
+
+ original_amount =
+ if socket.assigns.membership_fee_type do
+ socket.assigns.membership_fee_type.amount
+ else
+ Decimal.new("0")
+ end
+
+ # Update form with original amount
+ updated_form =
+ AshPhoenix.Form.validate(form, %{
+ "amount" => Decimal.to_string(original_amount)
+ })
+
+ {:noreply,
+ socket
+ |> assign(:form, updated_form)
+ |> assign(:show_amount_warning, false)
+ |> assign(:pending_amount, nil)}
+ end
+
+ def handle_event("confirm_amount_change", _params, socket) do
+ # Update form with pending amount and hide warning
+ form = socket.assigns.form
+
+ updated_form =
+ if socket.assigns.pending_amount do
+ AshPhoenix.Form.validate(form, %{"amount" => socket.assigns.pending_amount})
+ else
+ form
+ end
+
+ {:noreply,
+ socket
+ |> assign(:form, updated_form)
+ |> assign(:show_amount_warning, false)
+ |> assign(:pending_amount, nil)}
+ end
+
+ def handle_event("save", %{"membership_fee_type" => params}, socket) do
+ # If amount warning was shown but not confirmed, don't save
+ if socket.assigns.show_amount_warning do
+ {:noreply, put_flash(socket, :error, gettext("Please confirm the amount change first"))}
+ else
+ case AshPhoenix.Form.submit(socket.assigns.form, params: params) do
+ {:ok, membership_fee_type} ->
+ notify_parent({:saved, membership_fee_type})
+
+ socket =
+ socket
+ |> put_flash(:info, gettext("Membership fee type saved successfully"))
+ |> push_navigate(to: return_path(socket.assigns.return_to, membership_fee_type))
+
+ {:noreply, socket}
+
+ {:error, form} ->
+ {:noreply, assign(socket, form: form)}
+ end
+ end
+ end
+
+ @spec notify_parent(any()) :: any()
+ defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
+
+ @spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
+ defp assign_form(%{assigns: %{membership_fee_type: membership_fee_type}} = socket) do
+ form =
+ if membership_fee_type do
+ AshPhoenix.Form.for_update(
+ membership_fee_type,
+ :update,
+ domain: MembershipFees,
+ as: "membership_fee_type"
+ )
+ else
+ AshPhoenix.Form.for_create(
+ MembershipFeeType,
+ :create,
+ domain: MembershipFees,
+ as: "membership_fee_type"
+ )
+ end
+
+ assign(socket, form: to_form(form))
+ end
+
+ @spec return_path(String.t(), MembershipFeeType.t() | nil) :: String.t()
+ defp return_path("index", _membership_fee_type), do: ~p"/membership_fee_types"
+
+ @spec get_affected_member_count(String.t()) :: non_neg_integer()
+ defp get_affected_member_count(fee_type_id) do
+ case Ash.count(Member |> Ash.Query.filter(membership_fee_type_id == ^fee_type_id)) do
+ {:ok, count} -> count
+ _ -> 0
+ end
+ end
+end
diff --git a/lib/mv_web/live/membership_fee_type_live/index.ex b/lib/mv_web/live/membership_fee_type_live/index.ex
new file mode 100644
index 0000000..609f91f
--- /dev/null
+++ b/lib/mv_web/live/membership_fee_type_live/index.ex
@@ -0,0 +1,185 @@
+defmodule MvWeb.MembershipFeeTypeLive.Index do
+ @moduledoc """
+ LiveView for managing membership fee types (Admin).
+
+ ## Features
+ - List all membership fee types
+ - Display: Name, Amount, Interval, Member count
+ - Create new membership fee types
+ - Edit existing membership fee types (name, amount, description - NOT interval)
+ - Delete membership fee types (if no members assigned)
+
+ ## Permissions
+ - Admin only
+ """
+ use MvWeb, :live_view
+
+ require Ash.Query
+
+ alias Mv.MembershipFees
+ alias Mv.MembershipFees.MembershipFeeType
+ alias Mv.Membership.Member
+ alias MvWeb.Helpers.MembershipFeeHelpers
+
+ @impl true
+ def mount(_params, _session, socket) do
+ {:ok,
+ socket
+ |> assign(:page_title, gettext("Membership Fee Types"))
+ |> assign(:membership_fee_types, load_membership_fee_types())}
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ <.header>
+ {gettext("Membership Fee Types")}
+ <:subtitle>
+ {gettext("Manage membership fee types for membership fees.")}
+
+ <:actions>
+ <.button variant="primary" navigate={~p"/membership_fee_types/new"}>
+ <.icon name="hero-plus" /> {gettext("New Membership Fee Type")}
+
+
+
+
+ <.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)}
+
+
+ <:action :let={mft}>
+ <.link navigate={~p"/membership_fee_types/#{mft.id}/edit"} class="btn btn-ghost btn-xs">
+ <.icon name="hero-pencil" class="size-4" />
+
+
+
+ <:action :let={mft}>
+ 0,
+ do: "text-error opacity-50 cursor-not-allowed",
+ else: "text-error"
+ )
+ ]}
+ title={
+ if get_member_count(mft) > 0,
+ do:
+ gettext("Cannot delete - %{count} member(s) assigned", count: get_member_count(mft)),
+ else: gettext("Delete")
+ }
+ disabled={get_member_count(mft) > 0}
+ >
+ <.icon name="hero-trash" class="size-4" />
+
+
+
+
+ <.info_card />
+
+ """
+ end
+
+ @impl true
+ def handle_event("delete", %{"id" => id}, socket) do
+ fee_type = Ash.get!(MembershipFeeType, id)
+
+ case Ash.destroy(fee_type, domain: MembershipFees) do
+ :ok ->
+ updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id))
+
+ {:noreply,
+ socket
+ |> assign(:membership_fee_types, updated_types)
+ |> put_flash(:info, gettext("Membership fee type deleted"))}
+
+ {:error, error} ->
+ {:noreply, put_flash(socket, :error, format_error(error))}
+ end
+ end
+
+ # Helper functions
+
+ defp load_membership_fee_types do
+ MembershipFeeType
+ |> Ash.Query.sort(name: :asc)
+ |> Ash.read!(domain: MembershipFees)
+ end
+
+ defp get_member_count(fee_type) do
+ # Count members with this fee type
+ case Ash.count(Member |> Ash.Query.filter(membership_fee_type_id == ^fee_type.id)) do
+ {:ok, count} -> count
+ _ -> 0
+ end
+ 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")
+
+ # Info card explaining the membership fee type concept
+ defp info_card(assigns) do
+ ~H"""
+
+
+
+ <.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
+end