+ <.icon name="hero-information-circle" class="size-5" />
+
+ {gettext(
+ "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
+ )}
+
+
+ <% end %>
+
+
+ <%!-- Edit Cycle Amount Modal --%>
+ <%= if @editing_cycle do %>
+
+ <% end %>
+
+ <%!-- Delete Cycle Confirmation Modal --%>
+ <%= if @deleting_cycle do %>
+
+ <% end %>
+
+ """
+ end
+
+ @impl true
+ def update(assigns, socket) do
+ member = assigns.member
+
+ # Load cycles if not already loaded
+ cycles =
+ case member.membership_fee_cycles do
+ nil -> []
+ cycles when is_list(cycles) -> cycles
+ _ -> []
+ end
+
+ # Sort cycles by cycle_start descending (newest first)
+ cycles = Enum.sort_by(cycles, & &1.cycle_start, {:desc, Date})
+
+ # Get available fee types (filtered to same interval if member has a type)
+ available_fee_types = get_available_fee_types(member)
+
+ {:ok,
+ socket
+ |> assign(assigns)
+ |> assign_new(:cycles, fn -> cycles end)
+ |> assign_new(:available_fee_types, fn -> available_fee_types end)
+ |> assign_new(:interval_warning, fn -> nil end)
+ |> assign_new(:editing_cycle, fn -> nil end)
+ |> assign_new(:deleting_cycle, fn -> nil end)
+ |> assign_new(:regenerating, fn -> false end)}
+ end
+
+ @impl true
+ def handle_event("change_membership_fee_type", %{"value" => ""}, socket) do
+ # Remove membership fee type
+ case update_member_fee_type(socket.assigns.member, nil) do
+ {:ok, updated_member} ->
+ send(self(), {:member_updated, updated_member})
+
+ {:noreply,
+ socket
+ |> assign(:member, updated_member)
+ |> assign(:cycles, [])
+ |> assign(:available_fee_types, get_available_fee_types(updated_member))
+ |> assign(:interval_warning, nil)
+ |> put_flash(:info, gettext("Membership fee type removed"))}
+
+ {:error, error} ->
+ {:noreply, put_flash(socket, :error, format_error(error))}
+ end
+ end
+
+ def handle_event("change_membership_fee_type", %{"value" => fee_type_id}, socket) do
+ member = socket.assigns.member
+ new_fee_type = Ash.get!(MembershipFeeType, fee_type_id)
+
+ # Check if interval matches
+ interval_warning =
+ if member.membership_fee_type &&
+ member.membership_fee_type.interval != new_fee_type.interval do
+ gettext(
+ "Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval.",
+ old_interval: MembershipFeeHelpers.format_interval(member.membership_fee_type.interval),
+ new_interval: MembershipFeeHelpers.format_interval(new_fee_type.interval)
+ )
+ else
+ nil
+ end
+
+ if interval_warning do
+ {:noreply, assign(socket, :interval_warning, interval_warning)}
+ else
+ case update_member_fee_type(member, fee_type_id) do
+ {:ok, updated_member} ->
+ # Reload member with cycles
+ updated_member =
+ updated_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(:available_fee_types, get_available_fee_types(updated_member))
+ |> assign(:interval_warning, nil)
+ |> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))}
+
+ {:error, error} ->
+ {:noreply, put_flash(socket, :error, format_error(error))}
+ end
+ end
+ end
+
+ def handle_event("mark_cycle_status", %{"cycle_id" => cycle_id, "status" => status_str}, socket) do
+ status = String.to_existing_atom(status_str)
+ cycle = find_cycle(socket.assigns.cycles, cycle_id)
+
+ action =
+ case status do
+ :paid -> :mark_as_paid
+ :unpaid -> :mark_as_unpaid
+ :suspended -> :mark_as_suspended
+ end
+
+ case Ash.update!(cycle, action) do
+ updated_cycle ->
+ updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
+
+ {:noreply,
+ socket
+ |> assign(:cycles, updated_cycles)
+ |> put_flash(:info, gettext("Cycle status updated"))}
+ end
+ end
+
+ def handle_event("regenerate_cycles", _params, socket) do
+ member = socket.assigns.member
+
+ case CycleGenerator.generate_cycles_for_member(member.id) do
+ {:ok, _new_cycles} ->
+ # 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(:regenerating, false)
+ |> put_flash(:info, gettext("Cycles regenerated successfully"))}
+
+ {:error, error} ->
+ {:noreply,
+ socket
+ |> assign(:regenerating, false)
+ |> put_flash(:error, format_error(error))}
+ end
+ end
+
+ def handle_event("regenerate_missing_cycles", _params, socket) do
+ # Same as regenerate_cycles - CycleGenerator already handles missing cycles only
+ handle_event("regenerate_cycles", %{}, socket)
+ end
+
+ def handle_event("edit_cycle_amount", %{"cycle_id" => cycle_id}, socket) do
+ cycle = find_cycle(socket.assigns.cycles, cycle_id)
+
+ # Load cycle with membership_fee_type for display
+ cycle = Ash.load!(cycle, :membership_fee_type)
+
+ {:noreply, assign(socket, :editing_cycle, cycle)}
+ end
+
+ def handle_event("cancel_edit_amount", _params, socket) do
+ {:noreply, assign(socket, :editing_cycle, nil)}
+ end
+
+ def handle_event("save_cycle_amount", %{"cycle_id" => cycle_id, "amount" => amount_str}, socket) do
+ cycle = find_cycle(socket.assigns.cycles, cycle_id)
+
+ case Decimal.parse(amount_str) do
+ {amount, _} when is_struct(amount, Decimal) ->
+ case Ash.update(cycle, :update, %{amount: amount}) do
+ {:ok, updated_cycle} ->
+ updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
+
+ {:noreply,
+ socket
+ |> assign(:cycles, updated_cycles)
+ |> assign(:editing_cycle, nil)
+ |> put_flash(:info, gettext("Cycle amount updated"))}
+
+ {:error, error} ->
+ {:noreply,
+ socket
+ |> put_flash(:error, format_error(error))}
+ end
+
+ :error ->
+ {:noreply, put_flash(socket, :error, gettext("Invalid amount format"))}
+ end
+ end
+
+ def handle_event("delete_cycle", %{"cycle_id" => cycle_id}, socket) do
+ cycle = find_cycle(socket.assigns.cycles, cycle_id)
+
+ # Load cycle with membership_fee_type for display
+ cycle = Ash.load!(cycle, :membership_fee_type)
+
+ {:noreply, assign(socket, :deleting_cycle, cycle)}
+ end
+
+ def handle_event("cancel_delete_cycle", _params, socket) do
+ {:noreply, assign(socket, :deleting_cycle, nil)}
+ end
+
+ def handle_event("confirm_delete_cycle", %{"cycle_id" => cycle_id}, socket) do
+ cycle = find_cycle(socket.assigns.cycles, cycle_id)
+
+ case Ash.destroy(cycle) do
+ :ok ->
+ updated_cycles = Enum.reject(socket.assigns.cycles, &(&1.id == cycle_id))
+
+ {:noreply,
+ socket
+ |> assign(:cycles, updated_cycles)
+ |> assign(:deleting_cycle, nil)
+ |> put_flash(:info, gettext("Cycle deleted"))}
+
+ {:error, error} ->
+ {:noreply,
+ socket
+ |> assign(:deleting_cycle, nil)
+ |> put_flash(:error, format_error(error))}
+ end
+ end
+
+ # Helper functions
+
+ defp get_available_fee_types(member) do
+ all_types =
+ MembershipFeeType
+ |> Ash.Query.sort(name: :asc)
+ |> Ash.read!()
+
+ # If member has a fee type, filter to same interval
+ if member.membership_fee_type do
+ Enum.filter(all_types, fn type ->
+ type.interval == member.membership_fee_type.interval
+ end)
+ else
+ all_types
+ end
+ end
+
+ defp update_member_fee_type(member, fee_type_id) do
+ attrs = %{membership_fee_type_id: fee_type_id}
+
+ member
+ |> Ash.Changeset.for_update(:update_member, attrs, domain: Membership)
+ |> Ash.update(domain: Membership)
+ end
+
+ defp find_cycle(cycles, cycle_id) do
+ case Enum.find(cycles, &(&1.id == cycle_id)) do
+ nil -> raise "Cycle not found: #{cycle_id}"
+ cycle -> cycle
+ end
+ end
+
+ defp replace_cycle(cycles, updated_cycle) do
+ Enum.map(cycles, fn cycle ->
+ if cycle.id == updated_cycle.id, do: updated_cycle, else: cycle
+ end)
+ end
+
+ defp format_status_label(:paid), do: gettext("Paid")
+ defp format_status_label(:unpaid), do: gettext("Unpaid")
+ defp format_status_label(:suspended), do: gettext("Suspended")
+
+ defp format_error(%Ash.Error.Invalid{} = error) do
+ Enum.map_join(error.errors, ", ", fn e -> e.message end)
+ end
+
+ defp format_error(error) when is_binary(error), do: error
+ defp format_error(_error), do: gettext("An error occurred")
+
+ # Helper component for section box
+ attr :title, :string, required: true
+ slot :inner_block, required: true
+
+ defp section_box(assigns) do
+ ~H"""
+
+