Membership Fee 6 - UI Components & LiveViews closes #280 #304

Open
moritz wants to merge 65 commits from feature/280_membership_fee_ui into main
2 changed files with 516 additions and 0 deletions
Showing only changes of commit 810a54c11f - Show all commits

View 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

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:

  • Amount-Input with phx-debounce="blur" or phx-debounce="300"
  • or calculate Count only if show_amount_warning changes false -> true
  • or Count just load on submit
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: - Amount-Input with phx-debounce="blur" or phx-debounce="300" - or calculate Count only if show_amount_warning changes false -> true - or Count just load on submit
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

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

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

View 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

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

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.
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

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)

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