Merge branch 'main' into feat/421_accessibility
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
commit
73382c2c3f
49 changed files with 3415 additions and 1950 deletions
|
|
@ -6,7 +6,6 @@ defmodule MvWeb.MemberLive.Form do
|
|||
- 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
|
||||
|
||||
|
|
@ -21,6 +20,7 @@ defmodule MvWeb.MemberLive.Form do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
require Logger
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||
|
||||
alias Mv.Membership
|
||||
|
|
@ -38,222 +38,248 @@ defmodule MvWeb.MemberLive.Form do
|
|||
~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>
|
||||
<.header>
|
||||
<:leading>
|
||||
<.button navigate={return_path(@return_to, @member)} variant="neutral">
|
||||
<.icon name="hero-arrow-left" class="size-4" />
|
||||
{gettext("Back")}
|
||||
</.button>
|
||||
</:leading>
|
||||
<%= if @member do %>
|
||||
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
|
||||
<% else %>
|
||||
{gettext("New Member")}
|
||||
<% end %>
|
||||
<:actions>
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||
{gettext("Save")}
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<h1 class="text-2xl font-bold text-center flex-1">
|
||||
<%= if @member do %>
|
||||
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
|
||||
<% else %>
|
||||
{gettext("New Member")}
|
||||
<% end %>
|
||||
</h1>
|
||||
<div class="mt-6 space-y-6">
|
||||
<%!-- Tab navigation: Payments tab not shown on new/edit (only on member show) --%>
|
||||
<div role="tablist" class="tabs tabs-bordered">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<.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={@member_field_required_map[:first_name]}
|
||||
/>
|
||||
<%!-- 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={@member_field_required_map[:first_name]}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-48">
|
||||
<.input
|
||||
field={@form[:last_name]}
|
||||
label={gettext("Last Name")}
|
||||
required={@member_field_required_map[:last_name]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-48">
|
||||
<.input
|
||||
field={@form[:last_name]}
|
||||
label={gettext("Last Name")}
|
||||
required={@member_field_required_map[:last_name]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Address: Country, Postal Code, City in one row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-48">
|
||||
<.input field={@form[:country]} label={gettext("Country")} />
|
||||
<%!-- Address: Country, Postal Code, City in one row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-48">
|
||||
<.input field={@form[:country]} label={gettext("Country")} />
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<.input
|
||||
field={@form[:postal_code]}
|
||||
label={gettext("Postal Code")}
|
||||
required={@member_field_required_map[:postal_code]}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-48">
|
||||
<.input field={@form[:city]} label={gettext("City")} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<.input
|
||||
field={@form[:postal_code]}
|
||||
label={gettext("Postal Code")}
|
||||
required={@member_field_required_map[:postal_code]}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-48">
|
||||
<.input field={@form[:city]} label={gettext("City")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Street and Nr. below --%>
|
||||
<div class="flex gap-4">
|
||||
<%!-- Street and Nr. below --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-64">
|
||||
<.input field={@form[:street]} label={gettext("Street")} />
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<.input field={@form[:house_number]} label={gettext("Nr.")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Email --%>
|
||||
<div class="w-64">
|
||||
<.input field={@form[:street]} label={gettext("Street")} />
|
||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<.input field={@form[:house_number]} label={gettext("Nr.")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Email --%>
|
||||
<div class="w-64">
|
||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||
</div>
|
||||
|
||||
<%!-- Membership Dates Row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-36">
|
||||
<.input
|
||||
field={@form[:join_date]}
|
||||
label={gettext("Join Date")}
|
||||
type="date"
|
||||
required={@member_field_required_map[:join_date]}
|
||||
/>
|
||||
<%!-- Membership Dates Row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-36">
|
||||
<.input
|
||||
field={@form[:join_date]}
|
||||
label={gettext("Join Date")}
|
||||
type="date"
|
||||
required={@member_field_required_map[:join_date]}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-36">
|
||||
<.input
|
||||
field={@form[:exit_date]}
|
||||
label={gettext("Exit Date")}
|
||||
type="date"
|
||||
required={@member_field_required_map[:exit_date]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-36">
|
||||
|
||||
<%!-- Notes --%>
|
||||
<div>
|
||||
<.input
|
||||
field={@form[:exit_date]}
|
||||
label={gettext("Exit Date")}
|
||||
type="date"
|
||||
required={@member_field_required_map[:exit_date]}
|
||||
field={@form[:notes]}
|
||||
label={gettext("Notes")}
|
||||
type="textarea"
|
||||
required={@member_field_required_map[:notes]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</.form_section>
|
||||
</div>
|
||||
|
||||
<%!-- Notes --%>
|
||||
<%!-- 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)}
|
||||
required={cf.required}
|
||||
/>
|
||||
</.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>
|
||||
<.input
|
||||
field={@form[:notes]}
|
||||
label={gettext("Notes")}
|
||||
type="textarea"
|
||||
required={@member_field_required_map[:notes]}
|
||||
/>
|
||||
<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"
|
||||
value={@form[:membership_fee_type_id].value || ""}
|
||||
>
|
||||
<%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
|
||||
<option value="">{gettext("Select a membership fee type")}</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 error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %>
|
||||
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
|
||||
<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>
|
||||
|
||||
<%!-- 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)}
|
||||
required={cf.required}
|
||||
/>
|
||||
</.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>
|
||||
<%!-- Bottom Action Buttons --%>
|
||||
<div class="flex justify-end gap-4 mt-6">
|
||||
<.button navigate={return_path(@return_to, @member)} variant="neutral" type="button">
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||
{gettext("Save Member")}
|
||||
</.button>
|
||||
</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"
|
||||
value={@form[:membership_fee_type_id].value || ""}
|
||||
>
|
||||
<%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
|
||||
<option value="">{gettext("Select a membership fee type")}</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 error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %>
|
||||
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
|
||||
<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">
|
||||
<%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%>
|
||||
<%= if @member && can?(@current_user, :destroy, @member) do %>
|
||||
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
||||
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
||||
{gettext("Danger zone")}
|
||||
</h2>
|
||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||
<p class="text-base-content/70 mb-4">
|
||||
{gettext(
|
||||
"Select a membership fee type for this member. Members can only switch between types with the same interval."
|
||||
"Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
|
||||
)}
|
||||
</p>
|
||||
<.button
|
||||
variant="danger"
|
||||
type="button"
|
||||
phx-click="delete"
|
||||
phx-value-id={@member.id}
|
||||
data-confirm={
|
||||
gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
|
||||
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||
)
|
||||
}
|
||||
data-testid="member-delete"
|
||||
aria-label={
|
||||
gettext("Delete member %{name}",
|
||||
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||
)
|
||||
}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
{gettext("Delete member")}
|
||||
</.button>
|
||||
</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>
|
||||
</section>
|
||||
<% end %>
|
||||
</div>
|
||||
</.form>
|
||||
</Layouts.app>
|
||||
|
|
@ -374,6 +400,41 @@ defmodule MvWeb.MemberLive.Form do
|
|||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
member = socket.assigns.member
|
||||
actor = current_actor(socket)
|
||||
|
||||
cond do
|
||||
is_nil(member) ->
|
||||
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
|
||||
|
||||
to_string(id) != to_string(member.id) ->
|
||||
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
|
||||
|
||||
true ->
|
||||
handle_member_delete_destroy(socket, member, actor)
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_member_delete_destroy(socket, member, actor) do
|
||||
case Ash.destroy(member, actor: actor) do
|
||||
:ok ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:success, gettext("Member deleted successfully"))
|
||||
|> push_navigate(to: ~p"/members")}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:noreply,
|
||||
put_flash(socket, :error, gettext("You do not have permission to delete this member"))}
|
||||
|
||||
{:error, error} ->
|
||||
Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}")
|
||||
{:noreply, put_flash(socket, :error, format_destroy_error(error))}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_save_success(socket, member) do
|
||||
notify_parent({:saved, member})
|
||||
|
||||
|
|
@ -386,7 +447,7 @@ defmodule MvWeb.MemberLive.Form do
|
|||
|
||||
socket =
|
||||
socket
|
||||
|> put_flash(:info, flash_message)
|
||||
|> put_flash(:success, flash_message)
|
||||
|> maybe_put_vereinfacht_sync_flash(member.id)
|
||||
|> push_navigate(to: return_path(socket.assigns.return_to, member))
|
||||
|
||||
|
|
@ -421,6 +482,19 @@ defmodule MvWeb.MemberLive.Form do
|
|||
end
|
||||
end
|
||||
|
||||
defp format_destroy_error(%Ash.Error.Invalid{errors: errors}) do
|
||||
error_messages =
|
||||
Enum.map(errors, fn
|
||||
%{field: field, message: message} -> "#{field}: #{message}"
|
||||
%{message: message} -> message
|
||||
_ -> inspect(errors)
|
||||
end)
|
||||
|
||||
Enum.join(error_messages, ", ")
|
||||
end
|
||||
|
||||
defp format_destroy_error(error), do: inspect(error)
|
||||
|
||||
defp handle_save_error(socket, form) do
|
||||
# Always show a flash message when save fails
|
||||
# Field-level validation errors are displayed in form fields, but flash provides additional feedback
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
- `sort_order` - Sort direction (:asc or :desc)
|
||||
|
||||
## Events
|
||||
- `delete` - Remove a member from the database
|
||||
- `select_member` - Toggle individual member selection
|
||||
- `select_all` - Toggle selection of all visible members
|
||||
- `copy_emails` - Copy email addresses of selected members to clipboard
|
||||
|
|
@ -123,6 +122,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> assign(:groups, groups)
|
||||
|> assign(:boolean_custom_field_filters, %{})
|
||||
|> assign(:selected_members, MapSet.new())
|
||||
|> assign(:selected_member_id, nil)
|
||||
|> assign(:settings, settings)
|
||||
|> assign(:custom_fields_visible, custom_fields_visible)
|
||||
|> assign(:all_custom_fields, all_custom_fields)
|
||||
|
|
@ -157,48 +157,14 @@ defmodule MvWeb.MemberLive.Index do
|
|||
Handles member-related UI events.
|
||||
|
||||
## Supported events:
|
||||
- `"delete"` - Removes a member from the database
|
||||
- `"select_member"` - Toggles individual member selection
|
||||
- `"select_all"` - Toggles selection of all visible members
|
||||
- `"sort"` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
|
||||
"""
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
actor = current_actor(socket)
|
||||
|
||||
case Ash.get(Mv.Membership.Member, id, actor: actor) do
|
||||
{:ok, member} ->
|
||||
case Ash.destroy(member, actor: actor) do
|
||||
:ok ->
|
||||
updated_members = Enum.reject(socket.assigns.members, &(&1.id == id))
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:members, updated_members)
|
||||
|> put_flash(:info, gettext("Member deleted successfully"))}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("You do not have permission to delete this member")
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
end
|
||||
|
||||
{:error, %Ash.Error.Query.NotFound{}} ->
|
||||
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{} = _error} ->
|
||||
{:noreply,
|
||||
put_flash(socket, :error, gettext("You do not have permission to access this member"))}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
end
|
||||
def handle_event("select_row_and_navigate", %{"id" => id}, socket) do
|
||||
# Navigate to member show. Back button on show page uses ?highlight=id so returning to index shows row as selected.
|
||||
{:noreply, push_navigate(socket, to: ~p"/members/#{id}")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -343,22 +309,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
{:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)}
|
||||
end
|
||||
|
||||
# Helper to format errors for display
|
||||
defp format_error(%Ash.Error.Invalid{errors: errors}) do
|
||||
error_messages =
|
||||
Enum.map(errors, fn error ->
|
||||
case error do
|
||||
%{field: field, message: message} -> "#{field}: #{message}"
|
||||
%{message: message} -> message
|
||||
_ -> inspect(error)
|
||||
end
|
||||
end)
|
||||
|
||||
Enum.join(error_messages, ", ")
|
||||
end
|
||||
|
||||
defp format_error(error), do: inspect(error)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Handle Infos from Child Components
|
||||
# -----------------------------------------------------------------
|
||||
|
|
@ -656,6 +606,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> assign(:member_fields_visible_db, visible_member_fields_db)
|
||||
|> assign(:member_fields_visible_computed, visible_member_fields_computed)
|
||||
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
||||
|> assign(:selected_member_id, parse_highlight_param(params["highlight"]))
|
||||
|
||||
next_sig = build_signature(socket)
|
||||
|
||||
|
|
@ -855,6 +806,18 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
end
|
||||
|
||||
# Parses optional "highlight" URL param (member id for selected row styling). Returns nil if missing or invalid.
|
||||
defp parse_highlight_param(nil), do: nil
|
||||
defp parse_highlight_param(""), do: nil
|
||||
|
||||
defp parse_highlight_param(id) when is_binary(id) do
|
||||
if String.length(id) <= @max_uuid_length and match?({:ok, _}, Ecto.UUID.cast(id)),
|
||||
do: id,
|
||||
else: nil
|
||||
end
|
||||
|
||||
defp parse_highlight_param(_), do: nil
|
||||
|
||||
defp merge_fields_param_from_uri(params, nil), do: params
|
||||
|
||||
defp merge_fields_param_from_uri(params, %URI{query: query}) when is_binary(query) do
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
selected_count={@selected_count}
|
||||
/>
|
||||
<.button
|
||||
class="secondary"
|
||||
variant="secondary"
|
||||
id="copy-emails-btn"
|
||||
phx-hook="CopyToClipboard"
|
||||
phx-click="copy_emails"
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
{gettext("Copy email addresses")} ({@selected_count})
|
||||
</.button>
|
||||
<.button
|
||||
class="secondary"
|
||||
variant="secondary"
|
||||
id="open-email-btn"
|
||||
href={"mailto:?bcc=" <> @mailto_bcc}
|
||||
disabled={not @any_selected?}
|
||||
|
|
@ -54,13 +54,12 @@
|
|||
boolean_filters={@boolean_custom_field_filters}
|
||||
member_count={length(@members)}
|
||||
/>
|
||||
<button
|
||||
<.button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
class={["gap-2", @show_current_cycle && "btn-active"]}
|
||||
phx-click="toggle_cycle_view"
|
||||
class={[
|
||||
"btn gap-2",
|
||||
@show_current_cycle && "btn-active"
|
||||
]}
|
||||
data-testid="toggle-cycle-view"
|
||||
aria-label={
|
||||
if(@show_current_cycle,
|
||||
do: gettext("Current Cycle Payment Status"),
|
||||
|
|
@ -81,7 +80,7 @@
|
|||
else: gettext("Last Cycle Payment Status")
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</.button>
|
||||
<.live_component
|
||||
module={MvWeb.Components.FieldVisibilityDropdownComponent}
|
||||
id="field-visibility-dropdown"
|
||||
|
|
@ -91,40 +90,53 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<.table
|
||||
id="members"
|
||||
rows={@members}
|
||||
row_id={fn member -> "row-#{member.id}" end}
|
||||
row_click={fn member -> JS.navigate(~p"/members/#{member}") end}
|
||||
dynamic_cols={@dynamic_cols}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
<%!-- On desktop (lg:), only the table area scrolls; header and filters stay visible. On mobile, normal flow. --%>
|
||||
<div
|
||||
class="lg:max-h-[calc(100vh-14rem)] lg:overflow-auto min-h-0"
|
||||
data-testid="members-table-scroll"
|
||||
role="region"
|
||||
aria-label={gettext("Members table")}
|
||||
>
|
||||
|
||||
<.table
|
||||
id="members"
|
||||
rows={@members}
|
||||
sticky_header={true}
|
||||
row_id={fn member -> "row-#{member.id}" end}
|
||||
row_click={fn member -> JS.push("select_row_and_navigate", value: %{id: member.id}) end}
|
||||
row_tooltip={gettext("Click for member details")}
|
||||
row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end}
|
||||
dynamic_cols={@dynamic_cols}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
>
|
||||
|
||||
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
|
||||
<:col
|
||||
:let={member}
|
||||
col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1}
|
||||
label={
|
||||
~H"""
|
||||
<:col
|
||||
:let={member}
|
||||
col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1}
|
||||
label={
|
||||
~H"""
|
||||
<.input
|
||||
type="checkbox"
|
||||
name="select_all"
|
||||
phx-click="select_all"
|
||||
checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())}
|
||||
aria-label={gettext("Select all members")}
|
||||
role="checkbox"
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
<.input
|
||||
type="checkbox"
|
||||
name="select_all"
|
||||
phx-click="select_all"
|
||||
checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())}
|
||||
aria-label={gettext("Select all members")}
|
||||
name={member.id}
|
||||
checked={MapSet.member?(@selected_members, member.id)}
|
||||
aria-label={gettext("Select member")}
|
||||
role="checkbox"
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
<.input
|
||||
type="checkbox"
|
||||
name={member.id}
|
||||
checked={MapSet.member?(@selected_members, member.id)}
|
||||
aria-label={gettext("Select member")}
|
||||
role="checkbox"
|
||||
/>
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
|
|
@ -400,26 +412,11 @@
|
|||
</:col>
|
||||
<:action :let={member}>
|
||||
<div class="sr-only">
|
||||
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
|
||||
<.link navigate={~p"/members/#{member}"} data-testid="member-show-link">
|
||||
{gettext("Show")}
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<%= if can?(@current_user, :update, member) do %>
|
||||
<.link navigate={~p"/members/#{member}/edit"} data-testid="member-edit">
|
||||
{gettext("Edit")}
|
||||
</.link>
|
||||
<% end %>
|
||||
</:action>
|
||||
|
||||
<:action :let={member}>
|
||||
<%= if can?(@current_user, :destroy, member) do %>
|
||||
<.link
|
||||
phx-click={JS.push("delete", value: %{id: member.id}) |> hide("#row-#{member.id}")}
|
||||
data-confirm={gettext("Are you sure?")}
|
||||
data-testid="member-delete"
|
||||
>
|
||||
{gettext("Delete")}
|
||||
</.link>
|
||||
<% end %>
|
||||
</:action>
|
||||
</.table>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
|
|
|
|||
|
|
@ -30,235 +30,311 @@ defmodule MvWeb.MemberLive.Show do
|
|||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<%!-- Header with Back button, Name, and Edit button --%>
|
||||
<div class="flex items-center justify-between gap-4 pb-4">
|
||||
<.button navigate={~p"/members"} aria-label={gettext("Back to members list")}>
|
||||
<.icon name="hero-arrow-left" class="size-4" />
|
||||
{gettext("Back")}
|
||||
</.button>
|
||||
|
||||
<h1 class="text-2xl font-bold text-center flex-1">
|
||||
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
|
||||
</h1>
|
||||
|
||||
<%= if can?(@current_user, :update, @member) do %>
|
||||
<.header>
|
||||
<:leading>
|
||||
<.button
|
||||
variant="primary"
|
||||
navigate={~p"/members/#{@member}/edit?return_to=show"}
|
||||
data-testid="member-edit"
|
||||
navigate={~p"/members?highlight=#{@member.id}"}
|
||||
variant="neutral"
|
||||
aria-label={gettext("Back to members list")}
|
||||
>
|
||||
{gettext("Edit Member")}
|
||||
<.icon name="hero-arrow-left" class="size-4" />
|
||||
{gettext("Back")}
|
||||
</.button>
|
||||
<% end %>
|
||||
</div>
|
||||
</:leading>
|
||||
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
|
||||
<:actions>
|
||||
<%= if can?(@current_user, :update, @member) do %>
|
||||
<.button
|
||||
variant="primary"
|
||||
navigate={~p"/members/#{@member}/edit?return_to=show"}
|
||||
data-testid="member-edit"
|
||||
>
|
||||
<.icon name="hero-pencil-square" /> {gettext("Edit member")}
|
||||
</.button>
|
||||
<% end %>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<%!-- Tab Navigation --%>
|
||||
<div role="tablist" class="tabs tabs-bordered mb-6">
|
||||
<button
|
||||
role="tab"
|
||||
class={[
|
||||
"tab",
|
||||
if(@active_tab == :contact, do: "tab-active", else: "!text-gray-800")
|
||||
]}
|
||||
aria-selected={@active_tab == :contact}
|
||||
phx-click="switch_tab"
|
||||
phx-value-tab="contact"
|
||||
<div class="mt-6 space-y-6">
|
||||
<%!-- Tab Navigation: surface only behind buttons (inline); tabs-bordered; tabs-lg; both tabs keyboard-focusable --%>
|
||||
<div
|
||||
role="tablist"
|
||||
class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit"
|
||||
>
|
||||
<.icon name="hero-identification" class="size-4 mr-2" />
|
||||
{gettext("Contact Data")}
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
class={[
|
||||
"tab",
|
||||
if(@active_tab == :membership_fees, do: "tab-active", else: "!text-gray-800")
|
||||
]}
|
||||
aria-selected={@active_tab == :membership_fees}
|
||||
phx-click="switch_tab"
|
||||
phx-value-tab="membership_fees"
|
||||
>
|
||||
<.icon name="hero-credit-card" class="size-4 mr-2" />
|
||||
{gettext("Membership Fees")}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
id="member-tab-contact"
|
||||
role="tab"
|
||||
type="button"
|
||||
tabindex="0"
|
||||
aria-selected={@active_tab == :contact}
|
||||
aria-controls="member-tabpanel-contact"
|
||||
class={[
|
||||
"tab flex items-center gap-2",
|
||||
if(@active_tab == :contact, do: "tab-active", else: "text-base-content/70")
|
||||
]}
|
||||
phx-click="switch_tab"
|
||||
phx-value-tab="contact"
|
||||
>
|
||||
<.icon name="hero-identification" class="size-4 shrink-0" />
|
||||
{gettext("Contact Data")}
|
||||
</button>
|
||||
<button
|
||||
id="member-tab-membership_fees"
|
||||
role="tab"
|
||||
type="button"
|
||||
tabindex="0"
|
||||
aria-selected={@active_tab == :membership_fees}
|
||||
aria-controls="member-tabpanel-membership_fees"
|
||||
class={[
|
||||
"tab flex items-center gap-2",
|
||||
if(@active_tab == :membership_fees, do: "tab-active", else: "text-base-content/70")
|
||||
]}
|
||||
phx-click="switch_tab"
|
||||
phx-value-tab="membership_fees"
|
||||
>
|
||||
<.icon name="hero-credit-card" class="size-4 shrink-0" />
|
||||
{gettext("Membership Fees")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%= if @active_tab == :contact do %>
|
||||
<%!-- Contact Data Tab Content --%>
|
||||
<%!-- Personal Data and Custom Fields Row --%>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<%!-- Personal Data Section --%>
|
||||
<div>
|
||||
<.section_box title={gettext("Personal Data")}>
|
||||
<div class="space-y-4">
|
||||
<%!-- Name Row --%>
|
||||
<div class="flex gap-6">
|
||||
<.data_field label={gettext("First Name")} value={@member.first_name} class="w-48" />
|
||||
<.data_field label={gettext("Last Name")} value={@member.last_name} class="w-48" />
|
||||
</div>
|
||||
<%= if @active_tab == :contact do %>
|
||||
<%!-- Contact Data Tab Content --%>
|
||||
<div
|
||||
id="member-tabpanel-contact"
|
||||
role="tabpanel"
|
||||
aria-labelledby="member-tab-contact"
|
||||
>
|
||||
<%!-- Personal Data and Custom Fields Row --%>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<%!-- Personal Data Section --%>
|
||||
<div>
|
||||
<.section_box title={gettext("Personal Data")}>
|
||||
<div class="space-y-4">
|
||||
<%!-- Name Row --%>
|
||||
<div class="flex gap-6">
|
||||
<.data_field
|
||||
label={gettext("First Name")}
|
||||
value={@member.first_name}
|
||||
class="w-48"
|
||||
/>
|
||||
<.data_field
|
||||
label={gettext("Last Name")}
|
||||
value={@member.last_name}
|
||||
class="w-48"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%!-- Address --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Address")} value={format_address(@member)} />
|
||||
</div>
|
||||
<%!-- Address --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Address")} value={format_address(@member)} />
|
||||
</div>
|
||||
|
||||
<%!-- Email --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Email")}>
|
||||
<a
|
||||
href={"mailto:#{MvWeb.MemberLive.Index.format_member_email(@member)}"}
|
||||
class="text-blue-700 hover:text-blue-800 underline"
|
||||
>
|
||||
{@member.email}
|
||||
</a>
|
||||
</.data_field>
|
||||
</div>
|
||||
<%!-- Email --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Email")}>
|
||||
<a
|
||||
href={"mailto:#{MvWeb.MemberLive.Index.format_member_email(@member)}"}
|
||||
class="text-blue-700 hover:text-blue-800 underline"
|
||||
>
|
||||
{@member.email}
|
||||
</a>
|
||||
</.data_field>
|
||||
</div>
|
||||
|
||||
<%!-- Membership Dates Row --%>
|
||||
<div class="flex gap-6">
|
||||
<.data_field
|
||||
label={gettext("Join Date")}
|
||||
value={format_date(@member.join_date)}
|
||||
class="w-28"
|
||||
/>
|
||||
<.data_field
|
||||
label={gettext("Exit Date")}
|
||||
value={format_date(@member.exit_date)}
|
||||
class="w-28"
|
||||
/>
|
||||
</div>
|
||||
<%!-- Membership Dates Row --%>
|
||||
<div class="flex gap-6">
|
||||
<.data_field
|
||||
label={gettext("Join Date")}
|
||||
value={format_date(@member.join_date)}
|
||||
class="w-28"
|
||||
/>
|
||||
<.data_field
|
||||
label={gettext("Exit Date")}
|
||||
value={format_date(@member.exit_date)}
|
||||
class="w-28"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%!-- Linked User: only show when current user can see other users (e.g. admin).
|
||||
<%!-- Linked User: only show when current user can see other users (e.g. admin).
|
||||
read_only cannot see linked user, so hide the section to avoid "No user linked" when
|
||||
a user is linked but not visible. --%>
|
||||
<%= if can_access_page?(@current_user, "/users") do %>
|
||||
<div>
|
||||
<.data_field label={gettext("Linked User")}>
|
||||
<%= if @member.user do %>
|
||||
<.link
|
||||
navigate={~p"/users/#{@member.user}"}
|
||||
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
|
||||
>
|
||||
<.icon name="hero-user" class="size-4" />
|
||||
{@member.user.email}
|
||||
</.link>
|
||||
<%= if can_access_page?(@current_user, "/users") do %>
|
||||
<div>
|
||||
<.data_field label={gettext("Linked User")}>
|
||||
<%= if @member.user do %>
|
||||
<.link
|
||||
navigate={~p"/users/#{@member.user}"}
|
||||
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
|
||||
>
|
||||
<.icon name="hero-user" class="size-4" />
|
||||
{@member.user.email}
|
||||
</.link>
|
||||
<% else %>
|
||||
<span class="text-base-content/70 italic">
|
||||
{gettext("No user linked")}
|
||||
</span>
|
||||
<% end %>
|
||||
</.data_field>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%!-- Groups (in Personal Data) --%>
|
||||
<% groups = @member.groups || [] %>
|
||||
<div>
|
||||
<.data_field label={gettext("Groups")}>
|
||||
<%= if Enum.empty?(groups) do %>
|
||||
<span class="text-base-content/70 italic">{gettext("No groups")}</span>
|
||||
<% else %>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<%= for group <- groups do %>
|
||||
<.button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
navigate={~p"/groups/#{group.slug}"}
|
||||
aria-label={gettext("Member of group %{name}", name: group.name)}
|
||||
>
|
||||
{group.name}
|
||||
</.button>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</.data_field>
|
||||
</div>
|
||||
|
||||
<%!-- Notes --%>
|
||||
<%= if @member.notes && String.trim(@member.notes) != "" do %>
|
||||
<div>
|
||||
<.data_field label={gettext("Notes")}>
|
||||
<p class="whitespace-pre-wrap text-base-content/80">{@member.notes}</p>
|
||||
</.data_field>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</.section_box>
|
||||
</div>
|
||||
|
||||
<%!-- Custom Fields Section --%>
|
||||
<%= if Enum.any?(@custom_fields) do %>
|
||||
<div>
|
||||
<.section_box title={gettext("Custom Fields")}>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<%= for custom_field <- @custom_fields do %>
|
||||
<% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %>
|
||||
<.data_field label={custom_field.name}>
|
||||
{format_custom_field_value(cfv, custom_field.value_type)}
|
||||
</.data_field>
|
||||
<% end %>
|
||||
</div>
|
||||
</.section_box>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%!-- Payment Data Section --%>
|
||||
<div class="w-full">
|
||||
<.section_box title={gettext("Payment Data")}>
|
||||
<%= if @member.membership_fee_type do %>
|
||||
<div class="flex gap-6 flex-wrap">
|
||||
<.data_field
|
||||
label={gettext("Type")}
|
||||
value={@member.membership_fee_type.name}
|
||||
class="min-w-32"
|
||||
/>
|
||||
<.data_field
|
||||
label={gettext("Membership Fee")}
|
||||
value={MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)}
|
||||
class="min-w-24"
|
||||
/>
|
||||
<.data_field
|
||||
label={gettext("Payment Interval")}
|
||||
value={
|
||||
MembershipFeeHelpers.format_interval(@member.membership_fee_type.interval)
|
||||
}
|
||||
class="min-w-32"
|
||||
/>
|
||||
<.data_field label={gettext("Last Cycle")} class="min-w-32">
|
||||
<%= if @member.last_cycle_status do %>
|
||||
<.badge variant={MembershipFeeHelpers.status_variant(@member.last_cycle_status)}>
|
||||
{format_status_label(@member.last_cycle_status)}
|
||||
</.badge>
|
||||
<% else %>
|
||||
<span class="text-base-content/70 italic">{gettext("No user linked")}</span>
|
||||
<.badge variant="neutral">{gettext("No cycles")}</.badge>
|
||||
<% end %>
|
||||
</.data_field>
|
||||
<.data_field label={gettext("Current Cycle")} class="min-w-36">
|
||||
<%= if @member.current_cycle_status do %>
|
||||
<.badge variant={
|
||||
MembershipFeeHelpers.status_variant(@member.current_cycle_status)
|
||||
}>
|
||||
{format_status_label(@member.current_cycle_status)}
|
||||
</.badge>
|
||||
<% else %>
|
||||
<.badge variant="neutral">{gettext("No cycles")}</.badge>
|
||||
<% end %>
|
||||
</.data_field>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%!-- Groups (in Personal Data) --%>
|
||||
<% groups = @member.groups || [] %>
|
||||
<div>
|
||||
<.data_field label={gettext("Groups")}>
|
||||
<%= if Enum.empty?(groups) do %>
|
||||
<span class="text-base-content/70 italic">{gettext("No groups")}</span>
|
||||
<% else %>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<%= for group <- groups do %>
|
||||
<.link
|
||||
navigate={~p"/groups/#{group.slug}"}
|
||||
class="btn btn-xs btn-outline btn-primary"
|
||||
aria-label={gettext("Member of group %{name}", name: group.name)}
|
||||
>
|
||||
{group.name}
|
||||
</.link>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</.data_field>
|
||||
</div>
|
||||
|
||||
<%!-- Notes --%>
|
||||
<%= if @member.notes && String.trim(@member.notes) != "" do %>
|
||||
<div>
|
||||
<.data_field label={gettext("Notes")}>
|
||||
<p class="whitespace-pre-wrap text-base-content/80">{@member.notes}</p>
|
||||
</.data_field>
|
||||
<% else %>
|
||||
<div class="text-base-content/70 italic">
|
||||
{gettext("No membership fee type assigned")}
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</.section_box>
|
||||
</div>
|
||||
|
||||
<%!-- Custom Fields Section --%>
|
||||
<%= if Enum.any?(@custom_fields) do %>
|
||||
<div>
|
||||
<.section_box title={gettext("Custom Fields")}>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<%= for custom_field <- @custom_fields do %>
|
||||
<% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %>
|
||||
<.data_field label={custom_field.name}>
|
||||
{format_custom_field_value(cfv, custom_field.value_type)}
|
||||
</.data_field>
|
||||
<% end %>
|
||||
</div>
|
||||
</.section_box>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%!-- Payment Data Section --%>
|
||||
<div class="w-full">
|
||||
<.section_box title={gettext("Payment Data")}>
|
||||
<%= if @member.membership_fee_type do %>
|
||||
<div class="flex gap-6 flex-wrap">
|
||||
<.data_field
|
||||
label={gettext("Type")}
|
||||
value={@member.membership_fee_type.name}
|
||||
class="min-w-32"
|
||||
/>
|
||||
<.data_field
|
||||
label={gettext("Membership Fee")}
|
||||
value={MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)}
|
||||
class="min-w-24"
|
||||
/>
|
||||
<.data_field
|
||||
label={gettext("Payment Interval")}
|
||||
value={MembershipFeeHelpers.format_interval(@member.membership_fee_type.interval)}
|
||||
class="min-w-32"
|
||||
/>
|
||||
<.data_field label={gettext("Last Cycle")} class="min-w-32">
|
||||
<%= if @member.last_cycle_status do %>
|
||||
<.badge variant={MembershipFeeHelpers.status_variant(@member.last_cycle_status)}>
|
||||
{format_status_label(@member.last_cycle_status)}
|
||||
</.badge>
|
||||
<% else %>
|
||||
<.badge variant="neutral">{gettext("No cycles")}</.badge>
|
||||
<% end %>
|
||||
</.data_field>
|
||||
<.data_field label={gettext("Current Cycle")} class="min-w-36">
|
||||
<%= if @member.current_cycle_status do %>
|
||||
<.badge variant={
|
||||
MembershipFeeHelpers.status_variant(@member.current_cycle_status)
|
||||
}>
|
||||
{format_status_label(@member.current_cycle_status)}
|
||||
</.badge>
|
||||
<% else %>
|
||||
<.badge variant="neutral">{gettext("No cycles")}</.badge>
|
||||
<% end %>
|
||||
</.data_field>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-base-content/70 italic">
|
||||
{gettext("No membership fee type assigned")}
|
||||
</div>
|
||||
<% end %>
|
||||
</.section_box>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if @active_tab == :membership_fees do %>
|
||||
<%!-- Membership Fees Tab Content --%>
|
||||
<div
|
||||
id="member-tabpanel-membership_fees"
|
||||
role="tabpanel"
|
||||
aria-labelledby="member-tab-membership_fees"
|
||||
>
|
||||
<.live_component
|
||||
module={MvWeb.MemberLive.Show.MembershipFeesComponent}
|
||||
id={"membership-fees-#{@member.id}"}
|
||||
member={@member}
|
||||
current_user={@current_user}
|
||||
vereinfacht_receipts={@vereinfacht_receipts}
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @active_tab == :membership_fees do %>
|
||||
<%!-- Membership Fees Tab Content --%>
|
||||
<.live_component
|
||||
module={MvWeb.MemberLive.Show.MembershipFeesComponent}
|
||||
id={"membership-fees-#{@member.id}"}
|
||||
member={@member}
|
||||
current_user={@current_user}
|
||||
vereinfacht_receipts={@vereinfacht_receipts}
|
||||
/>
|
||||
<% end %>
|
||||
<%!-- Danger zone: same section pattern as section_box (h2 outside border) --%>
|
||||
<%= if can?(@current_user, :destroy, @member) do %>
|
||||
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
||||
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
||||
{gettext("Danger zone")}
|
||||
</h2>
|
||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||
<p class="text-base-content/70 mb-4">
|
||||
{gettext(
|
||||
"Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
|
||||
)}
|
||||
</p>
|
||||
<.button
|
||||
variant="danger"
|
||||
phx-click="delete"
|
||||
phx-value-id={@member.id}
|
||||
data-confirm={
|
||||
gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
|
||||
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||
)
|
||||
}
|
||||
data-testid="member-delete"
|
||||
aria-label={
|
||||
gettext("Delete member %{name}",
|
||||
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||
)
|
||||
}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
{gettext("Delete member")}
|
||||
</.button>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
|
@ -320,6 +396,37 @@ defmodule MvWeb.MemberLive.Show do
|
|||
{:noreply, assign(socket, :active_tab, :membership_fees)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
member = socket.assigns.member
|
||||
actor = current_actor(socket)
|
||||
|
||||
if to_string(id) != to_string(member.id) do
|
||||
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
|
||||
else
|
||||
case Ash.destroy(member, actor: actor) do
|
||||
:ok ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:success, gettext("Member deleted successfully"))
|
||||
|> push_navigate(to: ~p"/members")}
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("You do not have permission to delete this member")
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
require Logger
|
||||
Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}")
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("load_vereinfacht_receipts", %{"contact_id" => contact_id}, socket) do
|
||||
response =
|
||||
case Mv.Vereinfacht.Client.get_contact_with_receipts(contact_id) do
|
||||
|
|
@ -350,6 +457,19 @@ defmodule MvWeb.MemberLive.Show do
|
|||
defp page_title(:show), do: gettext("Show Member")
|
||||
defp page_title(:edit), do: gettext("Edit Member")
|
||||
|
||||
defp format_error(%Ash.Error.Invalid{errors: errors}) do
|
||||
error_messages =
|
||||
Enum.map(errors, fn
|
||||
%{field: field, message: message} -> "#{field}: #{message}"
|
||||
%{message: message} -> message
|
||||
_ -> inspect(errors)
|
||||
end)
|
||||
|
||||
Enum.join(error_messages, ", ")
|
||||
end
|
||||
|
||||
defp format_error(error), do: inspect(error)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Helper Components
|
||||
# -----------------------------------------------------------------
|
||||
|
|
@ -403,7 +523,7 @@ defmodule MvWeb.MemberLive.Show do
|
|||
~H"""
|
||||
<a
|
||||
href={"mailto:#{@email}"}
|
||||
class="text-blue-700 hover:text-blue-800 underline"
|
||||
class="link link-primary"
|
||||
>
|
||||
{@display}
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -66,14 +66,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
<.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" />
|
||||
</.link>
|
||||
<div>
|
||||
<button
|
||||
<.button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
phx-click="load_vereinfacht_receipts"
|
||||
phx-value-contact_id={@member.vereinfacht_contact_id}
|
||||
class="btn btn-sm btn-ghost"
|
||||
>
|
||||
{gettext("Show bookings/receipts from Vereinfacht")}
|
||||
</button>
|
||||
</.button>
|
||||
</div>
|
||||
<%= if @vereinfacht_receipts do %>
|
||||
<div
|
||||
|
|
@ -148,9 +149,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
</.button>
|
||||
<.button
|
||||
:if={Enum.any?(@cycles) and @can_destroy_cycle}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
phx-click="delete_all_cycles"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-error btn-outline"
|
||||
title={gettext("Delete all cycles")}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
|
|
@ -158,9 +160,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
</.button>
|
||||
<.button
|
||||
:if={@member.membership_fee_type != nil and @can_create_cycle}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
phx-click="open_create_cycle_modal"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-primary"
|
||||
title={gettext("Create a new cycle manually")}
|
||||
>
|
||||
<.icon name="hero-plus" class="size-4" />
|
||||
|
|
@ -257,17 +260,18 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
</div>
|
||||
<% end %>
|
||||
<%= if @can_destroy_cycle do %>
|
||||
<button
|
||||
<.button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
phx-click="delete_cycle"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-error btn-outline"
|
||||
title={gettext("Delete cycle")}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
{gettext("Delete")}
|
||||
</button>
|
||||
</.button>
|
||||
<% end %>
|
||||
</div>
|
||||
</:action>
|
||||
|
|
@ -307,10 +311,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
/>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" phx-click="cancel_edit_amount" phx-target={@myself} class="btn">
|
||||
<.button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
phx-click="cancel_edit_amount"
|
||||
phx-target={@myself}
|
||||
>
|
||||
{gettext("Cancel")}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">{gettext("Save")}</button>
|
||||
</.button>
|
||||
<.button type="submit" variant="primary">{gettext("Save")}</.button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -332,17 +341,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
)} - {MembershipFeeHelpers.format_currency(@deleting_cycle.amount)}
|
||||
</p>
|
||||
<div class="modal-action">
|
||||
<button phx-click="cancel_delete_cycle" phx-target={@myself} class="btn">
|
||||
<.button variant="neutral" phx-click="cancel_delete_cycle" phx-target={@myself}>
|
||||
{gettext("Cancel")}
|
||||
</button>
|
||||
<button
|
||||
</.button>
|
||||
<.button
|
||||
variant="danger"
|
||||
phx-click="confirm_delete_cycle"
|
||||
phx-value-cycle_id={@deleting_cycle.id}
|
||||
phx-target={@myself}
|
||||
class="btn btn-error"
|
||||
>
|
||||
{gettext("Delete")}
|
||||
</button>
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
|
@ -383,20 +392,20 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
/>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button phx-click="cancel_delete_all_cycles" phx-target={@myself} class="btn">
|
||||
<.button variant="neutral" phx-click="cancel_delete_all_cycles" phx-target={@myself}>
|
||||
{gettext("Cancel")}
|
||||
</button>
|
||||
<button
|
||||
</.button>
|
||||
<.button
|
||||
variant="danger"
|
||||
phx-click="confirm_delete_all_cycles"
|
||||
phx-target={@myself}
|
||||
class="btn btn-error"
|
||||
disabled={
|
||||
String.trim(String.downcase(@delete_all_confirmation)) !=
|
||||
String.downcase(gettext("Yes"))
|
||||
}
|
||||
>
|
||||
{gettext("Delete All")}
|
||||
</button>
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
|
@ -470,10 +479,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
</div>
|
||||
<% end %>
|
||||
<div class="modal-action">
|
||||
<button type="button" phx-click="cancel_create_cycle" phx-target={@myself} class="btn">
|
||||
<.button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
phx-click="cancel_create_cycle"
|
||||
phx-target={@myself}
|
||||
>
|
||||
{gettext("Cancel")}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">{gettext("Create")}</button>
|
||||
</.button>
|
||||
<.button type="submit" variant="primary">{gettext("Create")}</.button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -546,7 +560,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
get_available_fee_types(updated_member, actor)
|
||||
)
|
||||
|> assign(:interval_warning, nil)
|
||||
|> put_flash(:info, gettext("Membership fee type removed"))}
|
||||
|> put_flash(:success, gettext("Membership fee type removed"))}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
|
|
@ -605,7 +619,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
get_available_fee_types(updated_member, actor)
|
||||
)
|
||||
|> assign(:interval_warning, nil)
|
||||
|> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))}
|
||||
|> put_flash(:success, gettext("Membership fee type updated. Cycles regenerated."))}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
|
|
@ -633,7 +647,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
{:noreply,
|
||||
socket
|
||||
|> assign(:cycles, updated_cycles)
|
||||
|> put_flash(:info, gettext("Cycle status updated"))}
|
||||
|> put_flash(:success, gettext("Cycle status updated"))}
|
||||
|
||||
{:error, %Ash.Error.Invalid{} = error} ->
|
||||
error_msg =
|
||||
|
|
@ -689,7 +703,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|> assign(:member, updated_member)
|
||||
|> assign(:cycles, cycles)
|
||||
|> assign(:regenerating, false)
|
||||
|> put_flash(:info, gettext("Cycles regenerated successfully"))}
|
||||
|> put_flash(:success, gettext("Cycles regenerated successfully"))}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply,
|
||||
|
|
@ -739,7 +753,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
socket
|
||||
|> assign(:cycles, updated_cycles)
|
||||
|> assign(:editing_cycle, nil)
|
||||
|> put_flash(:info, gettext("Cycle amount updated"))}
|
||||
|> put_flash(:success, gettext("Cycle amount updated"))}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply,
|
||||
|
|
@ -778,7 +792,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
socket
|
||||
|> assign(:cycles, updated_cycles)
|
||||
|> assign(:deleting_cycle, nil)
|
||||
|> put_flash(:info, gettext("Cycle deleted"))}
|
||||
|> put_flash(:success, gettext("Cycle deleted"))}
|
||||
|
||||
{:ok, _destroyed} ->
|
||||
# Handle case where return_destroyed? is true
|
||||
|
|
@ -788,7 +802,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
socket
|
||||
|> assign(:cycles, updated_cycles)
|
||||
|> assign(:deleting_cycle, nil)
|
||||
|> put_flash(:info, gettext("Cycle deleted"))}
|
||||
|> put_flash(:success, gettext("Cycle deleted"))}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply,
|
||||
|
|
@ -934,7 +948,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|> assign(:creating_cycle, false)
|
||||
|> assign(:create_cycle_date, nil)
|
||||
|> assign(:create_cycle_error, nil)
|
||||
|> put_flash(:info, gettext("Cycle created successfully"))}
|
||||
|> put_flash(:success, gettext("Cycle created successfully"))}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply,
|
||||
|
|
@ -997,7 +1011,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|> assign(:member, updated_member)
|
||||
|> assign(:cycles, updated_cycles)
|
||||
|> reset_modal.()
|
||||
|> put_flash(:info, gettext("All cycles deleted"))}
|
||||
|> put_flash(:success, gettext("All cycles deleted"))}
|
||||
|
||||
{:ok, _} ->
|
||||
{:noreply,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue