feat(member): deactivate and reactivate members via an exit-date dialog

This commit is contained in:
Moritz 2026-06-08 12:17:02 +02:00
parent bcab2e21c4
commit 3dc3a2b8ef
6 changed files with 1735 additions and 1326 deletions

View file

@ -0,0 +1,203 @@
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