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 + /> + +
+ + + <%= if !is_nil(@membership_fee_type) do %> + + <% 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 %> + + + + <% 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}> + + + + + <.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