Redesign member view/edit UI with improved accessibility
- Group fields into Personal Data, Custom Fields, and Payment Data sections - Fix WCAG AA contrast issues and semantic HTML (dt/dd in dl) - Format mailto links with member name in href attribute
This commit is contained in:
parent
82f1a65b85
commit
ed961f7585
5 changed files with 922 additions and 1642 deletions
|
|
@ -3,19 +3,16 @@ defmodule MvWeb.MemberLive.Show do
|
|||
LiveView for displaying a single member's details.
|
||||
|
||||
## Features
|
||||
- Display all member information (personal, contact, address)
|
||||
- Show linked user account (if exists)
|
||||
- Display custom field values
|
||||
- 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
|
||||
|
||||
## Displayed Information
|
||||
- Basic: name, email, dates (join, exit)
|
||||
- Contact: phone number
|
||||
- Address: street, house number, postal code, city
|
||||
- Status: paid flag
|
||||
- Relationships: linked user account
|
||||
- Custom: dynamic custom field values from CustomFields
|
||||
## 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
|
||||
|
|
@ -23,69 +20,155 @@ defmodule MvWeb.MemberLive.Show do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
import Ash.Query
|
||||
alias MvWeb.Helpers.DateFormatter
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{@member.first_name} {@member.last_name}
|
||||
<:subtitle>{gettext("This is a member record from your database.")}</:subtitle>
|
||||
<%!-- 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>
|
||||
|
||||
<:actions>
|
||||
<.button navigate={~p"/members"} aria-label={gettext("Back to members list")}>
|
||||
<.icon name="hero-arrow-left" />
|
||||
<span class="sr-only">{gettext("Back to members list")}</span>
|
||||
</.button>
|
||||
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}>
|
||||
<.icon name="hero-pencil-square" /> {gettext("Edit Member")}
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
<h1 class="text-2xl font-bold text-center flex-1">
|
||||
{@member.first_name} {@member.last_name}
|
||||
</h1>
|
||||
|
||||
<.list>
|
||||
<:item title={gettext("Id")}>{@member.id}</:item>
|
||||
<:item title={gettext("First Name")}>{@member.first_name}</:item>
|
||||
<:item title={gettext("Last Name")}>{@member.last_name}</:item>
|
||||
<:item title={gettext("Email")}>{@member.email}</:item>
|
||||
<:item title={gettext("Paid")}>
|
||||
{if @member.paid, do: gettext("Yes"), else: gettext("No")}
|
||||
</:item>
|
||||
<:item title={gettext("Phone Number")}>{@member.phone_number}</:item>
|
||||
<:item title={gettext("Join Date")}>{DateFormatter.format_date(@member.join_date)}</:item>
|
||||
<:item title={gettext("Exit Date")}>{DateFormatter.format_date(@member.exit_date)}</:item>
|
||||
<:item title={gettext("Notes")}>{@member.notes}</:item>
|
||||
<:item title={gettext("City")}>{@member.city}</:item>
|
||||
<:item title={gettext("Street")}>{@member.street}</:item>
|
||||
<:item title={gettext("House Number")}>{@member.house_number}</:item>
|
||||
<:item title={gettext("Postal Code")}>{@member.postal_code}</:item>
|
||||
<:item title={gettext("Linked User")}>
|
||||
<%= if @member.user do %>
|
||||
<.link
|
||||
navigate={~p"/users/#{@member.user}"}
|
||||
class="text-blue-600 hover:text-blue-800 underline"
|
||||
>
|
||||
<.icon name="hero-user" class="h-4 w-4 inline mr-1" />
|
||||
{@member.user.email}
|
||||
</.link>
|
||||
<% else %>
|
||||
<span class="text-gray-500 italic">{gettext("No user linked")}</span>
|
||||
<% end %>
|
||||
</:item>
|
||||
</.list>
|
||||
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}>
|
||||
{gettext("Edit Member")}
|
||||
</.button>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Field Values")}</h3>
|
||||
<.generic_list items={
|
||||
Enum.map(@member.custom_field_values, fn cfv ->
|
||||
{
|
||||
# name
|
||||
cfv.custom_field && cfv.custom_field.name,
|
||||
# value
|
||||
format_custom_field_value(cfv)
|
||||
}
|
||||
end)
|
||||
} />
|
||||
<%!-- Tab Navigation --%>
|
||||
<div role="tablist" class="tabs tabs-bordered mb-6">
|
||||
<button role="tab" class="tab tab-active" aria-selected="true">
|
||||
<.icon name="hero-identification" class="size-4 mr-2" />
|
||||
{gettext("Contact Data")}
|
||||
</button>
|
||||
<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>
|
||||
<.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:#{format_email_mailto(@member.first_name, @member.last_name, @member.email)}"}
|
||||
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 (Mockup) --%>
|
||||
<div class="max-w-xl">
|
||||
<.section_box title={gettext("Payment Data")}>
|
||||
<div role="alert" class="alert alert-info mb-4">
|
||||
<.icon name="hero-information-circle" class="size-5" />
|
||||
<span>{gettext("This data is for demonstration purposes only (mockup).")}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6">
|
||||
<.data_field label={gettext("Contribution")} value="72 €" class="w-24" />
|
||||
<.data_field label={gettext("Payment Cycle")} value={gettext("monthly")} class="w-28" />
|
||||
<.data_field label={gettext("Paid")} class="w-24">
|
||||
<%= if @member.paid do %>
|
||||
<span class="badge badge-success">{gettext("Paid")}</span>
|
||||
<% else %>
|
||||
<span class="badge badge-warning">{gettext("Pending")}</span>
|
||||
<% end %>
|
||||
</.data_field>
|
||||
</div>
|
||||
</.section_box>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
|
@ -113,16 +196,132 @@ defmodule MvWeb.MemberLive.Show do
|
|||
defp page_title(:show), do: gettext("Show Member")
|
||||
defp page_title(:edit), do: gettext("Edit Member")
|
||||
|
||||
defp format_custom_field_value(cfv) do
|
||||
value =
|
||||
case cfv.value do
|
||||
%{value: v} -> v
|
||||
v -> v
|
||||
end
|
||||
# -----------------------------------------------------------------
|
||||
# Helper Components
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
case value do
|
||||
%Date{} = date -> DateFormatter.format_date(date)
|
||||
other -> other
|
||||
# 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_email_mailto(first_name, last_name, email) do
|
||||
name =
|
||||
[first_name, last_name]
|
||||
|> Enum.filter(&(&1 && &1 != ""))
|
||||
|> Enum.join(" ")
|
||||
|
||||
if name != "" do
|
||||
"#{name} <#{email}>"
|
||||
else
|
||||
email
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue