203 lines
6.6 KiB
Elixir
203 lines
6.6 KiB
Elixir
defmodule MvWeb.MemberLive.Show.DeactivateComponent do
|
|
@moduledoc """
|
|
LiveComponent owning the member deactivate/reactivate sub-flow on the member show page.
|
|
|
|
## Features
|
|
- Deactivate control (shown when the member has no exit_date)
|
|
- Reactivate control (shown when the member has an exit_date)
|
|
- Date-selection modal (default: today, future dates allowed) for deactivation
|
|
- Routes both actions through `Membership.update_member/2` with the real user actor,
|
|
inheriting the `exit_date > join_date` validation and the cycle-regeneration hook.
|
|
|
|
Controls are gated on `:update` permission for the member. On success the component
|
|
notifies the parent with `{:member_updated, member}`, which the parent already handles.
|
|
"""
|
|
use MvWeb, :live_component
|
|
|
|
import MvWeb.Authorization, only: [can?: 3]
|
|
|
|
alias Mv.Membership
|
|
alias MvWeb.Helpers.MemberHelpers
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<div id={@id}>
|
|
<%= if @can_update do %>
|
|
<section class="mt-8 mb-6" aria-labelledby="membership-state-heading">
|
|
<h2 id="membership-state-heading" class="text-lg font-semibold mb-3">
|
|
{gettext("Membership status")}
|
|
</h2>
|
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
|
<%= if @member.exit_date do %>
|
|
<p class="text-base-content/70 mb-4">
|
|
{gettext(
|
|
"This member is deactivated (exit date set). Reactivating clears the exit date."
|
|
)}
|
|
</p>
|
|
<.button
|
|
id="reactivate-member-trigger"
|
|
data-testid="member-reactivate"
|
|
variant="primary"
|
|
phx-click="reactivate"
|
|
phx-target={@myself}
|
|
aria-label={
|
|
gettext("Reactivate member %{name}", name: MemberHelpers.display_name(@member))
|
|
}
|
|
>
|
|
<.icon name="hero-arrow-uturn-left" class="size-4" />
|
|
{gettext("Reactivate member")}
|
|
</.button>
|
|
<% else %>
|
|
<p class="text-base-content/70 mb-4">
|
|
{gettext(
|
|
"Deactivating this member records an exit date. You can reactivate them later."
|
|
)}
|
|
</p>
|
|
<.button
|
|
id="deactivate-member-trigger"
|
|
data-testid="member-deactivate"
|
|
variant="outline"
|
|
phx-click="open_modal"
|
|
phx-target={@myself}
|
|
aria-label={
|
|
gettext("Deactivate member %{name}", name: MemberHelpers.display_name(@member))
|
|
}
|
|
>
|
|
<.icon name="hero-arrow-right-on-rectangle" class="size-4" />
|
|
{gettext("Deactivate member")}
|
|
</.button>
|
|
<% end %>
|
|
</div>
|
|
</section>
|
|
<% end %>
|
|
|
|
<%= if @show_modal do %>
|
|
<dialog
|
|
id="deactivate-member-modal"
|
|
class="modal modal-open"
|
|
role="dialog"
|
|
aria-labelledby="deactivate-member-modal-title"
|
|
phx-keydown="dialog_keydown"
|
|
phx-target={@myself}
|
|
>
|
|
<div class="modal-box">
|
|
<h3 id="deactivate-member-modal-title" class="text-lg font-bold">
|
|
{gettext("When did this member leave?")}
|
|
</h3>
|
|
<form phx-submit="deactivate" phx-target={@myself}>
|
|
<.input
|
|
type="date"
|
|
id="deactivate-exit-date"
|
|
name="exit_date"
|
|
label={gettext("Exit date")}
|
|
value={@exit_date}
|
|
errors={if @error, do: [@error], else: []}
|
|
required
|
|
phx-mounted={JS.focus()}
|
|
/>
|
|
<div class="modal-action">
|
|
<.button
|
|
type="button"
|
|
variant="neutral"
|
|
phx-click="cancel_modal"
|
|
phx-target={@myself}
|
|
>
|
|
{gettext("Cancel")}
|
|
</.button>
|
|
<.button type="submit" variant="primary">{gettext("Deactivate")}</.button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</dialog>
|
|
<% end %>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
@impl true
|
|
def update(assigns, socket) do
|
|
{:ok,
|
|
socket
|
|
|> assign(assigns)
|
|
|> assign(:can_update, can?(assigns.current_user, :update, assigns.member))
|
|
|> assign_new(:show_modal, fn -> false end)
|
|
|> assign_new(:exit_date, fn -> Date.utc_today() end)
|
|
|> assign_new(:error, fn -> nil end)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("open_modal", _params, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:show_modal, true)
|
|
|> assign(:exit_date, Date.utc_today())
|
|
|> assign(:error, nil)}
|
|
end
|
|
|
|
def handle_event("cancel_modal", _params, socket) do
|
|
{:noreply, close_modal(socket)}
|
|
end
|
|
|
|
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
|
|
{:noreply, close_modal(socket)}
|
|
end
|
|
|
|
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
|
|
|
|
def handle_event("deactivate", %{"exit_date" => exit_date_str}, socket) do
|
|
case Date.from_iso8601(exit_date_str) do
|
|
{:ok, exit_date} ->
|
|
apply_exit_date(socket, exit_date, show_inline_error: true)
|
|
|
|
{:error, _reason} ->
|
|
{:noreply, assign(socket, :error, gettext("Invalid date format"))}
|
|
end
|
|
end
|
|
|
|
def handle_event("reactivate", _params, socket) do
|
|
apply_exit_date(socket, nil, show_inline_error: false)
|
|
end
|
|
|
|
defp apply_exit_date(socket, exit_date, opts) do
|
|
member = socket.assigns.member
|
|
actor = socket.assigns.current_user
|
|
|
|
case Membership.update_member(member, %{exit_date: exit_date}, actor: actor) do
|
|
{:ok, updated_member} ->
|
|
send(self(), {:member_updated, updated_member})
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:member, updated_member)
|
|
|> close_modal()}
|
|
|
|
{:error, error} ->
|
|
if opts[:show_inline_error] do
|
|
{:noreply, assign(socket, :error, format_error(error))}
|
|
else
|
|
send(self(), {:put_flash, :error, format_error(error)})
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp close_modal(socket) do
|
|
socket
|
|
|> assign(:show_modal, false)
|
|
|> assign(:error, nil)
|
|
end
|
|
|
|
defp format_error(%Ash.Error.Invalid{errors: errors}) do
|
|
Enum.map_join(errors, ", ", fn
|
|
%{message: message} -> message
|
|
other -> inspect(other)
|
|
end)
|
|
end
|
|
|
|
defp format_error(%Ash.Error.Forbidden{}) do
|
|
gettext("You are not allowed to perform this action.")
|
|
end
|
|
|
|
defp format_error(_error), do: gettext("An error occurred")
|
|
end
|