From 128866ead361eea6de2cbd41131320cdde6242c4 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 16 Dec 2025 17:24:26 +0100 Subject: [PATCH] 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. --- .../show/membership_fees_component.ex | 471 +++++++++++++++--- 1 file changed, 397 insertions(+), 74 deletions(-) diff --git a/lib/mv_web/live/member_live/show/membership_fees_component.ex b/lib/mv_web/live/member_live/show/membership_fees_component.ex index fe0030a..2ccac15 100644 --- a/lib/mv_web/live/member_live/show/membership_fees_component.ex +++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -16,7 +16,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do alias Mv.Membership alias Mv.MembershipFees.MembershipFeeType + alias Mv.MembershipFees.MembershipFeeCycle alias Mv.MembershipFees.CycleGenerator + alias Mv.MembershipFees.CalendarCycles alias MvWeb.Helpers.MembershipFeeHelpers @impl true @@ -56,6 +58,26 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do <.icon name="hero-arrow-path" class="size-4" /> {if(@regenerating, do: gettext("Regenerating..."), else: gettext("Regenerate Cycles"))} + <.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 + :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")} + <%!-- Cycles Table --%> @@ -79,7 +101,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do <:col :let={cycle} label={gettext("Amount")}> - {MembershipFeeHelpers.format_currency(cycle.amount)} + + {MembershipFeeHelpers.format_currency(cycle.amount)} + <:col :let={cycle} label={gettext("Status")}> @@ -92,80 +122,59 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do <:action :let={cycle}> - @@ -243,6 +252,130 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do <% end %> + + <%!-- Delete All Cycles Confirmation Modal --%> + <%= if @deleting_all_cycles do %> + + + + <% end %> + + <%!-- Create Cycle Modal --%> + <%= if @creating_cycle do %> + + + + <% end %> """ end @@ -273,6 +406,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do |> assign_new(:interval_warning, fn -> nil end) |> assign_new(:editing_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)} end @@ -509,6 +647,173 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do 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 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), 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 attr :title, :string, required: true slot :inner_block, required: true