Membership Fee 6 - UI Components & LiveViews closes #280 #304
2 changed files with 516 additions and 0 deletions
331
lib/mv_web/live/membership_fee_type_live/form.ex
Normal file
331
lib/mv_web/live/membership_fee_type_live/form.ex
Normal file
|
|
@ -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"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>
|
||||
{gettext("Use this form to manage membership fee types in your database.")}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.form
|
||||
class="max-w-xl"
|
||||
for={@form}
|
||||
id="membership-fee-type-form"
|
||||
phx-change="validate"
|
||||
|
moritz marked this conversation as resolved
|
||||
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
|
||||
|
moritz marked this conversation as resolved
carla
commented
Interval is neccessary to create a fee type, but it is not marked as required. So when I leave it out there is no error message but my fee type is not created. Interval is neccessary to create a fee type, but it is not marked as required. So when I leave it out there is no error message but my fee type is not created.
|
||||
/>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">{gettext("Interval")}</span>
|
||||
</label>
|
||||
<select
|
||||
class="select select-bordered w-full"
|
||||
disabled={!is_nil(@membership_fee_type)}
|
||||
|
moritz marked this conversation as resolved
Outdated
carla
commented
The form has alreday phx-change validate, I think it is redundant here The form has alreday phx-change validate, I think it is redundant here
|
||||
name="interval"
|
||||
value={@form[:interval].value || ""}
|
||||
>
|
||||
<option value="">{gettext("Select interval")}</option>
|
||||
<option value="monthly" selected={@form[:interval].value == :monthly}>
|
||||
{gettext("Monthly")}
|
||||
</option>
|
||||
<option value="quarterly" selected={@form[:interval].value == :quarterly}>
|
||||
{gettext("Quarterly")}
|
||||
</option>
|
||||
<option value="half_yearly" selected={@form[:interval].value == :half_yearly}>
|
||||
{gettext("Half-yearly")}
|
||||
</option>
|
||||
<option value="yearly" selected={@form[:interval].value == :yearly}>
|
||||
{gettext("Yearly")}
|
||||
</option>
|
||||
</select>
|
||||
<%= if !is_nil(@membership_fee_type) do %>
|
||||
<label class="label">
|
||||
<span class="label-text-alt text-base-content/60">
|
||||
{gettext("Interval cannot be changed after creation.")}
|
||||
</span>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<.input
|
||||
field={@form[:description]}
|
||||
type="textarea"
|
||||
label={gettext("Description")}
|
||||
rows="3"
|
||||
/>
|
||||
|
||||
<div class="mt-4">
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||
{gettext("Save Membership Fee Type")}
|
||||
</.button>
|
||||
<.button navigate={return_path(@return_to, @membership_fee_type)} type="button">
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
|
||||
<%!-- Amount Change Warning Modal --%>
|
||||
<%= if @show_amount_warning do %>
|
||||
<dialog id="amount-warning-modal" class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">{gettext("Change Amount?")}</h3>
|
||||
<div class="py-4 space-y-4">
|
||||
<div class="alert alert-warning">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5" />
|
||||
<div>
|
||||
<p class="font-semibold">
|
||||
{gettext("Changing the amount will affect %{count} member(s).",
|
||||
count: @affected_member_count
|
||||
)}
|
||||
</p>
|
||||
<p class="mt-2 text-sm">
|
||||
{gettext("Future unpaid cycles will be regenerated with the new amount.")}
|
||||
</p>
|
||||
<p class="mt-2 text-sm">
|
||||
{gettext("Already paid cycles will remain with the old amount.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/70">{gettext("Current amount")}:</span>
|
||||
<span class="font-mono font-semibold">
|
||||
{MembershipFeeHelpers.format_currency(@old_amount)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/70">{gettext("New amount")}:</span>
|
||||
<span class="font-mono font-semibold text-primary">
|
||||
{MembershipFeeHelpers.format_currency(@new_amount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="cancel_amount_change"
|
||||
class="btn"
|
||||
>
|
||||
{gettext("Cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="confirm_amount_change"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{gettext("Confirm Change")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<% end %>
|
||||
</Layouts.app>
|
||||
"""
|
||||
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
|
||||
185
lib/mv_web/live/membership_fee_type_live/index.ex
Normal file
185
lib/mv_web/live/membership_fee_type_live/index.ex
Normal file
|
|
@ -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"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{gettext("Membership Fee Types")}
|
||||
<:subtitle>
|
||||
{gettext("Manage membership fee types for membership fees.")}
|
||||
</:subtitle>
|
||||
<:actions>
|
||||
<.button variant="primary" navigate={~p"/membership_fee_types/new"}>
|
||||
<.icon name="hero-plus" /> {gettext("New Membership Fee Type")}
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.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/60">{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)}</span>
|
||||
|
moritz marked this conversation as resolved
carla
commented
We use get_member_count(mft) in different places which calls always Ash.count. Maybe it is performancewise better to call it once and during initial load (%{fee_type_id => count}) and keep it in assigns? We use get_member_count(mft) in different places which calls always Ash.count. Maybe it is performancewise better to call it once and during initial load (%{fee_type_id => count}) and keep it in assigns?
|
||||
</:col>
|
||||
|
||||
<:action :let={mft}>
|
||||
<.link navigate={~p"/membership_fee_types/#{mft.id}/edit"} class="btn btn-ghost btn-xs">
|
||||
|
moritz marked this conversation as resolved
carla
commented
Axe Core gives me here the following error:
Axe Core gives me here the following error:
- Das Element besitzt keinen Text, der für Screenreader sichtbar ist.
- Es existiert kein aria-label-Attribut oder das Attribut ist leer.
- Das aria-labelledby-Attribut existiert nicht oder referenziert ein Element, das nicht existiert, nicht sichtbar oder leer ist.
- Element hat kein title-Attribut.
|
||||
<.icon name="hero-pencil" class="size-4" />
|
||||
</.link>
|
||||
</:action>
|
||||
|
||||
<:action :let={mft}>
|
||||
<button
|
||||
|
moritz marked this conversation as resolved
Outdated
carla
commented
Just a minor thing: maybe we can add a tooltip here why its disabled Just a minor thing: maybe we can add a tooltip here why its disabled
|
||||
phx-click="delete"
|
||||
phx-value-id={mft.id}
|
||||
data-confirm={gettext("Are you sure?")}
|
||||
class={[
|
||||
"btn btn-ghost btn-xs",
|
||||
if(get_member_count(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" />
|
||||
</button>
|
||||
</:action>
|
||||
</.table>
|
||||
|
||||
<.info_card />
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
fee_type = Ash.get!(MembershipFeeType, id)
|
||||
|
carla
commented
Seems to better to always add the domain as in the next line for Ash.destroy Seems to better to always add the domain as in the next line for Ash.destroy
|
||||
|
||||
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"""
|
||||
<div class="card bg-base-200 mt-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<.icon name="hero-information-circle" class="size-5" />
|
||||
{gettext("About Membership Fee Types")}
|
||||
</h2>
|
||||
<div class="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>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue
I think because of the on-change event the amount validation gets triggered already typin the first number and the warning dialog appears directly.
Ideas: