defmodule MvWeb.MemberLive.Show do
@moduledoc """
LiveView for displaying a single member's details.
## Features
- Display all member information in grouped sections
- Tab navigation for future features (Payments)
- Show custom field values with type-based formatting
- Navigate to edit form
- Return to member list
## Sections
- Personal Data: Name, address, contact information, membership dates, notes
- Custom Fields: Dynamic fields in uniform grid layout (sorted by name)
- Groups: Links to group detail pages in Personal Data section
- Payment Data: Membership fee type and cycle status
- Membership Fees: Tab showing all membership fee cycles with status management (via MembershipFeesComponent)
## Navigation
- Back to member list
- Edit member (with return_to parameter for back navigation)
"""
use MvWeb, :live_view
import Ash.Query
import MvWeb.LiveHelpers, only: [current_actor: 1]
alias MvWeb.Helpers.MembershipFeeHelpers
@impl true
def render(assigns) do
~H"""
<.header>
<:leading>
<.button
navigate={~p"/members?highlight=#{@member.id}"}
variant="neutral"
aria-label={gettext("Back to members list")}
>
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
{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")}
<% end %>
<%!-- 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 %>
<.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}
<% else %>
{gettext("No user linked")}
<% end %>
<% end %>
<%!-- Groups (in Personal Data) --%>
<% groups = @member.groups || [] %>
<.data_field label={gettext("Groups")}>
<%= if Enum.empty?(groups) do %>
{gettext("No groups")}
<% else %>
<%= 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}
<% end %>
<% end %>
<%!-- Notes --%>
<%= if @member.notes && String.trim(@member.notes) != "" do %>
<.data_field label={gettext("Notes")}>
{@member.notes}
<% end %>
<%!-- Custom Fields Section --%>
<%= if Enum.any?(@custom_fields) do %>
<.section_box title={gettext("Custom Fields")}>
<%= 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)}
<% end %>
<% end %>
<%!-- Payment Data Section --%>
<.section_box title={gettext("Payment Data")}>
<%= if @member.membership_fee_type do %>
<% end %>
<%!-- Delete Member Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
<%= if assigns[:show_delete_modal] do %>
<% end %>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:active_tab, :contact)
|> assign(:vereinfacht_receipts, nil)
|> assign_new(:show_delete_modal, fn -> false end)}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
actor = current_actor(socket)
# Load custom fields once using assign_new to avoid repeated queries
socket =
assign_new(socket, :custom_fields, fn ->
Mv.Membership.CustomField
|> Ash.Query.sort(name: :asc)
|> Ash.read!(actor: actor)
end)
query =
Mv.Membership.Member
|> filter(id == ^id)
|> load([
:user,
:membership_fee_type,
custom_field_values: [:custom_field],
membership_fee_cycles: [:membership_fee_type],
groups: [:id, :name, :slug]
])
member = Ash.read_one!(query, actor: actor)
# Calculate last and current cycle status from loaded cycles
last_cycle_status = get_last_cycle_status(member)
current_cycle_status = get_current_cycle_status(member)
member =
member
|> Map.put(:last_cycle_status, last_cycle_status)
|> Map.put(:current_cycle_status, current_cycle_status)
{:noreply,
socket
|> assign(:page_title, page_title(socket.assigns.live_action))
|> assign(:member, member)}
end
@impl true
def handle_event("switch_tab", %{"tab" => "contact"}, socket) do
{:noreply, assign(socket, :active_tab, :contact)}
end
def handle_event("switch_tab", %{"tab" => "membership_fees"}, socket) do
{:noreply, assign(socket, :active_tab, :membership_fees)}
end
@impl true
def handle_event("tab_keydown", %{"key" => key}, socket)
when key in ["ArrowLeft", "ArrowRight"] do
new_tab =
case {key, socket.assigns.active_tab} do
{"ArrowRight", :contact} -> :membership_fees
{"ArrowLeft", :membership_fees} -> :contact
_ -> socket.assigns.active_tab
end
{:noreply, assign(socket, :active_tab, new_tab)}
end
def handle_event("tab_keydown", _params, socket), do: {:noreply, socket}
@impl true
def handle_event("open_delete_modal", _params, socket) do
{:noreply, assign(socket, :show_delete_modal, true)}
end
@impl true
def handle_event("cancel_delete_modal", _params, socket) do
{:noreply, close_delete_modal_and_restore_focus(socket)}
end
# Escape closes modal (WCAG). phx-window-keydown ensures Escape is captured regardless of focus.
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
if socket.assigns[:show_delete_modal] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
else
{:noreply, socket}
end
end
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
end
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
@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,
socket
|> put_flash(:error, gettext("Member not found"))
|> assign(:show_delete_modal, false)}
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,
socket
|> put_flash(
:error,
gettext("You do not have permission to delete this member")
)
|> assign(:show_delete_modal, false)}
{:error, error} ->
require Logger
Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}")
{:noreply,
socket
|> put_flash(:error, format_error(error))
|> assign(:show_delete_modal, false)}
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
{:ok, receipts} -> {:ok, receipts}
{:error, reason} -> {:error, reason}
end
{:noreply, assign(socket, :vereinfacht_receipts, response)}
end
# WCAG 2.4.3: when modal closes, return focus to the trigger (Delete member button)
defp close_delete_modal_and_restore_focus(socket) do
socket
|> assign(:show_delete_modal, false)
|> push_event("focus_restore", %{id: "delete-member-trigger"})
end
# Flash set in LiveComponent is not shown in parent layout; child sends this to display flash
@impl true
def handle_info({:put_flash, type, message}, socket) do
{:noreply, put_flash(socket, type, message)}
end
# MembershipFeesComponent sends this after cycles are created/deleted/regenerated so parent keeps member in sync
@impl true
def handle_info({:member_updated, updated_member}, socket) do
member =
updated_member
|> Map.put(:last_cycle_status, get_last_cycle_status(updated_member))
|> Map.put(:current_cycle_status, get_current_cycle_status(updated_member))
{:noreply, assign(socket, :member, member)}
end
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
# -----------------------------------------------------------------
# Renders a section box with border and title.
attr :title, :string, required: true
slot :inner_block, required: true
defp section_box(assigns) do
~H"""
{@title}
{render_slot(@inner_block)}
"""
end
# Renders a labeled data field.
attr :label, :string, required: true
attr :value, :string, default: nil
attr :class, :string, default: ""
slot :inner_block
defp data_field(assigns) do
~H"""
{@label}
<%= if @inner_block != [] do %>
{render_slot(@inner_block)}
<% else %>
<%= if value_blank?(@value) do %>
<.empty_cell sr_text={gettext("Not set")} />
<% else %>
{@value}
<% end %>
<% end %>
"""
end
# Renders a mailto link if email is present, otherwise renders empty value placeholder
attr :email, :string, required: true
attr :display, :string, default: nil
defp mailto_link(assigns) do
display_text = assigns.display || assigns.email
if assigns.email && String.trim(assigns.email) != "" do
assigns = %{email: assigns.email, display: display_text}
~H"""
{@display}
"""
else
render_empty_value()
end
end
# -----------------------------------------------------------------
# Helper Functions
# -----------------------------------------------------------------
defp value_blank?(nil), do: true
defp value_blank?(v) when is_binary(v), do: String.trim(v) == ""
defp value_blank?(_), do: false
defp format_status_label(:paid), do: gettext("Paid")
defp format_status_label(:unpaid), do: gettext("Unpaid")
defp format_status_label(:suspended), do: gettext("Suspended")
defp format_status_label(nil), do: gettext("No status")
defp get_last_cycle_status(member) do
case MembershipFeeHelpers.get_last_completed_cycle(member) do
nil -> nil
cycle -> cycle.status
end
end
defp get_current_cycle_status(member) do
case MembershipFeeHelpers.get_current_cycle(member) do
nil -> nil
cycle -> cycle.status
end
end
defp format_address(member) do
street_part =
[member.street, member.house_number]
|> Enum.filter(&(&1 && &1 != ""))
|> Enum.join(" ")
city_part =
[member.postal_code, member.city]
|> Enum.filter(&(&1 && &1 != ""))
|> Enum.join(" ")
[member.country, street_part, city_part]
|> Enum.filter(&(&1 && &1 != ""))
|> Enum.join(", ")
|> case do
"" -> nil
address -> address
end
end
defp format_date(nil), do: nil
defp format_date(%Date{} = date) do
Calendar.strftime(date, "%d.%m.%Y")
end
defp format_date(date), do: to_string(date)
# Finds custom field value for a given custom field id
# Returns the value (not the CustomFieldValue struct) or nil
defp find_custom_field_value(nil, _custom_field_id), do: nil
defp find_custom_field_value(custom_field_values, custom_field_id)
when is_list(custom_field_values) do
Enum.find_value(custom_field_values, fn cfv ->
if cfv.custom_field_id == custom_field_id or
(cfv.custom_field && cfv.custom_field.id == custom_field_id) do
cfv.value
end
end)
end
defp find_custom_field_value(_custom_field_values, _custom_field_id), do: nil
# Formats custom field value based on type
# Handles both CustomFieldValue structs and direct values
defp format_custom_field_value(nil, _type), do: render_empty_value()
defp format_custom_field_value(%Mv.Membership.CustomFieldValue{} = cfv, value_type) do
format_custom_field_value(cfv.value, value_type)
end
defp format_custom_field_value(%Ash.Union{value: value, type: type}, _expected_type) do
format_custom_field_value(value, type)
end
defp format_custom_field_value(value, :boolean) when is_boolean(value) do
if value, do: gettext("Yes"), else: gettext("No")
end
defp format_custom_field_value(%Date{} = date, :date) do
Calendar.strftime(date, "%d.%m.%Y")
end
defp format_custom_field_value(value, :email) when is_binary(value) do
if String.trim(value) == "" do
render_empty_value()
else
assigns = %{email: value, display: value}
~H"""
<.mailto_link email={@email} display={@display} />
"""
end
end
defp format_custom_field_value(value, :integer) when is_integer(value) do
Integer.to_string(value)
end
defp format_custom_field_value(value, _type) when is_binary(value) do
if String.trim(value) == "", do: render_empty_value(), else: value
end
defp format_custom_field_value(value, _type), do: to_string(value)
# Renders accessible empty value: visually empty, screen-reader text only (see Design Guidelines ยง8.6).
# Returns safe HTML so it can be used from helpers without LiveView assigns.
defp render_empty_value do
text = gettext("Not set")
{:safe, ["", Phoenix.HTML.Engine.html_escape(text), ""]}
end
end