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}>
-
-
-
+
+ <.icon name="hero-check-circle" class="size-4" />
+ {gettext("Paid")}
+
+
+ <.icon name="hero-pause-circle" class="size-4" />
+ {gettext("Suspended")}
+
+
+ <.icon name="hero-x-circle" class="size-4" />
+ {gettext("Unpaid")}
+
+
+ <.icon name="hero-trash" class="size-4" />
+ {gettext("Delete")}
+
@@ -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