feat(member): deactivate and reactivate members via an exit-date dialog
This commit is contained in:
parent
bcab2e21c4
commit
3dc3a2b8ef
6 changed files with 1735 additions and 1326 deletions
203
lib/mv_web/live/member_live/show/deactivate_component.ex
Normal file
203
lib/mv_web/live/member_live/show/deactivate_component.ex
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue