feat: restyle tabs and move delete to edit view
This commit is contained in:
parent
ff9f98f8e7
commit
02af136fd9
8 changed files with 361 additions and 276 deletions
|
|
@ -487,7 +487,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
# Get boolean filter label (comma-separated list of active filter names)
|
# Get boolean filter label (comma-separated list of active filter names)
|
||||||
defp boolean_filter_label(_boolean_custom_fields, boolean_filters)
|
defp boolean_filter_label(_boolean_custom_fields, boolean_filters)
|
||||||
when map_size(boolean_filters) == 0 do
|
when map_size(boolean_filters) == 0 do
|
||||||
gettext("All")
|
gettext("Apply filters")
|
||||||
end
|
end
|
||||||
|
|
||||||
defp boolean_filter_label(boolean_custom_fields, boolean_filters) do
|
defp boolean_filter_label(boolean_custom_fields, boolean_filters) do
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
- Create new members with personal information
|
- Create new members with personal information
|
||||||
- Edit existing member details
|
- Edit existing member details
|
||||||
- Grouped sections for better organization
|
- Grouped sections for better organization
|
||||||
- Tab navigation (Payments tab disabled, coming soon)
|
|
||||||
- Manage custom properties (dynamic fields, displayed sorted by name)
|
- Manage custom properties (dynamic fields, displayed sorted by name)
|
||||||
- Real-time validation with visual feedback
|
- Real-time validation with visual feedback
|
||||||
|
|
||||||
|
|
@ -56,23 +55,12 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<div class="mt-6 space-y-6">
|
<div class="mt-6 space-y-6">
|
||||||
<%!-- Tab Navigation --%>
|
<%!-- Tab navigation: Payments tab not shown on new/edit (only on member show) --%>
|
||||||
<div role="tablist" class="tabs tabs-bordered">
|
<div role="tablist" class="tabs tabs-bordered">
|
||||||
<button type="button" role="tab" class="tab tab-active" aria-selected="true">
|
<button type="button" role="tab" class="tab tab-active" aria-selected="true">
|
||||||
<.icon name="hero-identification" class="size-4 mr-2" />
|
<.icon name="hero-identification" class="size-4 mr-2" />
|
||||||
{gettext("Contact Data")}
|
{gettext("Contact Data")}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<%!-- Personal Data and Custom Fields Row --%>
|
<%!-- Personal Data and Custom Fields Row --%>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
- `sort_order` - Sort direction (:asc or :desc)
|
- `sort_order` - Sort direction (:asc or :desc)
|
||||||
|
|
||||||
## Events
|
## Events
|
||||||
- `delete` - Remove a member from the database
|
|
||||||
- `select_member` - Toggle individual member selection
|
- `select_member` - Toggle individual member selection
|
||||||
- `select_all` - Toggle selection of all visible members
|
- `select_all` - Toggle selection of all visible members
|
||||||
- `copy_emails` - Copy email addresses of selected members to clipboard
|
- `copy_emails` - Copy email addresses of selected members to clipboard
|
||||||
|
|
@ -157,50 +156,10 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
Handles member-related UI events.
|
Handles member-related UI events.
|
||||||
|
|
||||||
## Supported events:
|
## Supported events:
|
||||||
- `"delete"` - Removes a member from the database
|
|
||||||
- `"select_member"` - Toggles individual member selection
|
- `"select_member"` - Toggles individual member selection
|
||||||
- `"select_all"` - Toggles selection of all visible members
|
- `"select_all"` - Toggles selection of all visible members
|
||||||
- `"sort"` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
|
- `"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(:success, 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
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("select_member", %{"id" => id}, socket) do
|
def handle_event("select_member", %{"id" => id}, socket) do
|
||||||
selected =
|
selected =
|
||||||
|
|
@ -343,22 +302,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
{:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)}
|
{:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)}
|
||||||
end
|
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
|
# Handle Infos from Child Components
|
||||||
# -----------------------------------------------------------------
|
# -----------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -379,26 +379,10 @@
|
||||||
</:col>
|
</:col>
|
||||||
<:action :let={member}>
|
<:action :let={member}>
|
||||||
<div class="sr-only">
|
<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>
|
</div>
|
||||||
|
|
||||||
<%= if can?(@current_user, :update, member) do %>
|
|
||||||
<.link navigate={~p"/members/#{member}/edit"} data-testid="member-edit">
|
|
||||||
{gettext("Edit member")}
|
|
||||||
</.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>
|
</:action>
|
||||||
</.table>
|
</.table>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
|
||||||
|
|
@ -54,217 +54,283 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<div class="mt-6 space-y-6">
|
<div class="mt-6 space-y-6">
|
||||||
<%!-- Tab Navigation --%>
|
<%!-- Tab Navigation: surface only behind buttons (inline); tabs-bordered; tabs-lg; both tabs keyboard-focusable --%>
|
||||||
<div role="tablist" class="tabs tabs-bordered">
|
<div
|
||||||
|
role="tablist"
|
||||||
|
class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
|
id="member-tab-contact"
|
||||||
role="tab"
|
role="tab"
|
||||||
class={[
|
type="button"
|
||||||
"tab",
|
tabindex="0"
|
||||||
if(@active_tab == :contact, do: "tab-active", else: "!text-gray-800")
|
|
||||||
]}
|
|
||||||
aria-selected={@active_tab == :contact}
|
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-click="switch_tab"
|
||||||
phx-value-tab="contact"
|
phx-value-tab="contact"
|
||||||
>
|
>
|
||||||
<.icon name="hero-identification" class="size-4 mr-2" />
|
<.icon name="hero-identification" class="size-4 shrink-0" />
|
||||||
{gettext("Contact Data")}
|
{gettext("Contact Data")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
id="member-tab-membership_fees"
|
||||||
role="tab"
|
role="tab"
|
||||||
class={[
|
type="button"
|
||||||
"tab",
|
tabindex="0"
|
||||||
if(@active_tab == :membership_fees, do: "tab-active", else: "!text-gray-800")
|
|
||||||
]}
|
|
||||||
aria-selected={@active_tab == :membership_fees}
|
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-click="switch_tab"
|
||||||
phx-value-tab="membership_fees"
|
phx-value-tab="membership_fees"
|
||||||
>
|
>
|
||||||
<.icon name="hero-credit-card" class="size-4 mr-2" />
|
<.icon name="hero-credit-card" class="size-4 shrink-0" />
|
||||||
{gettext("Membership Fees")}
|
{gettext("Membership Fees")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= if @active_tab == :contact do %>
|
<%= if @active_tab == :contact do %>
|
||||||
<%!-- Contact Data Tab Content --%>
|
<%!-- Contact Data Tab Content --%>
|
||||||
<%!-- Personal Data and Custom Fields Row --%>
|
<div
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
id="member-tabpanel-contact"
|
||||||
<%!-- Personal Data Section --%>
|
role="tabpanel"
|
||||||
<div>
|
aria-labelledby="member-tab-contact"
|
||||||
<.section_box title={gettext("Personal Data")}>
|
>
|
||||||
<div class="space-y-4">
|
<%!-- Personal Data and Custom Fields Row --%>
|
||||||
<%!-- Name Row --%>
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
<div class="flex gap-6">
|
<%!-- Personal Data Section --%>
|
||||||
<.data_field
|
<div>
|
||||||
label={gettext("First Name")}
|
<.section_box title={gettext("Personal Data")}>
|
||||||
value={@member.first_name}
|
<div class="space-y-4">
|
||||||
class="w-48"
|
<%!-- Name Row --%>
|
||||||
/>
|
<div class="flex gap-6">
|
||||||
<.data_field label={gettext("Last Name")} value={@member.last_name} class="w-48" />
|
<.data_field
|
||||||
</div>
|
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 --%>
|
<%!-- Address --%>
|
||||||
<div>
|
<div>
|
||||||
<.data_field label={gettext("Address")} value={format_address(@member)} />
|
<.data_field label={gettext("Address")} value={format_address(@member)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Email --%>
|
<%!-- Email --%>
|
||||||
<div>
|
<div>
|
||||||
<.data_field label={gettext("Email")}>
|
<.data_field label={gettext("Email")}>
|
||||||
<a
|
<a
|
||||||
href={"mailto:#{MvWeb.MemberLive.Index.format_member_email(@member)}"}
|
href={"mailto:#{MvWeb.MemberLive.Index.format_member_email(@member)}"}
|
||||||
class="text-blue-700 hover:text-blue-800 underline"
|
class="text-blue-700 hover:text-blue-800 underline"
|
||||||
>
|
>
|
||||||
{@member.email}
|
{@member.email}
|
||||||
</a>
|
</a>
|
||||||
</.data_field>
|
</.data_field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Membership Dates Row --%>
|
<%!-- Membership Dates Row --%>
|
||||||
<div class="flex gap-6">
|
<div class="flex gap-6">
|
||||||
<.data_field
|
<.data_field
|
||||||
label={gettext("Join Date")}
|
label={gettext("Join Date")}
|
||||||
value={format_date(@member.join_date)}
|
value={format_date(@member.join_date)}
|
||||||
class="w-28"
|
class="w-28"
|
||||||
/>
|
/>
|
||||||
<.data_field
|
<.data_field
|
||||||
label={gettext("Exit Date")}
|
label={gettext("Exit Date")}
|
||||||
value={format_date(@member.exit_date)}
|
value={format_date(@member.exit_date)}
|
||||||
class="w-28"
|
class="w-28"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
read_only cannot see linked user, so hide the section to avoid "No user linked" when
|
||||||
a user is linked but not visible. --%>
|
a user is linked but not visible. --%>
|
||||||
<%= if can_access_page?(@current_user, "/users") do %>
|
<%= 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>
|
<div>
|
||||||
<.data_field label={gettext("Linked User")}>
|
<.data_field label={gettext("Groups")}>
|
||||||
<%= if @member.user do %>
|
<%= if Enum.empty?(groups) do %>
|
||||||
<.link
|
<span class="text-base-content/70 italic">{gettext("No groups")}</span>
|
||||||
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 %>
|
<% else %>
|
||||||
<span class="text-base-content/70 italic">{gettext("No user linked")}</span>
|
<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 %>
|
<% end %>
|
||||||
</.data_field>
|
</.data_field>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%!-- Groups (in Personal Data) --%>
|
<%!-- Notes --%>
|
||||||
<% groups = @member.groups || [] %>
|
<%= if @member.notes && String.trim(@member.notes) != "" do %>
|
||||||
<div>
|
<div>
|
||||||
<.data_field label={gettext("Groups")}>
|
<.data_field label={gettext("Notes")}>
|
||||||
<%= if Enum.empty?(groups) do %>
|
<p class="whitespace-pre-wrap text-base-content/80">{@member.notes}</p>
|
||||||
<span class="text-base-content/70 italic">{gettext("No groups")}</span>
|
</.data_field>
|
||||||
<% else %>
|
</div>
|
||||||
<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 %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</.section_box>
|
</.section_box>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Payment Data Section --%>
|
<%!-- Custom Fields Section --%>
|
||||||
<div class="w-full">
|
<%= if Enum.any?(@custom_fields) do %>
|
||||||
<.section_box title={gettext("Payment Data")}>
|
<div>
|
||||||
<%= if @member.membership_fee_type do %>
|
<.section_box title={gettext("Custom Fields")}>
|
||||||
<div class="flex gap-6 flex-wrap">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<.data_field
|
<%= for custom_field <- @custom_fields do %>
|
||||||
label={gettext("Type")}
|
<% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %>
|
||||||
value={@member.membership_fee_type.name}
|
<.data_field label={custom_field.name}>
|
||||||
class="min-w-32"
|
{format_custom_field_value(cfv, custom_field.value_type)}
|
||||||
/>
|
</.data_field>
|
||||||
<.data_field
|
<% end %>
|
||||||
label={gettext("Membership Fee")}
|
</div>
|
||||||
value={MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)}
|
</.section_box>
|
||||||
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>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</.section_box>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= if @active_tab == :membership_fees do %>
|
<%= if @active_tab == :membership_fees do %>
|
||||||
<%!-- Membership Fees Tab Content --%>
|
<%!-- Membership Fees Tab Content --%>
|
||||||
<.live_component
|
<div
|
||||||
module={MvWeb.MemberLive.Show.MembershipFeesComponent}
|
id="member-tabpanel-membership_fees"
|
||||||
id={"membership-fees-#{@member.id}"}
|
role="tabpanel"
|
||||||
member={@member}
|
aria-labelledby="member-tab-membership_fees"
|
||||||
current_user={@current_user}
|
>
|
||||||
vereinfacht_receipts={@vereinfacht_receipts}
|
<.live_component
|
||||||
/>
|
module={MvWeb.MemberLive.Show.MembershipFeesComponent}
|
||||||
|
id={"membership-fees-#{@member.id}"}
|
||||||
|
member={@member}
|
||||||
|
current_user={@current_user}
|
||||||
|
vereinfacht_receipts={@vereinfacht_receipts}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<% 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 %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
@ -328,6 +394,35 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
{:noreply, assign(socket, :active_tab, :membership_fees)}
|
{:noreply, assign(socket, :active_tab, :membership_fees)}
|
||||||
end
|
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
|
def handle_event("load_vereinfacht_receipts", %{"contact_id" => contact_id}, socket) do
|
||||||
response =
|
response =
|
||||||
case Mv.Vereinfacht.Client.get_contact_with_receipts(contact_id) do
|
case Mv.Vereinfacht.Client.get_contact_with_receipts(contact_id) do
|
||||||
|
|
@ -358,6 +453,19 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
defp page_title(:show), do: gettext("Show Member")
|
defp page_title(:show), do: gettext("Show Member")
|
||||||
defp page_title(:edit), do: gettext("Edit 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
|
# Helper Components
|
||||||
# -----------------------------------------------------------------
|
# -----------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,38 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
|
||||||
Tests for error handling in the member form, specifically flash message display.
|
Tests for error handling in the member form, specifically flash message display.
|
||||||
"""
|
"""
|
||||||
use MvWeb.ConnCase, async: false
|
use MvWeb.ConnCase, async: false
|
||||||
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
describe "tab visibility" do
|
||||||
|
@tag :ui
|
||||||
|
test "Payments tab is not visible on new member form", %{conn: conn} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, _view, html} = live(conn, "/members/new")
|
||||||
|
|
||||||
|
refute html =~ gettext("Payments")
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag :ui
|
||||||
|
test "Payments tab is not visible on edit member form", %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, member} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Edit", last_name: "Member", email: "edit@example.com"},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/members/#{member}/edit")
|
||||||
|
|
||||||
|
refute html =~ gettext("Payments")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "error handling - flash messages" do
|
describe "error handling - flash messages" do
|
||||||
setup do
|
setup do
|
||||||
{:ok, settings} = Mv.Membership.get_settings()
|
{:ok, settings} = Mv.Membership.get_settings()
|
||||||
|
|
|
||||||
|
|
@ -266,36 +266,42 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
assert is_list(state.socket.assigns.members)
|
assert is_list(state.socket.assigns.members)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can delete a member without error", %{conn: conn} do
|
@tag :ui
|
||||||
|
test "member index does not render Edit or Delete actions", %{conn: conn} do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Create a test member first
|
{:ok, _member} =
|
||||||
{:ok, member} =
|
|
||||||
Mv.Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{
|
%{first_name: "Test", last_name: "User", email: "test@example.com"},
|
||||||
first_name: "Test",
|
|
||||||
last_name: "User",
|
|
||||||
email: "test@example.com"
|
|
||||||
},
|
|
||||||
actor: system_actor
|
actor: system_actor
|
||||||
)
|
)
|
||||||
|
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, index_view, _html} = live(conn, "/members")
|
{:ok, view, html} = live(conn, "/members")
|
||||||
|
|
||||||
# Verify the member is displayed
|
refute has_element?(view, "[data-testid='member-edit']")
|
||||||
assert has_element?(index_view, "#members", "Test User")
|
refute html =~ ~s(data-testid="member-delete")
|
||||||
|
end
|
||||||
|
|
||||||
# Click the delete link for this member
|
@tag :ui
|
||||||
index_view
|
test "row click navigates to member show", %{conn: conn} do
|
||||||
|> element("a", "Delete")
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, member} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Row", last_name: "Click", email: "rowclick@example.com"},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
|
# Click a data cell (e.g. second column = first name) to trigger row navigation
|
||||||
|
view
|
||||||
|
|> element("#row-#{member.id} td:nth-child(2)")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Verify the member is no longer displayed
|
assert_redirect(view, ~p"/members/#{member}")
|
||||||
refute has_element?(index_view, "#members", "Test User")
|
|
||||||
|
|
||||||
# Verify the member was actually deleted from the database
|
|
||||||
assert not (Mv.Membership.Member |> Ash.Query.filter(id == ^member.id) |> Ash.exists?())
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "copy_emails feature" do
|
describe "copy_emails feature" do
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,35 @@ defmodule MvWeb.MemberLive.ShowTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "delete action" do
|
||||||
|
test "renders Delete button when user can destroy member", %{
|
||||||
|
conn: conn,
|
||||||
|
member: member
|
||||||
|
} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members/#{member}")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='member-delete']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "delete event removes member and redirects to index", %{
|
||||||
|
conn: conn,
|
||||||
|
member: member
|
||||||
|
} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members/#{member}")
|
||||||
|
|
||||||
|
view
|
||||||
|
|> render_click("delete", %{"id" => member.id})
|
||||||
|
|
||||||
|
assert_redirect(view, ~p"/members")
|
||||||
|
|
||||||
|
refute Mv.Membership.Member
|
||||||
|
|> Ash.Query.filter(id == ^member.id)
|
||||||
|
|> Ash.exists?()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "custom field value formatting" do
|
describe "custom field value formatting" do
|
||||||
test "formats string custom field values", %{conn: conn, member: member, actor: actor} do
|
test "formats string custom field values", %{conn: conn, member: member, actor: actor} do
|
||||||
{:ok, custom_field} =
|
{:ok, custom_field} =
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue