mitgliederverwaltung/lib/mv_web/live/member_live/show.ex
Moritz be8a396ab6
Improve payment data box layout and translations
Change 'Payment Cycle' to 'Payment Interval' for accuracy.
Adjust width constraints to use min-w-* instead of fixed w-*
for better responsiveness. Use flex-wrap for better layout.
2025-12-18 15:11:00 +01:00

409 lines
14 KiB
Elixir

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)
- Payment Data: Mockup section with placeholder data
## Navigation
- Back to member list
- Edit member (with return_to parameter for back navigation)
"""
use MvWeb, :live_view
import Ash.Query
alias MvWeb.Helpers.MembershipFeeHelpers
@impl true
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">
{@member.first_name} {@member.last_name}
</h1>
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}>
{gettext("Edit Member")}
</.button>
</div>
<%!-- Tab Navigation --%>
<div role="tablist" class="tabs tabs-bordered mb-6">
<button
role="tab"
class={["tab", if(@active_tab == :contact, do: "tab-active", else: "")]}
aria-selected={@active_tab == :contact}
phx-click="switch_tab"
phx-value-tab="contact"
>
<.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: "")]}
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>
<%= 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>
<%!-- 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>
<%!-- Phone --%>
<div>
<.data_field label={gettext("Phone")} value={@member.phone_number} />
</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 --%>
<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>
<%!-- 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?(@member.custom_field_values) do %>
<div>
<.section_box title={gettext("Custom Fields")}>
<div class="grid grid-cols-2 gap-4">
<%= for cfv <- sort_custom_field_values(@member.custom_field_values) do %>
<% custom_field = cfv.custom_field %>
<% value_type = custom_field && custom_field.value_type %>
<.data_field label={custom_field && custom_field.name}>
{format_custom_field_value(cfv.value, 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 %>
<% status = @member.last_cycle_status %>
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
{format_status_label(status)}
</span>
<% else %>
<span class="badge badge-ghost">{gettext("No cycles")}</span>
<% end %>
</.data_field>
<.data_field label={gettext("Current Cycle")} class="min-w-36">
<%= if @member.current_cycle_status do %>
<% status = @member.current_cycle_status %>
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
{format_status_label(status)}
</span>
<% else %>
<span class="badge badge-ghost">{gettext("No cycles")}</span>
<% 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 --%>
<.live_component
module={MvWeb.MemberLive.Show.MembershipFeesComponent}
id={"membership-fees-#{@member.id}"}
member={@member}
/>
<% end %>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, :active_tab, :contact)}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
query =
Mv.Membership.Member
|> filter(id == ^id)
|> load([
:user,
:membership_fee_type,
custom_field_values: [:custom_field],
membership_fee_cycles: [:membership_fee_type]
])
member = Ash.read_one!(query)
# 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
defp page_title(:show), do: gettext("Show Member")
defp page_title(:edit), do: gettext("Edit Member")
# -----------------------------------------------------------------
# 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"""
<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
# 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"""
<dl class={@class}>
<dt class="text-sm font-medium text-base-content/70">{@label}</dt>
<dd class="mt-1 text-base-content">
<%= if @inner_block != [] do %>
{render_slot(@inner_block)}
<% else %>
{display_value(@value)}
<% end %>
</dd>
</dl>
"""
end
# -----------------------------------------------------------------
# Helper Functions
# -----------------------------------------------------------------
defp display_value(nil), do: ""
defp display_value(""), do: ""
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)
# Sorts custom field values by custom field name
defp sort_custom_field_values(custom_field_values) do
Enum.sort_by(custom_field_values, fn cfv ->
(cfv.custom_field && cfv.custom_field.name) || ""
end)
end
# Formats custom field value based on type
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(nil, _type), do: ""
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
assigns = %{email: value}
~H"""
<a href={"mailto:#{@email}"} class="text-blue-700 hover:text-blue-800 underline">{@email}</a>
"""
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: "", else: value
end
defp format_custom_field_value(value, _type), do: to_string(value)
end