381 lines
14 KiB
Elixir
381 lines
14 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
|
|
|
|
@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>
|
|
|
|
<%!-- Payment Data Section (Mockup) --%>
|
|
<div class="max-w-xl">
|
|
<.form_section title={gettext("Payment Data")}>
|
|
<div role="alert" class="alert alert-info mb-4">
|
|
<.icon name="hero-information-circle" class="size-5" />
|
|
<span>{gettext("This data is for demonstration purposes only (mockup).")}</span>
|
|
</div>
|
|
|
|
<div class="flex gap-8">
|
|
<div class="w-24">
|
|
<label for="mock-contribution" class="label text-sm font-medium">
|
|
{gettext("Contribution")}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="mock-contribution"
|
|
value="72 €"
|
|
disabled
|
|
class="input input-bordered w-full bg-base-200"
|
|
/>
|
|
</div>
|
|
<div class="w-40">
|
|
<label class="label text-sm font-medium">{gettext("Payment Cycle")}</label>
|
|
<div class="flex gap-3 mt-2">
|
|
<label class="flex items-center gap-1 cursor-not-allowed opacity-60">
|
|
<input type="radio" name="mock_cycle" checked disabled class="radio radio-sm" />
|
|
<span class="text-sm">{gettext("monthly")}</span>
|
|
</label>
|
|
<label class="flex items-center gap-1 cursor-not-allowed opacity-60">
|
|
<input type="radio" name="mock_cycle" disabled class="radio radio-sm" />
|
|
<span class="text-sm">{gettext("yearly")}</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="w-24 flex items-end">
|
|
<.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" />
|
|
</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)
|
|
end
|
|
|
|
page_title =
|
|
if is_nil(member), do: gettext("Create Member"), else: gettext("Edit 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_form()}
|
|
end
|
|
|
|
defp return_to("show"), do: "show"
|
|
defp return_to(_), do: "index"
|
|
|
|
@impl true
|
|
def handle_event("validate", %{"member" => member_params}, socket) do
|
|
{:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, member_params))}
|
|
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 Components
|
|
# -----------------------------------------------------------------
|
|
|
|
# Renders a form section box with border and title.
|
|
attr :title, :string, required: true
|
|
slot :inner_block, required: true
|
|
|
|
defp form_section(assigns) do
|
|
~H"""
|
|
<section class="mb-6">
|
|
<h2 class="text-lg font-semibold mb-3">{@title}</h2>
|
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
|
{render_slot(@inner_block)}
|
|
</div>
|
|
</section>
|
|
"""
|
|
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
|