Extract nested conditionals into separate helper functions: - check_amount_change/2 and related helpers in MembershipFeeTypeLive.Form - check_interval_change/2 and related helpers in MemberLive.Form This reduces nesting depth from 3 to 2, improving code readability.
460 lines
16 KiB
Elixir
460 lines
16 KiB
Elixir
defmodule MvWeb.MemberLive.Form do
|
|
@moduledoc """
|
|
LiveView form for creating and editing members.
|
|
|
|
## Features
|
|
- Create new members with personal information
|
|
- Edit existing member details
|
|
- Grouped sections for better organization
|
|
- Tab navigation (Payments tab disabled, coming soon)
|
|
- Manage custom properties (dynamic fields, displayed sorted by name)
|
|
- Real-time validation with visual feedback
|
|
|
|
## Form Sections
|
|
- Personal Data: Name, address, contact information, membership dates, notes
|
|
- Custom Fields: Dynamic fields in uniform grid layout (displayed sorted by name)
|
|
- Payment Data: Mockup section (not editable)
|
|
|
|
## Events
|
|
- `validate` - Real-time form validation
|
|
- `save` - Submit form (create or update member)
|
|
"""
|
|
use MvWeb, :live_view
|
|
|
|
alias Mv.MembershipFees
|
|
alias Mv.MembershipFees.MembershipFeeType
|
|
alias MvWeb.Helpers.MembershipFeeHelpers
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
# Sort custom fields by name for display only
|
|
sorted_custom_fields = Enum.sort_by(assigns.custom_fields, & &1.name)
|
|
assigns = assign(assigns, :sorted_custom_fields, sorted_custom_fields)
|
|
|
|
~H"""
|
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
|
<.form for={@form} id="member-form" phx-change="validate" phx-submit="save">
|
|
<%!-- Header with Back button, Name display, and Save button --%>
|
|
<div class="flex items-center justify-between gap-4 pb-4">
|
|
<.button navigate={return_path(@return_to, @member)} type="button">
|
|
<.icon name="hero-arrow-left" class="size-4" />
|
|
{gettext("Back")}
|
|
</.button>
|
|
|
|
<h1 class="text-2xl font-bold text-center flex-1">
|
|
<%= if @member do %>
|
|
{@member.first_name} {@member.last_name}
|
|
<% else %>
|
|
{gettext("New Member")}
|
|
<% end %>
|
|
</h1>
|
|
|
|
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
|
{gettext("Save")}
|
|
</.button>
|
|
</div>
|
|
|
|
<%!-- Tab Navigation --%>
|
|
<div role="tablist" class="tabs tabs-bordered mb-6">
|
|
<button type="button" role="tab" class="tab tab-active" aria-selected="true">
|
|
<.icon name="hero-identification" class="size-4 mr-2" />
|
|
{gettext("Contact Data")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
class="tab"
|
|
disabled
|
|
aria-disabled="true"
|
|
title={gettext("Coming soon")}
|
|
>
|
|
<.icon name="hero-credit-card" class="size-4 mr-2" />
|
|
{gettext("Payments")}
|
|
</button>
|
|
</div>
|
|
|
|
<%!-- Personal Data and Custom Fields Row --%>
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
|
<%!-- Personal Data Section --%>
|
|
<div>
|
|
<.form_section title={gettext("Personal Data")}>
|
|
<div class="space-y-4">
|
|
<%!-- Name Row --%>
|
|
<div class="flex gap-4">
|
|
<div class="w-48">
|
|
<.input field={@form[:first_name]} label={gettext("First Name")} required />
|
|
</div>
|
|
<div class="w-48">
|
|
<.input field={@form[:last_name]} label={gettext("Last Name")} required />
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Address Row --%>
|
|
<div class="flex gap-4">
|
|
<div class="flex-1">
|
|
<.input field={@form[:street]} label={gettext("Street")} />
|
|
</div>
|
|
<div class="w-16">
|
|
<.input field={@form[:house_number]} label={gettext("Nr.")} />
|
|
</div>
|
|
<div class="w-24">
|
|
<.input field={@form[:postal_code]} label={gettext("Postal Code")} />
|
|
</div>
|
|
<div class="w-32">
|
|
<.input field={@form[:city]} label={gettext("City")} />
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Email --%>
|
|
<div>
|
|
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
|
</div>
|
|
|
|
<%!-- Phone --%>
|
|
<div>
|
|
<.input field={@form[:phone_number]} label={gettext("Phone")} type="tel" />
|
|
</div>
|
|
|
|
<%!-- Membership Dates Row --%>
|
|
<div class="flex gap-4">
|
|
<div class="w-36">
|
|
<.input field={@form[:join_date]} label={gettext("Join Date")} type="date" />
|
|
</div>
|
|
<div class="w-36">
|
|
<.input field={@form[:exit_date]} label={gettext("Exit Date")} type="date" />
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Notes --%>
|
|
<div>
|
|
<.input field={@form[:notes]} label={gettext("Notes")} type="textarea" />
|
|
</div>
|
|
</div>
|
|
</.form_section>
|
|
</div>
|
|
|
|
<%!-- Custom Fields Section --%>
|
|
<%= if Enum.any?(@custom_fields) do %>
|
|
<div>
|
|
<.form_section title={gettext("Custom Fields")}>
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<%!-- Render in sorted order by finding the form for each sorted custom field --%>
|
|
<%= for cf <- @sorted_custom_fields do %>
|
|
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}>
|
|
<%= if f_cfv[:custom_field_id].value == cf.id do %>
|
|
<div class={if cf.value_type == :boolean, do: "flex items-end", else: ""}>
|
|
<.inputs_for :let={value_form} field={f_cfv[:value]}>
|
|
<.input
|
|
field={value_form[:value]}
|
|
label={cf.name}
|
|
type={custom_field_input_type(cf.value_type)}
|
|
/>
|
|
</.inputs_for>
|
|
<input
|
|
type="hidden"
|
|
name={f_cfv[:custom_field_id].name}
|
|
value={f_cfv[:custom_field_id].value}
|
|
/>
|
|
</div>
|
|
<% end %>
|
|
</.inputs_for>
|
|
<% end %>
|
|
</div>
|
|
</.form_section>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
|
|
<%!-- Membership Fee Section --%>
|
|
<div class="max-w-xl">
|
|
<.form_section title={gettext("Membership Fee")}>
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label class="label">
|
|
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
|
|
</label>
|
|
<select
|
|
class="select select-bordered w-full"
|
|
name={@form[:membership_fee_type_id].name}
|
|
phx-change="validate_membership_fee_type"
|
|
value={@form[:membership_fee_type_id].value || ""}
|
|
>
|
|
<option value="">{gettext("None")}</option>
|
|
<%= for fee_type <- @available_fee_types do %>
|
|
<option
|
|
value={fee_type.id}
|
|
selected={fee_type.id == @form[:membership_fee_type_id].value}
|
|
>
|
|
{fee_type.name} ({MembershipFeeHelpers.format_currency(fee_type.amount)}, {MembershipFeeHelpers.format_interval(
|
|
fee_type.interval
|
|
)})
|
|
</option>
|
|
<% end %>
|
|
</select>
|
|
<%= for {msg, _opts} <- @form.errors[:membership_fee_type_id] || [] do %>
|
|
<p class="text-error text-sm mt-1">{msg}</p>
|
|
<% end %>
|
|
<%= if @interval_warning do %>
|
|
<div class="alert alert-warning mt-2">
|
|
<.icon name="hero-exclamation-triangle" class="size-5" />
|
|
<span>{@interval_warning}</span>
|
|
</div>
|
|
<% end %>
|
|
<p class="text-sm text-base-content/60 mt-2">
|
|
{gettext(
|
|
"Select a membership fee type for this member. Members can only switch between types with the same interval."
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</.form_section>
|
|
</div>
|
|
|
|
<%!-- Bottom Action Buttons --%>
|
|
<div class="flex justify-end gap-4 mt-6">
|
|
<.button navigate={return_path(@return_to, @member)} type="button">
|
|
{gettext("Cancel")}
|
|
</.button>
|
|
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
|
{gettext("Save Member")}
|
|
</.button>
|
|
</div>
|
|
</.form>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
|
|
@impl true
|
|
def mount(params, _session, socket) do
|
|
{:ok, custom_fields} = Mv.Membership.list_custom_fields()
|
|
|
|
initial_custom_field_values =
|
|
Enum.map(custom_fields, fn cf ->
|
|
%{
|
|
"custom_field_id" => cf.id,
|
|
"value" => %{
|
|
"type" => cf.value_type,
|
|
"value" => nil,
|
|
"_union_type" => Atom.to_string(cf.value_type)
|
|
}
|
|
}
|
|
end)
|
|
|
|
member =
|
|
case params["id"] do
|
|
nil -> nil
|
|
id -> Ash.get!(Mv.Membership.Member, id, load: [:membership_fee_type])
|
|
end
|
|
|
|
page_title =
|
|
if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member")
|
|
|
|
# Load available membership fee types
|
|
available_fee_types = load_available_fee_types(member)
|
|
|
|
{:ok,
|
|
socket
|
|
|> assign(:return_to, return_to(params["return_to"]))
|
|
|> assign(:custom_fields, custom_fields)
|
|
|> assign(:initial_custom_field_values, initial_custom_field_values)
|
|
|> assign(member: member)
|
|
|> assign(:page_title, page_title)
|
|
|> assign(:available_fee_types, available_fee_types)
|
|
|> assign(:interval_warning, nil)
|
|
|> assign_form()}
|
|
end
|
|
|
|
defp return_to("show"), do: "show"
|
|
defp return_to(_), do: "index"
|
|
|
|
@impl true
|
|
def handle_event("validate", %{"member" => member_params}, socket) do
|
|
validated_form = AshPhoenix.Form.validate(socket.assigns.form, member_params)
|
|
|
|
# Check for interval mismatch if membership_fee_type_id changed
|
|
socket = check_interval_change(socket, member_params)
|
|
|
|
{:noreply, assign(socket, form: validated_form)}
|
|
end
|
|
|
|
def handle_event(
|
|
"validate_membership_fee_type",
|
|
%{"member" => %{"membership_fee_type_id" => fee_type_id}},
|
|
socket
|
|
) do
|
|
# Same validation as above, but triggered by select change
|
|
handle_event("validate", %{"member" => %{"membership_fee_type_id" => fee_type_id}}, socket)
|
|
end
|
|
|
|
def handle_event("save", %{"member" => member_params}, socket) do
|
|
case AshPhoenix.Form.submit(socket.assigns.form, params: member_params) do
|
|
{:ok, member} ->
|
|
notify_parent({:saved, member})
|
|
|
|
action =
|
|
case socket.assigns.form.source.type do
|
|
:create -> gettext("create")
|
|
:update -> gettext("update")
|
|
other -> to_string(other)
|
|
end
|
|
|
|
socket =
|
|
socket
|
|
|> put_flash(:info, gettext("Member %{action} successfully", action: action))
|
|
|> push_navigate(to: return_path(socket.assigns.return_to, member))
|
|
|
|
{:noreply, socket}
|
|
|
|
{:error, form} ->
|
|
{:noreply, assign(socket, form: form)}
|
|
end
|
|
end
|
|
|
|
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
|
|
|
defp assign_form(%{assigns: %{member: member}} = socket) do
|
|
form =
|
|
if member do
|
|
{:ok, member} = Ash.load(member, custom_field_values: [:custom_field])
|
|
|
|
existing_custom_field_values =
|
|
member.custom_field_values
|
|
|> Enum.map(& &1.custom_field_id)
|
|
|
|
is_missing_custom_field_value = fn i ->
|
|
not Enum.member?(existing_custom_field_values, Map.get(i, "custom_field_id"))
|
|
end
|
|
|
|
params = %{
|
|
"custom_field_values" =>
|
|
Enum.map(member.custom_field_values, fn cfv ->
|
|
%{
|
|
"custom_field_id" => cfv.custom_field_id,
|
|
"value" => %{
|
|
"_union_type" => Atom.to_string(cfv.value.type),
|
|
"type" => cfv.value.type,
|
|
"value" => cfv.value.value
|
|
}
|
|
}
|
|
end)
|
|
}
|
|
|
|
form =
|
|
AshPhoenix.Form.for_update(
|
|
member,
|
|
:update_member,
|
|
api: Mv.Membership,
|
|
as: "member",
|
|
params: params,
|
|
forms: [auto?: true]
|
|
)
|
|
|
|
missing_custom_field_values =
|
|
Enum.filter(socket.assigns[:initial_custom_field_values], is_missing_custom_field_value)
|
|
|
|
Enum.reduce(
|
|
missing_custom_field_values,
|
|
form,
|
|
&AshPhoenix.Form.add_form(&2, [:custom_field_values], params: &1)
|
|
)
|
|
else
|
|
AshPhoenix.Form.for_create(
|
|
Mv.Membership.Member,
|
|
:create_member,
|
|
api: Mv.Membership,
|
|
as: "member",
|
|
params: %{"custom_field_values" => socket.assigns[:initial_custom_field_values]},
|
|
forms: [auto?: true]
|
|
)
|
|
end
|
|
|
|
assign(socket, form: to_form(form))
|
|
end
|
|
|
|
defp return_path("index", _member), do: ~p"/members"
|
|
defp return_path("show", nil), do: ~p"/members"
|
|
defp return_path("show", member), do: ~p"/members/#{member.id}"
|
|
|
|
# -----------------------------------------------------------------
|
|
# Helper Functions
|
|
# -----------------------------------------------------------------
|
|
|
|
defp load_available_fee_types(member) do
|
|
all_types =
|
|
MembershipFeeType
|
|
|> Ash.Query.sort(name: :asc)
|
|
|> Ash.read!(domain: MembershipFees)
|
|
|
|
# If member has a fee type, filter to same interval
|
|
if member && member.membership_fee_type do
|
|
Enum.filter(all_types, fn type ->
|
|
type.interval == member.membership_fee_type.interval
|
|
end)
|
|
else
|
|
all_types
|
|
end
|
|
end
|
|
|
|
# Checks if membership fee type interval changed and updates socket assigns
|
|
defp check_interval_change(socket, member_params) do
|
|
if Map.has_key?(member_params, "membership_fee_type_id") &&
|
|
socket.assigns.member &&
|
|
socket.assigns.member.membership_fee_type do
|
|
handle_interval_change(socket, member_params["membership_fee_type_id"])
|
|
else
|
|
socket
|
|
end
|
|
end
|
|
|
|
# Handles interval change validation
|
|
defp handle_interval_change(socket, new_fee_type_id) do
|
|
if new_fee_type_id != "" &&
|
|
new_fee_type_id != socket.assigns.member.membership_fee_type_id do
|
|
validate_interval_match(socket, new_fee_type_id)
|
|
else
|
|
assign(socket, :interval_warning, nil)
|
|
end
|
|
end
|
|
|
|
# Validates that new fee type has same interval as current
|
|
defp validate_interval_match(socket, new_fee_type_id) do
|
|
new_fee_type = find_fee_type(socket.assigns.available_fee_types, new_fee_type_id)
|
|
|
|
if new_fee_type &&
|
|
new_fee_type.interval != socket.assigns.member.membership_fee_type.interval do
|
|
show_interval_warning(socket, new_fee_type)
|
|
else
|
|
assign(socket, :interval_warning, nil)
|
|
end
|
|
end
|
|
|
|
# Shows interval mismatch warning
|
|
defp show_interval_warning(socket, new_fee_type) do
|
|
assign(
|
|
socket,
|
|
:interval_warning,
|
|
gettext(
|
|
"Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval.",
|
|
old_interval:
|
|
MembershipFeeHelpers.format_interval(socket.assigns.member.membership_fee_type.interval),
|
|
new_interval: MembershipFeeHelpers.format_interval(new_fee_type.interval)
|
|
)
|
|
)
|
|
end
|
|
|
|
defp find_fee_type(fee_types, fee_type_id) do
|
|
Enum.find(fee_types, &(&1.id == fee_type_id))
|
|
end
|
|
|
|
# -----------------------------------------------------------------
|
|
# Helper Functions for Custom Fields
|
|
# -----------------------------------------------------------------
|
|
|
|
# Returns input type for custom field based on value type
|
|
defp custom_field_input_type(:string), do: "text"
|
|
defp custom_field_input_type(:integer), do: "number"
|
|
defp custom_field_input_type(:boolean), do: "checkbox"
|
|
defp custom_field_input_type(:date), do: "date"
|
|
defp custom_field_input_type(:email), do: "email"
|
|
defp custom_field_input_type(_), do: "text"
|
|
end
|