Replace dropdown with action buttons in cycles view

Replace dropdown menu with individual buttons for status changes.
Buttons are only shown when the status transition is possible.
Make amount clickable to edit instead of separate button.
This commit is contained in:
Moritz 2025-12-16 17:24:26 +01:00
parent 9a1f0fbfa6
commit 128866ead3
Signed by: moritz
GPG key ID: 1020A035E5DD0824

View file

@ -16,7 +16,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
alias Mv.Membership alias Mv.Membership
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.CycleGenerator alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.CalendarCycles
alias MvWeb.Helpers.MembershipFeeHelpers alias MvWeb.Helpers.MembershipFeeHelpers
@impl true @impl true
@ -56,6 +58,26 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<.icon name="hero-arrow-path" class="size-4" /> <.icon name="hero-arrow-path" class="size-4" />
{if(@regenerating, do: gettext("Regenerating..."), else: gettext("Regenerate Cycles"))} {if(@regenerating, do: gettext("Regenerating..."), else: gettext("Regenerate Cycles"))}
</.button> </.button>
<.button
:if={Enum.any?(@cycles)}
phx-click="delete_all_cycles"
phx-target={@myself}
class="btn btn-sm btn-error btn-outline"
title={gettext("Delete all cycles")}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete All Cycles")}
</.button>
<.button
:if={@member.membership_fee_type}
phx-click="open_create_cycle_modal"
phx-target={@myself}
class="btn btn-sm btn-primary"
title={gettext("Create a new cycle manually")}
>
<.icon name="hero-plus" class="size-4" />
{gettext("Create Cycle")}
</.button>
</div> </div>
<%!-- Cycles Table --%> <%!-- Cycles Table --%>
@ -79,7 +101,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</:col> </:col>
<:col :let={cycle} label={gettext("Amount")}> <:col :let={cycle} label={gettext("Amount")}>
<span class="font-mono">{MembershipFeeHelpers.format_currency(cycle.amount)}</span> <span
class="font-mono cursor-pointer hover:text-primary"
phx-click="edit_cycle_amount"
phx-value-cycle_id={cycle.id}
phx-target={@myself}
title={gettext("Click to edit amount")}
>
{MembershipFeeHelpers.format_currency(cycle.amount)}
</span>
</:col> </:col>
<:col :let={cycle} label={gettext("Status")}> <:col :let={cycle} label={gettext("Status")}>
@ -92,80 +122,59 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</:col> </:col>
<:action :let={cycle}> <:action :let={cycle}>
<div class="dropdown dropdown-end"> <div class="flex gap-1 flex-wrap">
<label tabindex="0" class="btn btn-ghost btn-xs"> <button
<.icon name="hero-ellipsis-vertical" class="size-4" /> :if={cycle.status != :paid}
</label> type="button"
<ul phx-click="mark_cycle_status"
tabindex="0" phx-value-cycle_id={cycle.id}
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow" phx-value-status="paid"
phx-target={@myself}
class="btn btn-sm btn-success"
title={gettext("Mark as paid")}
> >
<li> <.icon name="hero-check-circle" class="size-4" />
<button {gettext("Paid")}
type="button" </button>
phx-click="mark_cycle_status" <button
phx-value-cycle_id={cycle.id} type="button"
phx-value-status="paid" phx-click="mark_cycle_status"
phx-target={@myself} phx-value-cycle_id={cycle.id}
class={if(cycle.status == :paid, do: "active", else: "")} phx-value-status="suspended"
> phx-target={@myself}
<.icon name="hero-check-circle" class="size-4" /> class={[
{gettext("Mark as paid")} "btn btn-sm",
</button> if(cycle.status == :suspended, do: "btn-warning", else: "btn-outline btn-warning")
</li> ]}
<li> title={gettext("Mark as suspended")}
<button >
type="button" <.icon name="hero-pause-circle" class="size-4" />
phx-click="mark_cycle_status" {gettext("Suspended")}
phx-value-cycle_id={cycle.id} </button>
phx-value-status="unpaid" <button
phx-target={@myself} :if={cycle.status != :unpaid}
class={if(cycle.status == :unpaid, do: "active", else: "")} type="button"
> phx-click="mark_cycle_status"
<.icon name="hero-x-circle" class="size-4" /> phx-value-cycle_id={cycle.id}
{gettext("Mark as unpaid")} phx-value-status="unpaid"
</button> phx-target={@myself}
</li> class="btn btn-sm btn-error"
<li> title={gettext("Mark as unpaid")}
<button >
type="button" <.icon name="hero-x-circle" class="size-4" />
phx-click="mark_cycle_status" {gettext("Unpaid")}
phx-value-cycle_id={cycle.id} </button>
phx-value-status="suspended" <button
phx-target={@myself} type="button"
class={if(cycle.status == :suspended, do: "active", else: "")} phx-click="delete_cycle"
> phx-value-cycle_id={cycle.id}
<.icon name="hero-pause-circle" class="size-4" /> phx-target={@myself}
{gettext("Mark as suspended")} class="btn btn-sm btn-error btn-outline"
</button> title={gettext("Delete cycle")}
</li> >
<li> <.icon name="hero-trash" class="size-4" />
<hr class="my-1" /> {gettext("Delete")}
</li> </button>
<li>
<button
type="button"
phx-click="edit_cycle_amount"
phx-value-cycle_id={cycle.id}
phx-target={@myself}
>
<.icon name="hero-pencil" class="size-4" />
{gettext("Edit amount")}
</button>
</li>
<li>
<button
type="button"
phx-click="delete_cycle"
phx-value-cycle_id={cycle.id}
phx-target={@myself}
class="text-error"
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete")}
</button>
</li>
</ul>
</div> </div>
</:action> </:action>
</.table> </.table>
@ -243,6 +252,130 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</div> </div>
</dialog> </dialog>
<% end %> <% end %>
<%!-- Delete All Cycles Confirmation Modal --%>
<%= if @deleting_all_cycles do %>
<dialog id="delete-all-cycles-modal" class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold text-error">{gettext("Delete All Cycles")}</h3>
<div class="alert alert-warning mt-4">
<.icon name="hero-exclamation-triangle" class="size-5" />
<div>
<h4 class="font-bold">{gettext("Warning")}</h4>
<p>
{gettext("You are about to delete all %{count} cycles for this member.",
count: length(@cycles)
)}
</p>
<p class="mt-2">
{gettext("This action cannot be undone.")}
</p>
</div>
</div>
<div class="form-control w-full mt-4">
<label class="label">
<span class="label-text">
{gettext("Type '%{confirmation}' to confirm", confirmation: gettext("Yes"))}
</span>
</label>
<input
type="text"
phx-keyup="update_delete_all_confirmation"
phx-target={@myself}
value={@delete_all_confirmation || ""}
class="input input-bordered w-full"
placeholder={gettext("Yes")}
/>
</div>
<div class="modal-action">
<button phx-click="cancel_delete_all_cycles" phx-target={@myself} class="btn">
{gettext("Cancel")}
</button>
<button
phx-click="confirm_delete_all_cycles"
phx-target={@myself}
class="btn btn-error"
disabled={
@delete_all_confirmation != gettext("Yes") && @delete_all_confirmation != "Yes"
}
>
{gettext("Delete All")}
</button>
</div>
</div>
</dialog>
<% end %>
<%!-- Create Cycle Modal --%>
<%= if @creating_cycle do %>
<dialog id="create-cycle-modal" class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold">{gettext("Create Cycle")}</h3>
<form phx-submit="create_cycle" phx-target={@myself}>
<div class="form-control w-full mt-4">
<label class="label">
<span class="label-text">{gettext("Date")}</span>
</label>
<input
type="date"
name="date"
value={@create_cycle_date || ""}
phx-change="update_create_cycle_date"
phx-target={@myself}
class="input input-bordered w-full"
required
/>
<label class="label">
<span class="label-text-alt">
{gettext(
"The cycle period will be calculated based on this date and the interval."
)}
</span>
</label>
</div>
<%= if @create_cycle_date do %>
<div class="form-control w-full mt-4">
<label class="label">
<span class="label-text">{gettext("Cycle Period")}</span>
</label>
<div class="text-sm text-base-content/70">
{format_create_cycle_period(
@create_cycle_date,
@member.membership_fee_type.interval
)}
</div>
</div>
<% end %>
<div class="form-control w-full mt-4">
<label class="label">
<span class="label-text">{gettext("Amount")}</span>
</label>
<input
type="number"
name="amount"
step="0.01"
min="0"
value={Decimal.to_string(@member.membership_fee_type.amount)}
class="input input-bordered w-full"
required
/>
</div>
<%= if @create_cycle_error do %>
<div class="alert alert-error mt-4">
<.icon name="hero-exclamation-circle" class="size-5" />
<span>{@create_cycle_error}</span>
</div>
<% end %>
<div class="modal-action">
<button type="button" phx-click="cancel_create_cycle" phx-target={@myself} class="btn">
{gettext("Cancel")}
</button>
<button type="submit" class="btn btn-primary">{gettext("Create")}</button>
</div>
</form>
</div>
</dialog>
<% end %>
</div> </div>
""" """
end end
@ -273,6 +406,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign_new(:interval_warning, fn -> nil end) |> assign_new(:interval_warning, fn -> nil end)
|> assign_new(:editing_cycle, fn -> nil end) |> assign_new(:editing_cycle, fn -> nil end)
|> assign_new(:deleting_cycle, fn -> nil end) |> assign_new(:deleting_cycle, fn -> nil end)
|> assign_new(:deleting_all_cycles, fn -> false end)
|> assign_new(:delete_all_confirmation, fn -> "" end)
|> assign_new(:creating_cycle, fn -> false end)
|> assign_new(:create_cycle_date, fn -> nil end)
|> assign_new(:create_cycle_error, fn -> nil end)
|> assign_new(:regenerating, fn -> false end)} |> assign_new(:regenerating, fn -> false end)}
end end
@ -509,6 +647,173 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
end end
end end
def handle_event("delete_all_cycles", _params, socket) do
{:noreply,
socket
|> assign(:deleting_all_cycles, true)
|> assign(:delete_all_confirmation, "")}
end
def handle_event("cancel_delete_all_cycles", _params, socket) do
{:noreply,
socket
|> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "")}
end
def handle_event("update_delete_all_confirmation", %{"value" => value}, socket) do
{:noreply, assign(socket, :delete_all_confirmation, value)}
end
def handle_event("confirm_delete_all_cycles", _params, socket) do
member = socket.assigns.member
cycles = socket.assigns.cycles
# Delete all cycles
results =
Enum.map(cycles, fn cycle ->
Ash.destroy(cycle)
end)
# Check if all deletions were successful
errors = Enum.filter(results, &match?({:error, _}, &1))
if Enum.empty?(errors) do
# Reload member to get updated cycles
updated_member =
member
|> Ash.load!([
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
])
updated_cycles =
Enum.sort_by(
updated_member.membership_fee_cycles || [],
& &1.cycle_start,
{:desc, Date}
)
send(self(), {:member_updated, updated_member})
{:noreply,
socket
|> assign(:member, updated_member)
|> assign(:cycles, updated_cycles)
|> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "")
|> put_flash(:info, gettext("All cycles deleted"))}
else
error_msg =
Enum.map_join(errors, ", ", fn {:error, error} -> format_error(error) end)
{:noreply,
socket
|> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "")
|> put_flash(:error, gettext("Failed to delete some cycles: %{errors}", errors: error_msg))}
end
end
def handle_event("open_create_cycle_modal", _params, socket) do
{:noreply,
socket
|> assign(:creating_cycle, true)
|> assign(:create_cycle_date, nil)
|> assign(:create_cycle_error, nil)}
end
def handle_event("cancel_create_cycle", _params, socket) do
{:noreply,
socket
|> assign(:creating_cycle, false)
|> assign(:create_cycle_date, nil)
|> assign(:create_cycle_error, nil)}
end
def handle_event("update_create_cycle_date", %{"value" => date_str}, socket) do
date =
case Date.from_iso8601(date_str) do
{:ok, date} -> date
_ -> nil
end
{:noreply,
socket
|> assign(:create_cycle_date, date)
|> assign(:create_cycle_error, nil)}
end
def handle_event("create_cycle", %{"date" => date_str, "amount" => amount_str}, socket) do
member = socket.assigns.member
with {:ok, date} <- Date.from_iso8601(date_str),
{amount, _} when is_struct(amount, Decimal) <- Decimal.parse(amount_str),
cycle_start <-
CalendarCycles.calculate_cycle_start(date, member.membership_fee_type.interval),
:ok <- validate_cycle_not_exists(socket.assigns.cycles, cycle_start) do
attrs = %{
cycle_start: cycle_start,
amount: amount,
status: :unpaid,
member_id: member.id,
membership_fee_type_id: member.membership_fee_type_id
}
case Ash.create(MembershipFeeCycle, attrs) do
{:ok, _new_cycle} ->
# Reload member with cycles
updated_member =
member
|> Ash.load!([
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
])
cycles =
Enum.sort_by(
updated_member.membership_fee_cycles || [],
& &1.cycle_start,
{:desc, Date}
)
send(self(), {:member_updated, updated_member})
{:noreply,
socket
|> assign(:member, updated_member)
|> assign(:cycles, cycles)
|> assign(:creating_cycle, false)
|> assign(:create_cycle_date, nil)
|> assign(:create_cycle_error, nil)
|> put_flash(:info, gettext("Cycle created successfully"))}
{:error, error} ->
{:noreply,
socket
|> assign(:create_cycle_error, format_error(error))}
end
else
:error ->
{:noreply,
socket
|> assign(:create_cycle_error, gettext("Invalid date format"))}
{:error, :invalid_amount} ->
{:noreply,
socket
|> assign(:create_cycle_error, gettext("Invalid amount format"))}
{:error, :cycle_exists} ->
{:noreply,
socket
|> assign(
:create_cycle_error,
gettext("A cycle for this period already exists")
)}
end
end
# Helper functions # Helper functions
defp get_available_fee_types(member) do defp get_available_fee_types(member) do
@ -559,6 +864,24 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
defp format_error(error) when is_binary(error), do: error defp format_error(error) when is_binary(error), do: error
defp format_error(_error), do: gettext("An error occurred") defp format_error(_error), do: gettext("An error occurred")
defp validate_cycle_not_exists(cycles, cycle_start) do
if Enum.any?(cycles, &(&1.cycle_start == cycle_start)) do
{:error, :cycle_exists}
else
:ok
end
end
defp format_create_cycle_period(date, interval) when is_struct(date, Date) do
cycle_start = CalendarCycles.calculate_cycle_start(date, interval)
cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval)
MembershipFeeHelpers.format_cycle_range(cycle_start, interval) <>
" (#{Calendar.strftime(cycle_start, "%d.%m.%Y")} - #{Calendar.strftime(cycle_end, "%d.%m.%Y")})"
end
defp format_create_cycle_period(_date, _interval), do: ""
# Helper component for section box # Helper component for section box
attr :title, :string, required: true attr :title, :string, required: true
slot :inner_block, required: true slot :inner_block, required: true