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> {MvWeb.Helpers.MemberHelpers.display_name(@member)} <:actions> <.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")} <%= if can?(@current_user, :update, @member) do %> <.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"} data-testid="member-edit" > {gettext("Edit member")} <% end %>
<%!-- Tab Navigation: surface only behind buttons (inline); tabs-bordered; tabs-lg; both tabs keyboard-focusable --%>
<%= if @active_tab == :contact do %> <%!-- Contact Data Tab Content --%>
<%!-- Personal Data and Custom Fields Row --%>
<%!-- Personal Data Section --%>
<.section_box title={gettext("Personal Data")}>
<%!-- Name Row --%>
<.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" />
<%!-- Address --%>
<.data_field label={gettext("Address")} value={format_address(@member)} />
<%!-- Email --%>
<.data_field label={gettext("Email")}> {@member.email}
<%!-- Membership Dates Row --%>
<.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" />
<%!-- 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 %>
<.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 %> <% status = @member.last_cycle_status %> {format_status_label(status)} <% else %> {gettext("No cycles")} <% end %> <.data_field label={gettext("Current Cycle")} class="min-w-36"> <%= if @member.current_cycle_status do %> <% status = @member.current_cycle_status %> {format_status_label(status)} <% else %> {gettext("No cycles")} <% end %>
<% else %>
{gettext("No membership fee type assigned")}
<% end %>
<% 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 %>

{gettext("Danger zone")}

{gettext( "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed." )}

<.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")}
<% end %>
""" end @impl true def mount(_params, _session, socket) do {:ok, socket |> assign(:active_tab, :contact) |> assign(:vereinfacht_receipts, nil)} 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("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} -> {: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 {:ok, receipts} -> {:ok, receipts} {:error, reason} -> {:error, reason} end {:noreply, assign(socket, :vereinfacht_receipts, response)} 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 %> {display_value(@value)} <% 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 display_value(nil), do: render_empty_value() defp display_value(""), do: render_empty_value() defp display_value(value), do: value 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(" ") [street_part, city_part] |> Enum.filter(&(&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} ~H""" <.mailto_link email={@email} display={@email} /> """ 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 placeholder for empty values # Uses translated text for screen readers while maintaining visual consistency # The visual "—" is hidden from screen readers, while the translated text is only visible to screen readers defp render_empty_value do assigns = %{text: gettext("Not set")} ~H""" {@text} """ end end